Compare commits

..

12 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
175 changed files with 27380 additions and 19 deletions

149
README.md
View File

@@ -23,8 +23,12 @@ luxtools provides DokuWiki syntax that:
- Lists a directory's direct children (files + folders) or files matching a glob pattern
- Renders an image thumbnail gallery (with lightbox)
- Groups multiple `{{image>...}}` blocks in compact grid/flex layouts
- Provides "open this folder/path" links for local workflows
- Embeds file-backed scratchpads with a minimal inline editor (no wiki revisions)
- Links a page to a media folder via a UUID (.pagelink), enabling a `blobs/` alias
- Adds a Page ID download link in the page info area to fetch a `.pagelink` file
- Renders a basic calendar widget with clickable day links to chronological pages
It also ships a small file-serving endpoint (`lib/plugins/luxtools/file.php`) used
to deliver files and generate cached thumbnails.
@@ -46,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.
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)
@@ -96,15 +107,20 @@ Key settings:
- **paths**
Allowed base filesystem roots (one per line). Each root can be followed by:
- `A> /Alias/` (optional) alias used in wiki syntax and open links
- `A> Alias` (optional) alias used in wiki syntax and open links
Example:
```
/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:
`lib/plugins/luxtools/file.php?root=...&file=...`
@@ -141,6 +157,24 @@ Key settings:
URL of a local client service used by `{{open>...}}` and directory links.
See luxtools-client.
- **image_base_path**
Base filesystem path used for chronological photo integration.
On canonical day pages (`chronological:YYYY:MM:DD`), files that start with
`YYYY-MM-DD` are listed automatically.
If a yearly subfolder exists (for example `.../2026/`), it is preferred.
- **calendar_ics_files**
Local calendar `.ics` files (one absolute file path per line).
Events are parsed by `sabre/vobject` and shown on matching chronological day pages.
Recurrence and exclusions from the ICS are respected. For timed entries, the
page stores the original timestamp and renders the visible time in the
browser's local timezone.
Multi-day events appear on each overlapping day.
- **pagelink_search_depth**
Maximum directory depth for `.pagelink` discovery under each configured root.
`0` means only the root directory itself is checked.
### Template style settings
The `{{open>...}}` links and directory “open” links use a dedicated color
@@ -206,6 +240,66 @@ Supported input examples include:
- `2026-01-30 13:45`
- `2026-01-30T13:45:00`
### 0.2) Page Link: link a page to a folder
Page linking uses a page-scoped UUID stored in page metadata. This UUID is used
to link the page to a folder that contains a `.pagelink` file with the same UUID.
The Page Link workflow is driven by the **Page ID link** in the page info area
(page footer, `.docInfo`):
1. **Link Page** (page has no UUID yet)
Creates the UUID and downloads a `.pagelink` file.
2. **Download Link File** (page has UUID, but no linked folder found)
Downloads the `.pagelink` file.
3. **Unlink Page** (page is linked)
Prompts for confirmation, removes the `.pagelink` file from the linked folder
(if found), removes the UUID from the page, and refreshes the page.
After downloading the `.pagelink` file, place it into the folder you want to
link (within your configured `paths` roots). Once DokuWiki can discover it,
the page becomes “linked”.
Once linked, you can use `blobs/` as an alias in luxtools syntax on that page,
for example:
```
{{images>blobs/*.png}}
{{directory>blobs/&recursive=1}}
```
### 0.3) Calendar widget
Render a basic monthly calendar that links each day to canonical chronological pages:
```
{{calendar>}}
{{calendar>2024-10}}
```
Notes:
- `{{calendar>}}` renders the current month.
- `{{calendar>YYYY-MM}}` renders a specific month.
- Day links target `chronological:YYYY:MM:DD`.
- Header month/year links target `chronological:YYYY:MM` and `chronological:YYYY`.
- Prev/next month buttons update the widget in-place without a full page reload.
- Month switches fetch server-rendered widget HTML via AJAX and replace only the widget node.
### 0.4) Virtual chronological day pages
When a canonical day page (for example `chronological:2026:02:13`) does not yet
exist, luxtools renders a virtual page in normal show mode instead of the
default "page does not exist" output.
The virtual page includes:
- a German-formatted heading (for example `Freitag, 13. Februar 2026`)
- matching local calendar events from configured `.ics` files (when available)
- matching day photos (via existing `{{images>...}}` rendering) when available
The page is only created once you edit and save actual content.
### 1) List files by glob pattern
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
@@ -271,7 +365,52 @@ The image links to the full-size version when clicked.
Remote images (HTTP/HTTPS URLs) are linked directly without proxying or thumbnailing.
### 5) Open a local path/folder (best-effort)
### 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}}
@@ -283,7 +422,7 @@ Behaviour:
- Prefer calling the configured local client service (open_service_url).
- Fall back to opening a file:// URL in a new tab (often blocked by browsers).
### 6) Scratchpads (shared, file-backed, no page revisions)
### 7) Scratchpads (shared, file-backed, no page revisions)
```
{{scratchpad>start}}
@@ -291,7 +430,7 @@ 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.
### 7) Link Favicons (automatic)
### 8) Link Favicons (automatic)
External links automatically display the favicon of the linked website. This feature:

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()
{
$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');
$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

@@ -239,6 +239,101 @@ class plugin_luxtools_test extends DokuWikiTest
$this->assertStringContainsString('height="150"', $xhtml);
}
/**
* Grouping wrapper should use default flex mode with zero gap.
*/
public function test_grouping_default_flex()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping>'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$structure = [
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
'div.luxtools-grouping .luxtools-imagebox' => 2,
];
$this->structureCheck($doc, $structure);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-cols: 2', $style);
$this->assertStringContainsString('--luxtools-grouping-gap: 0', $style);
$this->assertStringContainsString('--luxtools-grouping-justify: start', $style);
$this->assertStringContainsString('--luxtools-grouping-align: start', $style);
}
/**
* Grouping wrapper should accept custom flex layout and gap.
*/
public function test_grouping_custom_flex()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping layout="flex" gap="8px">'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$structure = [
'div.luxtools-grouping.luxtools-grouping--flex' => 1,
'div.luxtools-grouping .luxtools-imagebox' => 2,
];
$this->structureCheck($doc, $structure);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-gap: 8px', $style);
}
/**
* Grouping wrapper should accept justify and align controls.
*/
public function test_grouping_justify_and_align()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping layout="flex" justify="space-between" align="center">'
. '{{image>' . $imagePath . '|One|120}}'
. '{{image>' . $imagePath . '|Two|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$doc = new Document();
$doc->html($xhtml);
$style = (string)$doc->find('div.luxtools-grouping')->first()->attr('style');
$this->assertStringContainsString('--luxtools-grouping-justify: space-between', $style);
$this->assertStringContainsString('--luxtools-grouping-align: center', $style);
}
/**
* Unknown grouping attributes should render a warning string.
*/
public function test_grouping_unknown_option_warning()
{
$imagePath = TMP_DIR . '/filelistdata/exampleimage.png';
$syntax = '<grouping gpa="0.5rem">'
. '{{image>' . $imagePath . '|One|120}}'
. '</grouping>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
$this->assertStringContainsString('[grouping: unknown option(s): gpa]', $xhtml);
}
/**
* Ensure the built-in file endpoint includes the host page id so file.php can
* enforce per-page ACL.
@@ -309,4 +404,121 @@ class plugin_luxtools_test extends DokuWikiTest
// Directory row should trigger the same behaviour as {{open>...}} for that folder
$this->assertStringContainsString('data-path="/Scape/exampledir"', $xhtml);
}
/**
* Strict ISO dates in plain text should be auto-linked to canonical day IDs.
*/
public function test_auto_link_iso_date_plain_text()
{
$instructions = p_get_instructions('Meeting with John on 2024-10-24.');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, '>2024-10-24</a>') === false) {
throw new \Exception('Auto-link text for 2024-10-24 not found');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') === false) {
throw new \Exception('Auto-link target chronological:2024:10:24 not found');
}
}
/**
* Auto-linking must not run inside code blocks.
*/
public function test_auto_link_skips_code_blocks()
{
$syntax = 'Outside date 2024-10-25.' . "\n\n" . '<code>Inside code 2024-10-24</code>';
$instructions = p_get_instructions($syntax);
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, '>2024-10-25</a>') === false) {
throw new \Exception('Outside date 2024-10-25 was not auto-linked');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:25') === false) {
throw new \Exception('Outside auto-link target chronological:2024:10:25 not found');
}
if (strpos(urldecode($xhtml), 'chronological:2024:10:24') !== false) {
throw new \Exception('Date inside code block was incorrectly auto-linked');
}
if (strpos($xhtml, 'Inside code 2024-10-24') === false) {
throw new \Exception('Code block content was unexpectedly altered');
}
}
/**
* Calendar widget should render links to canonical day IDs.
*/
public function test_calendar_widget_links_canonical_day_ids()
{
$instructions = p_get_instructions('{{calendar>2024-10}}');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, 'luxtools-calendar') === false) {
throw new \Exception('Calendar container not rendered');
}
$decoded = urldecode($xhtml);
if (strpos($decoded, 'chronological:2024:10:01') === false) {
throw new \Exception('Expected canonical day link for 2024-10-01 not found');
}
if (strpos($decoded, 'chronological:2024:10:31') === false) {
throw new \Exception('Expected canonical day link for 2024-10-31 not found');
}
if (strpos($decoded, 'chronological:2024:10') === false) {
throw new \Exception('Expected month link chronological:2024:10 not found in header');
}
if (strpos($decoded, 'chronological:2024') === false) {
throw new \Exception('Expected year link chronological:2024 not found in header');
}
if (strpos($decoded, 'chronological:2024:09') === false) {
throw new \Exception('Expected previous month canonical ID chronological:2024:09 not found');
}
if (strpos($decoded, 'chronological:2024:11') === false) {
throw new \Exception('Expected next month canonical ID chronological:2024:11 not found');
}
if (strpos($xhtml, 'luxtools-calendar-nav') === false) {
throw new \Exception('Calendar navigation container not rendered');
}
if (strpos($xhtml, 'luxtools-calendar-nav-button') === false) {
throw new \Exception('Calendar navigation buttons not rendered');
}
if (strpos($xhtml, 'data-luxtools-calendar="1"') === false) {
throw new \Exception('Calendar JS state attribute not rendered');
}
if (strpos($xhtml, 'data-luxtools-ajax-url=') === false) {
throw new \Exception('Calendar AJAX endpoint metadata not rendered');
}
if (strpos($xhtml, 'luxtools-calendar-day') === false || strpos($xhtml, '<td class="luxtools-calendar-day') === false) {
throw new \Exception('Calendar day cells not rendered as expected');
}
}
/**
* Empty calendar target should default to current month rendering.
*/
public function test_calendar_widget_defaults_to_current_month()
{
$instructions = p_get_instructions('{{calendar>}}');
$xhtml = p_render('xhtml', $instructions, $info);
if (strpos($xhtml, 'luxtools-calendar-table') === false) {
throw new \Exception('Calendar table not rendered for default month');
}
$today = date('Y-m-d');
$parts = explode('-', $today);
$expected = 'chronological:' . $parts[0] . ':' . $parts[1] . ':' . $parts[2];
if (strpos(urldecode($xhtml), $expected) === false) {
throw new \Exception('Expected canonical link for current date not found: ' . $expected);
}
}
}

View File

@@ -3,14 +3,23 @@
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(Doku_Event_Handler $controller)
public function register(EventHandler $controller)
{
$controller->register_hook(
"TPL_METAHEADER_OUTPUT",
@@ -18,12 +27,48 @@ class action_plugin_luxtools extends ActionPlugin
$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",
@@ -49,7 +94,9 @@ class action_plugin_luxtools extends ActionPlugin
"open-service.js",
"scratchpads.js",
"date-fix.js",
"page-link.js",
"linkfavicon.js",
"calendar-widget.js",
"main.js",
];
@@ -61,6 +108,46 @@ class action_plugin_luxtools extends ActionPlugin
}
}
/**
* 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.
*
@@ -82,6 +169,378 @@ class action_plugin_luxtools extends ActionPlugin
$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.
*

View File

@@ -28,6 +28,9 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
'thumb_placeholder',
'gallery_thumb_scale',
'open_service_url',
'image_base_path',
'calendar_ics_files',
'pagelink_search_depth',
];
public function getMenuText($language)
@@ -85,6 +88,15 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
$newConf['thumb_placeholder'] = $INPUT->str('thumb_placeholder');
$newConf['gallery_thumb_scale'] = $INPUT->str('gallery_thumb_scale');
$newConf['open_service_url'] = $INPUT->str('open_service_url');
$newConf['image_base_path'] = $INPUT->str('image_base_path');
$icsFiles = $INPUT->str('calendar_ics_files');
$icsFiles = str_replace(["\r\n", "\r"], "\n", $icsFiles);
$newConf['calendar_ics_files'] = $icsFiles;
$depth = (int)$INPUT->int('pagelink_search_depth');
if ($depth < 0) $depth = 0;
$newConf['pagelink_search_depth'] = $depth;
if ($this->savePluginLocalConf($newConf)) {
msg($this->getLang('saved'), 1);
@@ -223,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 '</label><br />';
// image_base_path
echo '<label class="block"><span>' . hsc($this->getLang('image_base_path')) . '</span> ';
echo '<input type="text" class="edit" name="image_base_path" value="' . hsc((string)$this->getConf('image_base_path')) . '" />';
echo '</label><br />';
// calendar_ics_files
$icsFiles = $this->normalizeMultilineDisplay((string)$this->getConf('calendar_ics_files'), 'calendar_ics_files');
echo '<label class="block"><span>' . hsc($this->getLang('calendar_ics_files')) . '</span><br />';
echo '<textarea name="calendar_ics_files" rows="4" cols="80" class="edit">' . hsc($icsFiles) . '</textarea>';
echo '</label><br />';
// pagelink_search_depth
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
echo '</label><br />';
echo '<button type="submit" class="button">' . hsc($this->getLang('btn_save')) . '</button>';
echo '</fieldset>';

View File

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

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

@@ -34,6 +34,15 @@ $conf['gallery_thumb_scale'] = 1;
// Local client service used by {{open>...}}.
$conf['open_service_url'] = 'http://127.0.0.1:8765';
// Base filesystem path for chronological photo integration.
$conf['image_base_path'] = '';
// Local calendar ICS files (one absolute file path per line).
$conf['calendar_ics_files'] = '';
// Maximum depth when searching for .pagelink files under allowed roots.
$conf['pagelink_search_depth'] = 3;
// Image syntax defaults
$conf['default_image_width'] = 250;
$conf['default_image_align'] = 'right'; // left|right|center

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

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

View File

@@ -8,6 +8,7 @@
var OpenService = Luxtools.OpenService;
var GalleryThumbnails = Luxtools.GalleryThumbnails;
var Scratchpads = Luxtools.Scratchpads;
var CalendarWidget = Luxtools.CalendarWidget;
// ============================================================
// Click Handlers
@@ -79,12 +80,49 @@
});
}
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();

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

View File

@@ -62,6 +62,12 @@ $lang["gallery_thumb_scale"] =
"Skalierungsfaktor für Galerie-Thumbnails. 2 erzeugt schärfere Thumbnails auf HiDPI-Displays (Anzeige bleibt 150×150).";
$lang["open_service_url"] =
"URL des lokalen Client-Dienstes für {{open>...}} (z.B. http://127.0.0.1:8765).";
$lang["image_base_path"] =
"Basis-Dateisystempfad für die chronologische Foto-Integration.";
$lang["calendar_ics_files"] =
"Lokale Kalender-.ics-Dateien (ein absoluter Dateipfad pro Zeile).";
$lang["pagelink_search_depth"] =
"Maximale Verzeichnisebene für .pagelink-Suche (0 = nur Root).";
$lang["scratchpad_edit"] = "Scratchpad bearbeiten";
$lang["scratchpad_save"] = "Speichern";
@@ -73,3 +79,9 @@ $lang["scratchpad_err_unreadable"] = "Scratchpad-Datei ist nicht lesbar";
$lang["toolbar_code_title"] = "Code-Block";
$lang["toolbar_code_sample"] = "Ihr Code hier";
$lang["toolbar_datefix_title"] = "Datums-Fix";
$lang["toolbar_datefix_all_title"] = "Datums-Fix (Alle)";
$lang["pagelink_unlinked"] = "Seite nicht verknüpft";
$lang["pagelink_multi_warning"] = "Mehrere Ordner verknüpft";
$lang["chronological_photos_title"] = "Fotos";
$lang["chronological_events_title"] = "Termine";

View File

@@ -26,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["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

@@ -62,6 +62,12 @@ $lang["gallery_thumb_scale"] =
"Gallery thumbnail scale factor. Use 2 for sharper thumbnails on HiDPI screens (still displayed as 150×150).";
$lang["open_service_url"] =
"Local client service URL for the {{open>...}} button (e.g. http://127.0.0.1:8765).";
$lang["image_base_path"] =
"Base filesystem path for chronological photo integration.";
$lang["calendar_ics_files"] =
"Local calendar .ics files (one absolute file path per line).";
$lang["pagelink_search_depth"] =
"Maximum directory depth for .pagelink search (0 = only root).";
$lang["scratchpad_edit"] = "Edit scratchpad";
$lang["scratchpad_save"] = "Save";
@@ -75,3 +81,8 @@ $lang["toolbar_code_title"] = "Code Block";
$lang["toolbar_code_sample"] = "your code here";
$lang["toolbar_datefix_title"] = "Date Fix";
$lang["toolbar_datefix_all_title"] = "Date Fix (All)";
$lang["pagelink_unlinked"] = "Page not linked";
$lang["pagelink_multi_warning"] = "Multiple folders linked";
$lang["calendar_err_badmonth"] = "Invalid calendar month. Use YYYY-MM.";
$lang["chronological_photos_title"] = "Photos";
$lang["chronological_events_title"] = "Events";

View File

@@ -26,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['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']);

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

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
* "/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
* normalization of slashes and dot-segments).
@@ -117,12 +117,17 @@ class Path
// normalize input for matching, but do not force a trailing slash
$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 = [];
foreach ($this->paths as $key => $info) {
if (!isset($info['root']) || $key !== $info['root']) 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;
@@ -131,16 +136,34 @@ class Path
uksort($mappings, static fn($a, $b) => strlen($b) - strlen($a));
foreach ($mappings as $root => $alias) {
$rootNoTrailingSlash = rtrim($root, '/');
if (!str_starts_with($normalized, $root) && $normalized !== $rootNoTrailingSlash) continue;
$suffix = '';
if (str_starts_with($normalized, $root)) {
$suffix = substr($normalized, strlen($root));
$alias = static::cleanPath($alias, true);
return rtrim($alias, '/') . '/' . $suffix;
$suffix = (string)substr($normalized, strlen($root));
}
$suffix = ltrim($suffix, '/');
return $alias . '>' . $suffix;
}
return $normalized;
}
/**
* Convert legacy path-like aliases (e.g. /Scape/) to open aliases (Scape).
*
* @param string $alias
* @return string
*/
protected function normalizeOpenAlias($alias)
{
$alias = trim($alias);
$alias = trim($alias, '/\\');
return $alias;
}
/**
* Clean a path for better comparison
*

149
style.css
View File

@@ -49,6 +49,19 @@ div.luxtools-plugin .luxtools-empty {
padding: 0.25em 0;
}
/* Page link status (unlinked blobs alias) */
span.luxtools-pagelink-status {
display: inline-block;
font-size: 0.85em;
line-height: 1.3;
margin: 0.25em 0;
padding: 0.15em 0.4em;
border: 1px solid @ini_border;
border-radius: 0.2em;
background-color: @ini_background_alt;
color: inherit;
}
/* Image gallery spacing. */
div.luxtools-gallery {
padding-bottom: 0.5em;
@@ -409,6 +422,31 @@ html.luxtools-noscroll body {
}
}
/* ========================================================================
* 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)
* ======================================================================== */
@@ -470,3 +508,114 @@ html.luxtools-noscroll body {
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

@@ -4,6 +4,7 @@ use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Crawler;
use dokuwiki\plugin\luxtools\Output;
use dokuwiki\plugin\luxtools\Path;
use dokuwiki\plugin\luxtools\PageLinkTrait;
require_once(__DIR__ . '/../autoload.php');
@@ -15,6 +16,7 @@ require_once(__DIR__ . '/../autoload.php');
if (!class_exists('syntax_plugin_luxtools_abstract', false)) {
abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
{
use PageLinkTrait;
/**
* Returns the syntax keyword (e.g., 'files', 'directory', 'images').
* Used for pattern matching and plugin registration.
@@ -208,7 +210,12 @@ abstract class syntax_plugin_luxtools_abstract extends SyntaxPlugin
protected function getPathInfoSafe(string $basePath, \Doku_Renderer $renderer)
{
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);
} catch (\Exception $e) {
$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,
];
}
}

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

View File

@@ -2,6 +2,7 @@
use dokuwiki\Extension\SyntaxPlugin;
use dokuwiki\plugin\luxtools\Path;
use dokuwiki\plugin\luxtools\PageLinkTrait;
use dokuwiki\plugin\luxtools\ThumbnailHelper;
require_once(__DIR__ . '/../autoload.php');
@@ -15,6 +16,7 @@ require_once(__DIR__ . '/../autoload.php');
*/
class syntax_plugin_luxtools_image extends SyntaxPlugin
{
use PageLinkTrait;
/** @inheritdoc */
public function getType()
{
@@ -132,7 +134,13 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
}
try {
$pathHelper = new Path($this->getConf('paths'));
$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) {
@@ -244,8 +252,9 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
$outerStyle = ' style="width: ' . ($width + 10) . 'px;"';
}
// Use thumbnail metadata from helper
$dataThumbAttr = $thumb['isFinal'] ? '' : ' data-thumb-src="' . hsc($thumb['thumbUrl']) . '"';
// 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"';
@@ -264,7 +273,7 @@ class syntax_plugin_luxtools_image extends SyntaxPlugin
// 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>';;
$renderer->doc .= '</a>';
// Caption
if ($caption !== '') {

View File

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

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

View File

@@ -0,0 +1,138 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeImmutable;
use DateTimeInterface;
use Sabre\VObject;
use Sabre\VObject\InvalidDataException;
/**
* VAlarm component.
*
* This component contains some additional functionality specific for VALARMs.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VAlarm extends VObject\Component
{
/**
* Returns a DateTime object when this alarm is going to trigger.
*
* This ignores repeated alarm, only the first trigger is returned.
*
* @return DateTimeImmutable
*/
public function getEffectiveTriggerTime()
{
$trigger = $this->TRIGGER;
if (!isset($trigger['VALUE']) || 'DURATION' === strtoupper($trigger['VALUE'])) {
$triggerDuration = VObject\DateTimeParser::parseDuration($this->TRIGGER);
$related = (isset($trigger['RELATED']) && 'END' == strtoupper($trigger['RELATED'])) ? 'END' : 'START';
$parentComponent = $this->parent;
if ('START' === $related) {
if ('VTODO' === $parentComponent->name) {
$propName = 'DUE';
} else {
$propName = 'DTSTART';
}
$effectiveTrigger = $parentComponent->$propName->getDateTime();
$effectiveTrigger = $effectiveTrigger->add($triggerDuration);
} else {
if ('VTODO' === $parentComponent->name) {
$endProp = 'DUE';
} elseif ('VEVENT' === $parentComponent->name) {
$endProp = 'DTEND';
} else {
throw new InvalidDataException('time-range filters on VALARM components are only supported when they are a child of VTODO or VEVENT');
}
if (isset($parentComponent->$endProp)) {
$effectiveTrigger = $parentComponent->$endProp->getDateTime();
$effectiveTrigger = $effectiveTrigger->add($triggerDuration);
} elseif (isset($parentComponent->DURATION)) {
$effectiveTrigger = $parentComponent->DTSTART->getDateTime();
$duration = VObject\DateTimeParser::parseDuration($parentComponent->DURATION);
$effectiveTrigger = $effectiveTrigger->add($duration);
$effectiveTrigger = $effectiveTrigger->add($triggerDuration);
} else {
$effectiveTrigger = $parentComponent->DTSTART->getDateTime();
$effectiveTrigger = $effectiveTrigger->add($triggerDuration);
}
}
} else {
$effectiveTrigger = $trigger->getDateTime();
}
return $effectiveTrigger;
}
/**
* Returns true or false depending on if the event falls in the specified
* time-range. This is used for filtering purposes.
*
* The rules used to determine if an event falls within the specified
* time-range is based on the CalDAV specification.
*
* @param DateTime $start
* @param DateTime $end
*
* @return bool
*/
public function isInTimeRange(DateTimeInterface $start, DateTimeInterface $end)
{
$effectiveTrigger = $this->getEffectiveTriggerTime();
if (isset($this->DURATION)) {
$duration = VObject\DateTimeParser::parseDuration($this->DURATION);
$repeat = (string) $this->REPEAT;
if (!$repeat) {
$repeat = 1;
}
$period = new \DatePeriod($effectiveTrigger, $duration, (int) $repeat);
foreach ($period as $occurrence) {
if ($start <= $occurrence && $end > $occurrence) {
return true;
}
}
return false;
} else {
return $start <= $effectiveTrigger && $end > $effectiveTrigger;
}
}
/**
* 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 [
'ACTION' => 1,
'TRIGGER' => 1,
'DURATION' => '?',
'REPEAT' => '?',
'ATTACH' => '?',
];
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use Sabre\VObject;
/**
* The VAvailability component.
*
* This component adds functionality to a component, specific for VAVAILABILITY
* components.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Ivan Enderlin
* @license http://sabre.io/license/ Modified BSD License
*/
class VAvailability extends VObject\Component
{
/**
* Returns true or false depending on if the event falls in the specified
* time-range. This is used for filtering purposes.
*
* The rules used to determine if an event falls within the specified
* time-range is based on:
*
* https://tools.ietf.org/html/draft-daboo-calendar-availability-05#section-3.1
*
* @return bool
*/
public function isInTimeRange(DateTimeInterface $start, DateTimeInterface $end)
{
list($effectiveStart, $effectiveEnd) = $this->getEffectiveStartEnd();
return
(is_null($effectiveStart) || $start < $effectiveEnd) &&
(is_null($effectiveEnd) || $end > $effectiveStart)
;
}
/**
* 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 = null;
$effectiveEnd = null;
if (isset($this->DTSTART)) {
$effectiveStart = $this->DTSTART->getDateTime();
}
if (isset($this->DTEND)) {
$effectiveEnd = $this->DTEND->getDateTime();
} elseif ($effectiveStart && isset($this->DURATION)) {
$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,
'DTSTAMP' => 1,
'BUSYTYPE' => '?',
'CLASS' => '?',
'CREATED' => '?',
'DESCRIPTION' => '?',
'DTSTART' => '?',
'LAST-MODIFIED' => '?',
'ORGANIZER' => '?',
'PRIORITY' => '?',
'SEQUENCE' => '?',
'SUMMARY' => '?',
'URL' => '?',
'DTEND' => '?',
'DURATION' => '?',
'CATEGORIES' => '*',
'COMMENT' => '*',
'CONTACT' => '*',
];
}
/**
* 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;
}
}

View File

@@ -0,0 +1,528 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject;
use Sabre\VObject\Component;
use Sabre\VObject\InvalidDataException;
use Sabre\VObject\Property;
use Sabre\VObject\Recur\EventIterator;
use Sabre\VObject\Recur\NoInstancesException;
/**
* The VCalendar component.
*
* This component adds functionality to a component, specific for a VCALENDAR.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VCalendar extends VObject\Document
{
/**
* The default name for this component.
*
* This should be 'VCALENDAR' or 'VCARD'.
*
* @var string
*/
public static $defaultName = 'VCALENDAR';
/**
* This is a list of components, and which classes they should map to.
*
* @var array
*/
public static $componentMap = [
'VCALENDAR' => self::class,
'VALARM' => VAlarm::class,
'VEVENT' => VEvent::class,
'VFREEBUSY' => VFreeBusy::class,
'VAVAILABILITY' => VAvailability::class,
'AVAILABLE' => Available::class,
'VJOURNAL' => VJournal::class,
'VTIMEZONE' => VTimeZone::class,
'VTODO' => VTodo::class,
];
/**
* List of value-types, and which classes they map to.
*
* @var array
*/
public static $valueMap = [
'BINARY' => VObject\Property\Binary::class,
'BOOLEAN' => VObject\Property\Boolean::class,
'CAL-ADDRESS' => VObject\Property\ICalendar\CalAddress::class,
'DATE' => VObject\Property\ICalendar\Date::class,
'DATE-TIME' => VObject\Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class,
'FLOAT' => VObject\Property\FloatValue::class,
'INTEGER' => VObject\Property\IntegerValue::class,
'PERIOD' => VObject\Property\ICalendar\Period::class,
'RECUR' => VObject\Property\ICalendar\Recur::class,
'TEXT' => VObject\Property\Text::class,
'TIME' => VObject\Property\Time::class,
'UNKNOWN' => VObject\Property\Unknown::class, // jCard / jCal-only.
'URI' => VObject\Property\Uri::class,
'UTC-OFFSET' => VObject\Property\UtcOffset::class,
];
/**
* List of properties, and which classes they map to.
*
* @var array
*/
public static $propertyMap = [
// Calendar properties
'CALSCALE' => VObject\Property\FlatText::class,
'METHOD' => VObject\Property\FlatText::class,
'PRODID' => VObject\Property\FlatText::class,
'VERSION' => VObject\Property\FlatText::class,
// Component properties
'ATTACH' => VObject\Property\Uri::class,
'CATEGORIES' => VObject\Property\Text::class,
'CLASS' => VObject\Property\FlatText::class,
'COMMENT' => VObject\Property\FlatText::class,
'DESCRIPTION' => VObject\Property\FlatText::class,
'GEO' => VObject\Property\FloatValue::class,
'LOCATION' => VObject\Property\FlatText::class,
'PERCENT-COMPLETE' => VObject\Property\IntegerValue::class,
'PRIORITY' => VObject\Property\IntegerValue::class,
'RESOURCES' => VObject\Property\Text::class,
'STATUS' => VObject\Property\FlatText::class,
'SUMMARY' => VObject\Property\FlatText::class,
// Date and Time Component Properties
'COMPLETED' => VObject\Property\ICalendar\DateTime::class,
'DTEND' => VObject\Property\ICalendar\DateTime::class,
'DUE' => VObject\Property\ICalendar\DateTime::class,
'DTSTART' => VObject\Property\ICalendar\DateTime::class,
'DURATION' => VObject\Property\ICalendar\Duration::class,
'FREEBUSY' => VObject\Property\ICalendar\Period::class,
'TRANSP' => VObject\Property\FlatText::class,
// Time Zone Component Properties
'TZID' => VObject\Property\FlatText::class,
'TZNAME' => VObject\Property\FlatText::class,
'TZOFFSETFROM' => VObject\Property\UtcOffset::class,
'TZOFFSETTO' => VObject\Property\UtcOffset::class,
'TZURL' => VObject\Property\Uri::class,
// Relationship Component Properties
'ATTENDEE' => VObject\Property\ICalendar\CalAddress::class,
'CONTACT' => VObject\Property\FlatText::class,
'ORGANIZER' => VObject\Property\ICalendar\CalAddress::class,
'RECURRENCE-ID' => VObject\Property\ICalendar\DateTime::class,
'RELATED-TO' => VObject\Property\FlatText::class,
'URL' => VObject\Property\Uri::class,
'UID' => VObject\Property\FlatText::class,
// Recurrence Component Properties
'EXDATE' => VObject\Property\ICalendar\DateTime::class,
'RDATE' => VObject\Property\ICalendar\DateTime::class,
'RRULE' => VObject\Property\ICalendar\Recur::class,
'EXRULE' => VObject\Property\ICalendar\Recur::class, // Deprecated since rfc5545
// Alarm Component Properties
'ACTION' => VObject\Property\FlatText::class,
'REPEAT' => VObject\Property\IntegerValue::class,
'TRIGGER' => VObject\Property\ICalendar\Duration::class,
// Change Management Component Properties
'CREATED' => VObject\Property\ICalendar\DateTime::class,
'DTSTAMP' => VObject\Property\ICalendar\DateTime::class,
'LAST-MODIFIED' => VObject\Property\ICalendar\DateTime::class,
'SEQUENCE' => VObject\Property\IntegerValue::class,
// Request Status
'REQUEST-STATUS' => VObject\Property\Text::class,
// Additions from draft-daboo-valarm-extensions-04
'ALARM-AGENT' => VObject\Property\Text::class,
'ACKNOWLEDGED' => VObject\Property\ICalendar\DateTime::class,
'PROXIMITY' => VObject\Property\Text::class,
'DEFAULT-ALARM' => VObject\Property\Boolean::class,
// Additions from draft-daboo-calendar-availability-05
'BUSYTYPE' => VObject\Property\Text::class,
];
/**
* Returns the current document type.
*
* @return int
*/
public function getDocumentType()
{
return self::ICALENDAR20;
}
/**
* Returns a list of all 'base components'. For instance, if an Event has
* a recurrence rule, and one instance is overridden, the overridden event
* will have the same UID, but will be excluded from this list.
*
* VTIMEZONE components will always be excluded.
*
* @param string $componentName filter by component name
*
* @return VObject\Component[]
*/
public function getBaseComponents($componentName = null)
{
$isBaseComponent = function ($component) {
if (!$component instanceof VObject\Component) {
return false;
}
if ('VTIMEZONE' === $component->name) {
return false;
}
if (isset($component->{'RECURRENCE-ID'})) {
return false;
}
return true;
};
if ($componentName) {
// Early exit
return array_filter(
$this->select($componentName),
$isBaseComponent
);
}
$components = [];
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if (!$child instanceof Component) {
// If one child is not a component, they all are so we skip
// the entire group.
continue 2;
}
if ($isBaseComponent($child)) {
$components[] = $child;
}
}
}
return $components;
}
/**
* Returns the first component that is not a VTIMEZONE, and does not have
* an RECURRENCE-ID.
*
* If there is no such component, null will be returned.
*
* @param string $componentName filter by component name
*
* @return VObject\Component|null
*/
public function getBaseComponent($componentName = null)
{
$isBaseComponent = function ($component) {
if (!$component instanceof VObject\Component) {
return false;
}
if ('VTIMEZONE' === $component->name) {
return false;
}
if (isset($component->{'RECURRENCE-ID'})) {
return false;
}
return true;
};
if ($componentName) {
foreach ($this->select($componentName) as $child) {
if ($isBaseComponent($child)) {
return $child;
}
}
return null;
}
// Searching all components
foreach ($this->children as $childGroup) {
foreach ($childGroup as $child) {
if ($isBaseComponent($child)) {
return $child;
}
}
}
return null;
}
/**
* Expand all events in this VCalendar object and return a new VCalendar
* with the expanded events.
*
* If this calendar object, has events with recurrence rules, this method
* can be used to expand the event into multiple sub-events.
*
* Each event will be stripped from its recurrence information, and only
* the instances of the event in the specified timerange will be left
* alone.
*
* In addition, this method will cause timezone information to be stripped,
* and normalized to UTC.
*
* @param DateTimeZone $timeZone reference timezone for floating dates and
* times
*
* @return VCalendar
*/
public function expand(DateTimeInterface $start, DateTimeInterface $end, ?DateTimeZone $timeZone = null)
{
$newChildren = [];
$recurringEvents = [];
if (!$timeZone) {
$timeZone = new DateTimeZone('UTC');
}
$stripTimezones = function (Component $component) use ($timeZone, &$stripTimezones) {
foreach ($component->children() as $componentChild) {
if ($componentChild instanceof Property\ICalendar\DateTime && $componentChild->hasTime()) {
$dt = $componentChild->getDateTimes($timeZone);
// We only need to update the first timezone, because
// setDateTimes will match all other timezones to the
// first.
$dt[0] = $dt[0]->setTimeZone(new DateTimeZone('UTC'));
$componentChild->setDateTimes($dt);
} elseif ($componentChild instanceof Component) {
$stripTimezones($componentChild);
}
}
return $component;
};
foreach ($this->children() as $child) {
if ($child instanceof Property && 'PRODID' !== $child->name) {
// We explicitly want to ignore PRODID, because we want to
// overwrite it with our own.
$newChildren[] = clone $child;
} elseif ($child instanceof Component && 'VTIMEZONE' !== $child->name) {
// We're also stripping all VTIMEZONE objects because we're
// converting everything to UTC.
if ('VEVENT' === $child->name && (isset($child->{'RECURRENCE-ID'}) || isset($child->RRULE) || isset($child->RDATE))) {
// Handle these a bit later.
$uid = (string) $child->UID;
if (!$uid) {
throw new InvalidDataException('Every VEVENT object must have a UID property');
}
if (isset($recurringEvents[$uid])) {
$recurringEvents[$uid][] = clone $child;
} else {
$recurringEvents[$uid] = [clone $child];
}
} elseif ('VEVENT' === $child->name && $child->isInTimeRange($start, $end)) {
$newChildren[] = $stripTimezones(clone $child);
}
}
}
foreach ($recurringEvents as $events) {
try {
$it = new EventIterator($events, null, $timeZone);
} catch (NoInstancesException $e) {
// This event is recurring, but it doesn't have a single
// instance. We are skipping this event from the output
// entirely.
continue;
}
$it->fastForward($start);
while ($it->valid() && $it->getDTStart() < $end) {
if ($it->getDTEnd() > $start) {
$newChildren[] = $stripTimezones($it->getEventObject());
}
$it->next();
}
}
return new self($newChildren);
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [
'VERSION' => '2.0',
'PRODID' => '-//Sabre//Sabre VObject '.VObject\Version::VERSION.'//EN',
'CALSCALE' => 'GREGORIAN',
];
}
/**
* 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 [
'PRODID' => 1,
'VERSION' => 1,
'CALSCALE' => '?',
'METHOD' => '?',
];
}
/**
* 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)
{
$warnings = parent::validate($options);
if ($ver = $this->VERSION) {
if ('2.0' !== (string) $ver) {
$warnings[] = [
'level' => 3,
'message' => 'Only iCalendar version 2.0 as defined in rfc5545 is supported.',
'node' => $this,
];
}
}
$uidList = [];
$componentsFound = 0;
$componentTypes = [];
foreach ($this->children() as $child) {
if ($child instanceof Component) {
++$componentsFound;
if (!in_array($child->name, ['VEVENT', 'VTODO', 'VJOURNAL'])) {
continue;
}
$componentTypes[] = $child->name;
$uid = (string) $child->UID;
$isMaster = isset($child->{'RECURRENCE-ID'}) ? 0 : 1;
if (isset($uidList[$uid])) {
++$uidList[$uid]['count'];
if ($isMaster && $uidList[$uid]['hasMaster']) {
$warnings[] = [
'level' => 3,
'message' => 'More than one master object was found for the object with UID '.$uid,
'node' => $this,
];
}
$uidList[$uid]['hasMaster'] += $isMaster;
} else {
$uidList[$uid] = [
'count' => 1,
'hasMaster' => $isMaster,
];
}
}
}
if (0 === $componentsFound) {
$warnings[] = [
'level' => 3,
'message' => 'An iCalendar object must have at least 1 component.',
'node' => $this,
];
}
if ($options & self::PROFILE_CALDAV) {
if (count($uidList) > 1) {
$warnings[] = [
'level' => 3,
'message' => 'A calendar object on a CalDAV server may only have components with the same UID.',
'node' => $this,
];
}
if (0 === count($componentTypes)) {
$warnings[] = [
'level' => 3,
'message' => 'A calendar object on a CalDAV server must have at least 1 component (VTODO, VEVENT, VJOURNAL).',
'node' => $this,
];
}
if (count(array_unique($componentTypes)) > 1) {
$warnings[] = [
'level' => 3,
'message' => 'A calendar object on a CalDAV server may only have 1 type of component (VEVENT, VTODO or VJOURNAL).',
'node' => $this,
];
}
if (isset($this->METHOD)) {
$warnings[] = [
'level' => 3,
'message' => 'A calendar object on a CalDAV server MUST NOT have a METHOD property.',
'node' => $this,
];
}
}
return $warnings;
}
/**
* Returns all components with a specific UID value.
*
* @return array
*/
public function getByUID($uid)
{
return array_filter($this->getComponents(), function ($item) use ($uid) {
if (!$itemUid = $item->select('UID')) {
return false;
}
$itemUid = current($itemUid)->getValue();
return $uid === $itemUid;
});
}
}

View File

@@ -0,0 +1,541 @@
<?php
namespace Sabre\VObject\Component;
use Sabre\VObject;
use Sabre\Xml;
/**
* The VCard component.
*
* This component represents the BEGIN:VCARD and END:VCARD found in every
* vcard.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VCard extends VObject\Document
{
/**
* The default name for this component.
*
* This should be 'VCALENDAR' or 'VCARD'.
*
* @var string
*/
public static $defaultName = 'VCARD';
/**
* Caching the version number.
*
* @var int
*/
private $version = null;
/**
* This is a list of components, and which classes they should map to.
*
* @var array
*/
public static $componentMap = [
'VCARD' => VCard::class,
];
/**
* List of value-types, and which classes they map to.
*
* @var array
*/
public static $valueMap = [
'BINARY' => VObject\Property\Binary::class,
'BOOLEAN' => VObject\Property\Boolean::class,
'CONTENT-ID' => VObject\Property\FlatText::class, // vCard 2.1 only
'DATE' => VObject\Property\VCard\Date::class,
'DATE-TIME' => VObject\Property\VCard\DateTime::class,
'DATE-AND-OR-TIME' => VObject\Property\VCard\DateAndOrTime::class, // vCard only
'FLOAT' => VObject\Property\FloatValue::class,
'INTEGER' => VObject\Property\IntegerValue::class,
'LANGUAGE-TAG' => VObject\Property\VCard\LanguageTag::class,
'PHONE-NUMBER' => VObject\Property\VCard\PhoneNumber::class, // vCard 3.0 only
'TIMESTAMP' => VObject\Property\VCard\TimeStamp::class,
'TEXT' => VObject\Property\Text::class,
'TIME' => VObject\Property\Time::class,
'UNKNOWN' => VObject\Property\Unknown::class, // jCard / jCal-only.
'URI' => VObject\Property\Uri::class,
'URL' => VObject\Property\Uri::class, // vCard 2.1 only
'UTC-OFFSET' => VObject\Property\UtcOffset::class,
];
/**
* List of properties, and which classes they map to.
*
* @var array
*/
public static $propertyMap = [
// vCard 2.1 properties and up
'N' => VObject\Property\Text::class,
'FN' => VObject\Property\FlatText::class,
'PHOTO' => VObject\Property\Binary::class,
'BDAY' => VObject\Property\VCard\DateAndOrTime::class,
'ADR' => VObject\Property\Text::class,
'LABEL' => VObject\Property\FlatText::class, // Removed in vCard 4.0
'TEL' => VObject\Property\FlatText::class,
'EMAIL' => VObject\Property\FlatText::class,
'MAILER' => VObject\Property\FlatText::class, // Removed in vCard 4.0
'GEO' => VObject\Property\FlatText::class,
'TITLE' => VObject\Property\FlatText::class,
'ROLE' => VObject\Property\FlatText::class,
'LOGO' => VObject\Property\Binary::class,
// 'AGENT' => 'Sabre\\VObject\\Property\\', // Todo: is an embedded vCard. Probably rare, so
// not supported at the moment
'ORG' => VObject\Property\Text::class,
'NOTE' => VObject\Property\FlatText::class,
'REV' => VObject\Property\VCard\TimeStamp::class,
'SOUND' => VObject\Property\FlatText::class,
'URL' => VObject\Property\Uri::class,
'UID' => VObject\Property\FlatText::class,
'VERSION' => VObject\Property\FlatText::class,
'KEY' => VObject\Property\FlatText::class,
'TZ' => VObject\Property\Text::class,
// vCard 3.0 properties
'CATEGORIES' => VObject\Property\Text::class,
'SORT-STRING' => VObject\Property\FlatText::class,
'PRODID' => VObject\Property\FlatText::class,
'NICKNAME' => VObject\Property\Text::class,
'CLASS' => VObject\Property\FlatText::class, // Removed in vCard 4.0
// rfc2739 properties
'FBURL' => VObject\Property\Uri::class,
'CAPURI' => VObject\Property\Uri::class,
'CALURI' => VObject\Property\Uri::class,
'CALADRURI' => VObject\Property\Uri::class,
// rfc4770 properties
'IMPP' => VObject\Property\Uri::class,
// vCard 4.0 properties
'SOURCE' => VObject\Property\Uri::class,
'XML' => VObject\Property\FlatText::class,
'ANNIVERSARY' => VObject\Property\VCard\DateAndOrTime::class,
'CLIENTPIDMAP' => VObject\Property\Text::class,
'LANG' => VObject\Property\VCard\LanguageTag::class,
'GENDER' => VObject\Property\Text::class,
'KIND' => VObject\Property\FlatText::class,
'MEMBER' => VObject\Property\Uri::class,
'RELATED' => VObject\Property\Uri::class,
// rfc6474 properties
'BIRTHPLACE' => VObject\Property\FlatText::class,
'DEATHPLACE' => VObject\Property\FlatText::class,
'DEATHDATE' => VObject\Property\VCard\DateAndOrTime::class,
// rfc6715 properties
'EXPERTISE' => VObject\Property\FlatText::class,
'HOBBY' => VObject\Property\FlatText::class,
'INTEREST' => VObject\Property\FlatText::class,
'ORG-DIRECTORY' => VObject\Property\FlatText::class,
];
/**
* Returns the current document type.
*
* @return int
*/
public function getDocumentType()
{
if (!$this->version) {
$version = (string) $this->VERSION;
switch ($version) {
case '2.1':
$this->version = self::VCARD21;
break;
case '3.0':
$this->version = self::VCARD30;
break;
case '4.0':
$this->version = self::VCARD40;
break;
default:
// We don't want to cache the version if it's unknown,
// because we might get a version property in a bit.
return self::UNKNOWN;
}
}
return $this->version;
}
/**
* Converts the document to a different vcard version.
*
* Use one of the VCARD constants for the target. This method will return
* a copy of the vcard in the new version.
*
* At the moment the only supported conversion is from 3.0 to 4.0.
*
* If input and output version are identical, a clone is returned.
*
* @param int $target
*
* @return VCard
*/
public function convert($target)
{
$converter = new VObject\VCardConverter();
return $converter->convert($this, $target);
}
/**
* VCards with version 2.1, 3.0 and 4.0 are found.
*
* If the VCARD doesn't know its version, 2.1 is assumed.
*/
const DEFAULT_VERSION = self::VCARD21;
/**
* Validates the node for correctness.
*
* The following options are supported:
* Node::REPAIR - May attempt to automatically repair the problem.
*
* 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 - An inconsequential issue
* 3 - A severe issue.
*
* @param int $options
*
* @return array
*/
public function validate($options = 0)
{
$warnings = [];
$versionMap = [
self::VCARD21 => '2.1',
self::VCARD30 => '3.0',
self::VCARD40 => '4.0',
];
$version = $this->select('VERSION');
if (1 === count($version)) {
$version = (string) $this->VERSION;
if ('2.1' !== $version && '3.0' !== $version && '4.0' !== $version) {
$warnings[] = [
'level' => 3,
'message' => 'Only vcard version 4.0 (RFC6350), version 3.0 (RFC2426) or version 2.1 (icm-vcard-2.1) are supported.',
'node' => $this,
];
if ($options & self::REPAIR) {
$this->VERSION = $versionMap[self::DEFAULT_VERSION];
}
}
if ('2.1' === $version && ($options & self::PROFILE_CARDDAV)) {
$warnings[] = [
'level' => 3,
'message' => 'CardDAV servers are not allowed to accept vCard 2.1.',
'node' => $this,
];
}
}
$uid = $this->select('UID');
if (0 === count($uid)) {
if ($options & self::PROFILE_CARDDAV) {
// Required for CardDAV
$warningLevel = 3;
$message = 'vCards on CardDAV servers MUST have a UID property.';
} else {
// Not required for regular vcards
$warningLevel = 2;
$message = 'Adding a UID to a vCard property is recommended.';
}
if ($options & self::REPAIR) {
$this->UID = VObject\UUIDUtil::getUUID();
$warningLevel = 1;
}
$warnings[] = [
'level' => $warningLevel,
'message' => $message,
'node' => $this,
];
}
$fn = $this->select('FN');
if (1 !== count($fn)) {
$repaired = false;
if (($options & self::REPAIR) && 0 === count($fn)) {
// We're going to try to see if we can use the contents of the
// N property.
if (isset($this->N)) {
$value = explode(';', (string) $this->N);
if (isset($value[1]) && $value[1]) {
$this->FN = $value[1].' '.$value[0];
} else {
$this->FN = $value[0];
}
$repaired = true;
// Otherwise, the ORG property may work
} elseif (isset($this->ORG)) {
$this->FN = (string) $this->ORG;
$repaired = true;
// Otherwise, the NICKNAME property may work
} elseif (isset($this->NICKNAME)) {
$this->FN = (string) $this->NICKNAME;
$repaired = true;
// Otherwise, the EMAIL property may work
} elseif (isset($this->EMAIL)) {
$this->FN = (string) $this->EMAIL;
$repaired = true;
}
}
$warnings[] = [
'level' => $repaired ? 1 : 3,
'message' => 'The FN property must appear in the VCARD component exactly 1 time',
'node' => $this,
];
}
return array_merge(
parent::validate($options),
$warnings
);
}
/**
* 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 [
'ADR' => '*',
'ANNIVERSARY' => '?',
'BDAY' => '?',
'CALADRURI' => '*',
'CALURI' => '*',
'CATEGORIES' => '*',
'CLIENTPIDMAP' => '*',
'EMAIL' => '*',
'FBURL' => '*',
'IMPP' => '*',
'GENDER' => '?',
'GEO' => '*',
'KEY' => '*',
'KIND' => '?',
'LANG' => '*',
'LOGO' => '*',
'MEMBER' => '*',
'N' => '?',
'NICKNAME' => '*',
'NOTE' => '*',
'ORG' => '*',
'PHOTO' => '*',
'PRODID' => '?',
'RELATED' => '*',
'REV' => '?',
'ROLE' => '*',
'SOUND' => '*',
'SOURCE' => '*',
'TEL' => '*',
'TITLE' => '*',
'TZ' => '*',
'URL' => '*',
'VERSION' => '1',
'XML' => '*',
// FN is commented out, because it's already handled by the
// validate function, which may also try to repair it.
// 'FN' => '+',
'UID' => '?',
];
}
/**
* Returns a preferred field.
*
* VCards can indicate whether a field such as ADR, TEL or EMAIL is
* preferred by specifying TYPE=PREF (vcard 2.1, 3) or PREF=x (vcard 4, x
* being a number between 1 and 100).
*
* If neither of those parameters are specified, the first is returned, if
* a field with that name does not exist, null is returned.
*
* @param string $fieldName
*
* @return VObject\Property|null
*/
public function preferred($propertyName)
{
$preferred = null;
$lastPref = 101;
foreach ($this->select($propertyName) as $field) {
$pref = 101;
if (isset($field['TYPE']) && $field['TYPE']->has('PREF')) {
$pref = 1;
} elseif (isset($field['PREF'])) {
$pref = $field['PREF']->getValue();
}
if ($pref < $lastPref || is_null($preferred)) {
$preferred = $field;
$lastPref = $pref;
}
}
return $preferred;
}
/**
* Returns a property with a specific TYPE value (ADR, TEL, or EMAIL).
*
* This function will return null if the property does not exist. If there are
* multiple properties with the same TYPE value, only one will be returned.
*
* @param string $propertyName
* @param string $type
*
* @return VObject\Property|null
*/
public function getByType($propertyName, $type)
{
foreach ($this->select($propertyName) as $field) {
if (isset($field['TYPE']) && $field['TYPE']->has($type)) {
return $field;
}
}
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [
'VERSION' => '4.0',
'PRODID' => '-//Sabre//Sabre VObject '.VObject\Version::VERSION.'//EN',
'UID' => 'sabre-vobject-'.VObject\UUIDUtil::getUUID(),
];
}
/**
* 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()
{
// A vcard does not have sub-components, so we're overriding this
// method to remove that array element.
$properties = [];
foreach ($this->children() as $child) {
$properties[] = $child->jsonSerialize();
}
return [
strtolower($this->name),
$properties,
];
}
/**
* 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
{
$propertiesByGroup = [];
foreach ($this->children() as $property) {
$group = $property->group;
if (!isset($propertiesByGroup[$group])) {
$propertiesByGroup[$group] = [];
}
$propertiesByGroup[$group][] = $property;
}
$writer->startElement(strtolower($this->name));
foreach ($propertiesByGroup as $group => $properties) {
if (!empty($group)) {
$writer->startElement('group');
$writer->writeAttribute('name', strtolower($group));
}
foreach ($properties as $property) {
switch ($property->name) {
case 'VERSION':
break;
case 'XML':
$value = $property->getParts();
$fragment = new Xml\Element\XmlFragment($value[0]);
$writer->write($fragment);
break;
default:
$property->xmlSerialize($writer);
break;
}
}
if (!empty($group)) {
$writer->endElement();
}
}
$writer->endElement();
}
/**
* Returns the default class for a property name.
*
* @param string $propertyName
*
* @return string
*/
public function getClassNameForPropertyName($propertyName)
{
$className = parent::getClassNameForPropertyName($propertyName);
// In vCard 4, BINARY no longer exists, and we need URI instead.
if (VObject\Property\Binary::class == $className && self::VCARD40 === $this->getDocumentType()) {
return VObject\Property\Uri::class;
}
return $className;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use Sabre\VObject;
use Sabre\VObject\Recur\EventIterator;
use Sabre\VObject\Recur\NoInstancesException;
/**
* VEvent component.
*
* This component contains some additional functionality specific for VEVENT's.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VEvent extends VObject\Component
{
/**
* Returns true or false depending on if the event falls in the specified
* time-range. This is used for filtering purposes.
*
* The rules used to determine if an event falls within the specified
* time-range is based on the CalDAV specification.
*
* @return bool
*/
public function isInTimeRange(DateTimeInterface $start, DateTimeInterface $end)
{
if ($this->RRULE || $this->RDATE) {
try {
$it = new EventIterator($this, null, $start->getTimezone());
} catch (NoInstancesException $e) {
// If we've caught this exception, there are no instances
// for the event that fall into the specified time-range.
return false;
}
$it->fastForward($start);
// We fast-forwarded to a spot where the end-time of the
// recurrence instance exceeded the start of the requested
// time-range.
//
// If the starttime of the recurrence did not exceed the
// end of the time range as well, we have a match.
return $it->getDTStart() < $end && $it->getDTEnd() > $start;
}
$effectiveStart = $this->DTSTART->getDateTime($start->getTimezone());
if (isset($this->DTEND)) {
// The DTEND property is considered non inclusive. So for a 3 day
// event in july, dtstart and dtend would have to be July 1st and
// July 4th respectively.
//
// See:
// http://tools.ietf.org/html/rfc5545#page-54
$effectiveEnd = $this->DTEND->getDateTime($end->getTimezone());
} elseif (isset($this->DURATION)) {
$effectiveEnd = $effectiveStart->add(VObject\DateTimeParser::parseDuration($this->DURATION));
} elseif (!$this->DTSTART->hasTime()) {
$effectiveEnd = $effectiveStart->modify('+1 day');
} else {
$effectiveEnd = $effectiveStart;
}
return
($start < $effectiveEnd) && ($end > $effectiveStart)
;
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [
'UID' => 'sabre-vobject-'.VObject\UUIDUtil::getUUID(),
'DTSTAMP' => gmdate('Ymd\\THis\\Z'),
];
}
/**
* 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()
{
$hasMethod = isset($this->parent->METHOD);
return [
'UID' => 1,
'DTSTAMP' => 1,
'DTSTART' => $hasMethod ? '?' : '1',
'CLASS' => '?',
'CREATED' => '?',
'DESCRIPTION' => '?',
'GEO' => '?',
'LAST-MODIFIED' => '?',
'LOCATION' => '?',
'ORGANIZER' => '?',
'PRIORITY' => '?',
'SEQUENCE' => '?',
'STATUS' => '?',
'SUMMARY' => '?',
'TRANSP' => '?',
'URL' => '?',
'RECURRENCE-ID' => '?',
'RRULE' => '?',
'DTEND' => '?',
'DURATION' => '?',
'ATTACH' => '*',
'ATTENDEE' => '*',
'CATEGORIES' => '*',
'COMMENT' => '*',
'CONTACT' => '*',
'EXDATE' => '*',
'REQUEST-STATUS' => '*',
'RELATED-TO' => '*',
'RESOURCES' => '*',
'RDATE' => '*',
];
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use Sabre\VObject;
/**
* The VFreeBusy component.
*
* This component adds functionality to a component, specific for VFREEBUSY
* components.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VFreeBusy extends VObject\Component
{
/**
* Checks based on the contained FREEBUSY information, if a timeslot is
* available.
*
* @return bool
*/
public function isFree(DateTimeInterface $start, DatetimeInterface $end)
{
foreach ($this->select('FREEBUSY') as $freebusy) {
// We are only interested in FBTYPE=BUSY (the default),
// FBTYPE=BUSY-TENTATIVE or FBTYPE=BUSY-UNAVAILABLE.
if (isset($freebusy['FBTYPE']) && 'BUSY' !== strtoupper(substr((string) $freebusy['FBTYPE'], 0, 4))) {
continue;
}
// The freebusy component can hold more than 1 value, separated by
// commas.
$periods = explode(',', (string) $freebusy);
foreach ($periods as $period) {
// Every period is formatted as [start]/[end]. The start is an
// absolute UTC time, the end may be an absolute UTC time, or
// duration (relative) value.
list($busyStart, $busyEnd) = explode('/', $period);
$busyStart = VObject\DateTimeParser::parse($busyStart);
$busyEnd = VObject\DateTimeParser::parse($busyEnd);
if ($busyEnd instanceof \DateInterval) {
$busyEnd = $busyStart->add($busyEnd);
}
if ($start < $busyEnd && $end > $busyStart) {
return false;
}
}
}
return true;
}
/**
* 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,
'DTSTAMP' => 1,
'CONTACT' => '?',
'DTSTART' => '?',
'DTEND' => '?',
'ORGANIZER' => '?',
'URL' => '?',
'ATTENDEE' => '*',
'COMMENT' => '*',
'FREEBUSY' => '*',
'REQUEST-STATUS' => '*',
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use Sabre\VObject;
/**
* VJournal component.
*
* This component contains some additional functionality specific for VJOURNALs.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VJournal extends VObject\Component
{
/**
* Returns true or false depending on if the event falls in the specified
* time-range. This is used for filtering purposes.
*
* The rules used to determine if an event falls within the specified
* time-range is based on the CalDAV specification.
*
* @return bool
*/
public function isInTimeRange(DateTimeInterface $start, DateTimeInterface $end)
{
$dtstart = isset($this->DTSTART) ? $this->DTSTART->getDateTime() : null;
if ($dtstart) {
$effectiveEnd = $dtstart;
if (!$this->DTSTART->hasTime()) {
$effectiveEnd = $effectiveEnd->modify('+1 day');
}
return $start <= $effectiveEnd && $end > $dtstart;
}
return false;
}
/**
* 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,
'DTSTAMP' => 1,
'CLASS' => '?',
'CREATED' => '?',
'DTSTART' => '?',
'LAST-MODIFIED' => '?',
'ORGANIZER' => '?',
'RECURRENCE-ID' => '?',
'SEQUENCE' => '?',
'STATUS' => '?',
'SUMMARY' => '?',
'URL' => '?',
'RRULE' => '?',
'ATTACH' => '*',
'ATTENDEE' => '*',
'CATEGORIES' => '*',
'COMMENT' => '*',
'CONTACT' => '*',
'DESCRIPTION' => '*',
'EXDATE' => '*',
'RELATED-TO' => '*',
'RDATE' => '*',
];
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [
'UID' => 'sabre-vobject-'.VObject\UUIDUtil::getUUID(),
'DTSTAMP' => gmdate('Ymd\\THis\\Z'),
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Sabre\VObject\Component;
use Sabre\VObject;
/**
* The VTimeZone component.
*
* This component adds functionality to a component, specific for VTIMEZONE
* components.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VTimeZone extends VObject\Component
{
/**
* Returns the PHP DateTimeZone for this VTIMEZONE component.
*
* If we can't accurately determine the timezone, this method will return
* UTC.
*
* @return \DateTimeZone
*/
public function getTimeZone()
{
return VObject\TimeZoneUtil::getTimeZone((string) $this->TZID, $this->root);
}
/**
* 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 [
'TZID' => 1,
'LAST-MODIFIED' => '?',
'TZURL' => '?',
// At least 1 STANDARD or DAYLIGHT must appear.
//
// The validator is not specific yet to pick this up, so these
// rules are too loose.
'STANDARD' => '*',
'DAYLIGHT' => '*',
];
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace Sabre\VObject\Component;
use DateTimeInterface;
use Sabre\VObject;
/**
* VTodo component.
*
* This component contains some additional functionality specific for VTODOs.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class VTodo extends VObject\Component
{
/**
* Returns true or false depending on if the event falls in the specified
* time-range. This is used for filtering purposes.
*
* The rules used to determine if an event falls within the specified
* time-range is based on the CalDAV specification.
*
* @return bool
*/
public function isInTimeRange(DateTimeInterface $start, DateTimeInterface $end)
{
$dtstart = isset($this->DTSTART) ? $this->DTSTART->getDateTime() : null;
$duration = isset($this->DURATION) ? VObject\DateTimeParser::parseDuration($this->DURATION) : null;
$due = isset($this->DUE) ? $this->DUE->getDateTime() : null;
$completed = isset($this->COMPLETED) ? $this->COMPLETED->getDateTime() : null;
$created = isset($this->CREATED) ? $this->CREATED->getDateTime() : null;
if ($dtstart) {
if ($duration) {
$effectiveEnd = $dtstart->add($duration);
return $start <= $effectiveEnd && $end > $dtstart;
} elseif ($due) {
return
($start < $due || $start <= $dtstart) &&
($end > $dtstart || $end >= $due);
} else {
return $start <= $dtstart && $end > $dtstart;
}
}
if ($due) {
return $start < $due && $end >= $due;
}
if ($completed && $created) {
return
($start <= $created || $start <= $completed) &&
($end >= $created || $end >= $completed);
}
if ($completed) {
return $start <= $completed && $end >= $completed;
}
if ($created) {
return $end > $created;
}
return true;
}
/**
* 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,
'DTSTAMP' => 1,
'CLASS' => '?',
'COMPLETED' => '?',
'CREATED' => '?',
'DESCRIPTION' => '?',
'DTSTART' => '?',
'GEO' => '?',
'LAST-MODIFIED' => '?',
'LOCATION' => '?',
'ORGANIZER' => '?',
'PERCENT' => '?',
'PRIORITY' => '?',
'RECURRENCE-ID' => '?',
'SEQUENCE' => '?',
'STATUS' => '?',
'SUMMARY' => '?',
'URL' => '?',
'RRULE' => '?',
'DUE' => '?',
'DURATION' => '?',
'ATTACH' => '*',
'ATTENDEE' => '*',
'CATEGORIES' => '*',
'COMMENT' => '*',
'CONTACT' => '*',
'EXDATE' => '*',
'REQUEST-STATUS' => '*',
'RELATED-TO' => '*',
'RESOURCES' => '*',
'RDATE' => '*',
];
}
/**
* Validates the node for correctness.
*
* The following options are supported:
* Node::REPAIR - May attempt to automatically repair the problem.
*
* 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 - An inconsequential issue
* 3 - A severe issue.
*
* @param int $options
*
* @return array
*/
public function validate($options = 0)
{
$result = parent::validate($options);
if (isset($this->DUE) && isset($this->DTSTART)) {
$due = $this->DUE;
$dtStart = $this->DTSTART;
if ($due->getValueType() !== $dtStart->getValueType()) {
$result[] = [
'level' => 3,
'message' => 'The value type (DATE or DATE-TIME) must be identical for DUE and DTSTART',
'node' => $due,
];
} elseif ($due->getDateTime() < $dtStart->getDateTime()) {
$result[] = [
'level' => 3,
'message' => 'DUE must occur after DTSTART',
'node' => $due,
];
}
}
return $result;
}
/**
* This method should return a list of default property values.
*
* @return array
*/
protected function getDefaults()
{
return [
'UID' => 'sabre-vobject-'.VObject\UUIDUtil::getUUID(),
'DTSTAMP' => date('Ymd\\THis\\Z'),
];
}
}

View File

@@ -0,0 +1,560 @@
<?php
namespace Sabre\VObject;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
/**
* DateTimeParser.
*
* This class is responsible for parsing the several different date and time
* formats iCalendar and vCards have.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class DateTimeParser
{
/**
* Parses an iCalendar (rfc5545) formatted datetime and returns a
* DateTimeImmutable object.
*
* Specifying a reference timezone is optional. It will only be used
* if the non-UTC format is used. The argument is used as a reference, the
* returned DateTimeImmutable object will still be in the UTC timezone.
*
* @param string $dt
* @param DateTimeZone $tz
*
* @return DateTimeImmutable
*/
public static function parseDateTime($dt, ?DateTimeZone $tz = null)
{
// Format is YYYYMMDD + "T" + hhmmss
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])T([0-2][0-9])([0-5][0-9])([0-5][0-9])([Z]?)$/', $dt, $matches);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
}
if ('Z' === $matches[7] || is_null($tz)) {
$tz = new DateTimeZone('UTC');
}
try {
$date = new DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3].' '.$matches[4].':'.$matches[5].':'.$matches[6], $tz);
} catch (\Exception $e) {
throw new InvalidDataException('The supplied iCalendar datetime value is incorrect: '.$dt);
}
return $date;
}
/**
* Parses an iCalendar (rfc5545) formatted date and returns a DateTimeImmutable object.
*
* @param string $date
* @param DateTimeZone $tz
*
* @return DateTimeImmutable
*/
public static function parseDate($date, ?DateTimeZone $tz = null)
{
// Format is YYYYMMDD
$result = preg_match('/^([0-9]{4})([0-1][0-9])([0-3][0-9])$/', $date, $matches);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
}
if (is_null($tz)) {
$tz = new DateTimeZone('UTC');
}
try {
$date = new DateTimeImmutable($matches[1].'-'.$matches[2].'-'.$matches[3], $tz);
} catch (\Exception $e) {
throw new InvalidDataException('The supplied iCalendar date value is incorrect: '.$date);
}
return $date;
}
/**
* Parses an iCalendar (RFC5545) formatted duration value.
*
* This method will either return a DateTimeInterval object, or a string
* suitable for strtotime or DateTime::modify.
*
* @param string $duration
* @param bool $asString
*
* @return DateInterval|string
*/
public static function parseDuration($duration, $asString = false)
{
$result = preg_match('/^(?<plusminus>\+|-)?P((?<week>\d+)W)?((?<day>\d+)D)?(T((?<hour>\d+)H)?((?<minute>\d+)M)?((?<second>\d+)S)?)?$/', $duration, $matches);
if (!$result) {
throw new InvalidDataException('The supplied iCalendar duration value is incorrect: '.$duration);
}
if (!$asString) {
$invert = false;
if (isset($matches['plusminus']) && '-' === $matches['plusminus']) {
$invert = true;
}
$parts = [
'week',
'day',
'hour',
'minute',
'second',
];
foreach ($parts as $part) {
$matches[$part] = isset($matches[$part]) && $matches[$part] ? (int) $matches[$part] : 0;
}
// We need to re-construct the $duration string, because weeks and
// days are not supported by DateInterval in the same string.
$duration = 'P';
$days = $matches['day'];
if ($matches['week']) {
$days += $matches['week'] * 7;
}
if ($days) {
$duration .= $days.'D';
}
if ($matches['minute'] || $matches['second'] || $matches['hour']) {
$duration .= 'T';
if ($matches['hour']) {
$duration .= $matches['hour'].'H';
}
if ($matches['minute']) {
$duration .= $matches['minute'].'M';
}
if ($matches['second']) {
$duration .= $matches['second'].'S';
}
}
if ('P' === $duration) {
$duration = 'PT0S';
}
$iv = new DateInterval($duration);
if ($invert) {
$iv->invert = true;
}
return $iv;
}
$parts = [
'week',
'day',
'hour',
'minute',
'second',
];
$newDur = '';
foreach ($parts as $part) {
if (isset($matches[$part]) && $matches[$part]) {
$newDur .= ' '.$matches[$part].' '.$part.'s';
}
}
$newDur = ('-' === $matches['plusminus'] ? '-' : '+').trim($newDur);
if ('+' === $newDur) {
$newDur = '+0 seconds';
}
return $newDur;
}
/**
* Parses either a Date or DateTime, or Duration value.
*
* @param string $date
* @param DateTimeZone|string $referenceTz
*
* @return DateTimeImmutable|DateInterval
*/
public static function parse($date, $referenceTz = null)
{
if ('P' === $date[0] || ('-' === $date[0] && 'P' === $date[1])) {
return self::parseDuration($date);
} elseif (8 === strlen($date)) {
return self::parseDate($date, $referenceTz);
} else {
return self::parseDateTime($date, $referenceTz);
}
}
/**
* This method parses a vCard date and or time value.
*
* This can be used for the DATE, DATE-TIME, TIMESTAMP and
* DATE-AND-OR-TIME value.
*
* This method returns an array, not a DateTime value.
*
* The elements in the array are in the following order:
* year, month, date, hour, minute, second, timezone
*
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the year, etc.
*
* Timezone is either returned as 'Z' or as '+0800'
*
* For any non-specified values null is returned.
*
* List of date formats that are supported:
* YYYY
* YYYY-MM
* YYYYMMDD
* --MMDD
* ---DD
*
* YYYY-MM-DD
* --MM-DD
* ---DD
*
* List of supported time formats:
*
* HH
* HHMM
* HHMMSS
* -MMSS
* --SS
*
* HH
* HH:MM
* HH:MM:SS
* -MM:SS
* --SS
*
* A full basic-format date-time string looks like :
* 20130603T133901
*
* A full extended-format date-time string looks like :
* 2013-06-03T13:39:01
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +1100.
*
* @param string $date
*
* @return array
*/
public static function parseVCardDateTime($date)
{
$regex = '/^
(?: # date part
(?:
(?: (?<year> [0-9]{4}) (?: -)?| --)
(?<month> [0-9]{2})?
|---)
(?<date> [0-9]{2})?
)?
(?:T # time part
(?<hour> [0-9]{2} | -)
(?<minute> [0-9]{2} | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{4})
)?
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
// Attempting to parse the extended format.
$regex = '/^
(?: # date part
(?: (?<year> [0-9]{4}) - | -- )
(?<month> [0-9]{2}) -
(?<date> [0-9]{2})
)?
(?:T # time part
(?: (?<hour> [0-9]{2}) : | -)
(?: (?<minute> [0-9]{2}) : | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
)?
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
throw new InvalidDataException('Invalid vCard date-time string: '.$date);
}
}
$parts = [
'year',
'month',
'date',
'hour',
'minute',
'second',
'timezone',
];
$result = [];
foreach ($parts as $part) {
if (empty($matches[$part])) {
$result[$part] = null;
} elseif ('-' === $matches[$part] || '--' === $matches[$part]) {
$result[$part] = null;
} else {
$result[$part] = $matches[$part];
}
}
return $result;
}
/**
* This method parses a vCard TIME value.
*
* This method returns an array, not a DateTime value.
*
* The elements in the array are in the following order:
* hour, minute, second, timezone
*
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the hour etc.
*
* Timezone is either returned as 'Z' or as '+08:00'
*
* For any non-specified values null is returned.
*
* List of supported time formats:
*
* HH
* HHMM
* HHMMSS
* -MMSS
* --SS
*
* HH
* HH:MM
* HH:MM:SS
* -MM:SS
* --SS
*
* A full basic-format time string looks like :
* 133901
*
* A full extended-format time string looks like :
* 13:39:01
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +11:00.
*
* @param string $date
*
* @return array
*/
public static function parseVCardTime($date)
{
$regex = '/^
(?<hour> [0-9]{2} | -)
(?<minute> [0-9]{2} | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{4})
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
// Attempting to parse the extended format.
$regex = '/^
(?: (?<hour> [0-9]{2}) : | -)
(?: (?<minute> [0-9]{2}) : | -)?
(?<second> [0-9]{2})?
(?: \.[0-9]{3})? # milliseconds
(?P<timezone> # timezone offset
Z | (?: \+|-)(?: [0-9]{2}:[0-9]{2})
)?
$/x';
if (!preg_match($regex, $date, $matches)) {
throw new InvalidDataException('Invalid vCard time string: '.$date);
}
}
$parts = [
'hour',
'minute',
'second',
'timezone',
];
$result = [];
foreach ($parts as $part) {
if (empty($matches[$part])) {
$result[$part] = null;
} elseif ('-' === $matches[$part]) {
$result[$part] = null;
} else {
$result[$part] = $matches[$part];
}
}
return $result;
}
/**
* This method parses a vCard date and or time value.
*
* This can be used for the DATE, DATE-TIME and
* DATE-AND-OR-TIME value.
*
* This method returns an array, not a DateTime value.
* The elements in the array are in the following order:
* year, month, date, hour, minute, second, timezone
* Almost any part of the string may be omitted. It's for example legal to
* just specify seconds, leave out the year, etc.
*
* Timezone is either returned as 'Z' or as '+0800'
*
* For any non-specified values null is returned.
*
* List of date formats that are supported:
* 20150128
* 2015-01
* --01
* --0128
* ---28
*
* List of supported time formats:
* 13
* 1353
* 135301
* -53
* -5301
* --01 (unreachable, see the tests)
* --01Z
* --01+1234
*
* List of supported date-time formats:
* 20150128T13
* --0128T13
* ---28T13
* ---28T1353
* ---28T135301
* ---28T13Z
* ---28T13+1234
*
* See the regular expressions for all the possible patterns.
*
* Times may be postfixed by a timezone offset. This can be either 'Z' for
* UTC, or a string like -0500 or +1100.
*
* @param string $date
*
* @return array
*/
public static function parseVCardDateAndOrTime($date)
{
// \d{8}|\d{4}-\d\d|--\d\d(\d\d)?|---\d\d
$valueDate = '/^(?J)(?:'.
'(?<year>\d{4})(?<month>\d\d)(?<date>\d\d)'.
'|(?<year>\d{4})-(?<month>\d\d)'.
'|--(?<month>\d\d)(?<date>\d\d)?'.
'|---(?<date>\d\d)'.
')$/';
// (\d\d(\d\d(\d\d)?)?|-\d\d(\d\d)?|--\d\d)(Z|[+\-]\d\d(\d\d)?)?
$valueTime = '/^(?J)(?:'.
'((?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?'.
'|-(?<minute>\d\d)(?<second>\d\d)?'.
'|--(?<second>\d\d))'.
'(?<timezone>(Z|[+\-]\d\d(\d\d)?))?'.
')$/';
// (\d{8}|--\d{4}|---\d\d)T\d\d(\d\d(\d\d)?)?(Z|[+\-]\d\d(\d\d?)?
$valueDateTime = '/^(?:'.
'((?<year0>\d{4})(?<month0>\d\d)(?<date0>\d\d)'.
'|--(?<month1>\d\d)(?<date1>\d\d)'.
'|---(?<date2>\d\d))'.
'T'.
'(?<hour>\d\d)((?<minute>\d\d)(?<second>\d\d)?)?'.
'(?<timezone>(Z|[+\-]\d\d(\d\d?)))?'.
')$/';
// date-and-or-time is date | date-time | time
// in this strict order.
if (0 === preg_match($valueDate, $date, $matches)
&& 0 === preg_match($valueDateTime, $date, $matches)
&& 0 === preg_match($valueTime, $date, $matches)) {
throw new InvalidDataException('Invalid vCard date-time string: '.$date);
}
$parts = [
'year' => null,
'month' => null,
'date' => null,
'hour' => null,
'minute' => null,
'second' => null,
'timezone' => null,
];
// The $valueDateTime expression has a bug with (?J) so we simulate it.
$parts['date0'] = &$parts['date'];
$parts['date1'] = &$parts['date'];
$parts['date2'] = &$parts['date'];
$parts['month0'] = &$parts['month'];
$parts['month1'] = &$parts['month'];
$parts['year0'] = &$parts['year'];
foreach ($parts as $part => &$value) {
if (!empty($matches[$part])) {
$value = $matches[$part];
}
}
unset($parts['date0']);
unset($parts['date1']);
unset($parts['date2']);
unset($parts['month0']);
unset($parts['month1']);
unset($parts['year0']);
return $parts;
}
}

269
vendor/sabre/vobject/lib/Document.php vendored Normal file
View File

@@ -0,0 +1,269 @@
<?php
namespace Sabre\VObject;
/**
* Document.
*
* A document is just like a component, except that it's also the top level
* element.
*
* Both a VCALENDAR and a VCARD are considered documents.
*
* This class also provides a registry for document types.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
abstract class Document extends Component
{
/**
* Unknown document type.
*/
const UNKNOWN = 1;
/**
* vCalendar 1.0.
*/
const VCALENDAR10 = 2;
/**
* iCalendar 2.0.
*/
const ICALENDAR20 = 3;
/**
* vCard 2.1.
*/
const VCARD21 = 4;
/**
* vCard 3.0.
*/
const VCARD30 = 5;
/**
* vCard 4.0.
*/
const VCARD40 = 6;
/**
* The default name for this component.
*
* This should be 'VCALENDAR' or 'VCARD'.
*
* @var string
*/
public static $defaultName;
/**
* List of properties, and which classes they map to.
*
* @var array
*/
public static $propertyMap = [];
/**
* List of components, along with which classes they map to.
*
* @var array
*/
public static $componentMap = [];
/**
* List of value-types, and which classes they map to.
*
* @var array
*/
public static $valueMap = [];
/**
* Creates a new document.
*
* We're changing the default behavior slightly here. First, we don't want
* to have to specify a name (we already know it), and we want to allow
* children to be specified in the first argument.
*
* But, the default behavior also works.
*
* So the two sigs:
*
* new Document(array $children = [], $defaults = true);
* new Document(string $name, array $children = [], $defaults = true)
*/
public function __construct()
{
$args = func_get_args();
$name = static::$defaultName;
if (0 === count($args) || is_array($args[0])) {
$children = isset($args[0]) ? $args[0] : [];
$defaults = isset($args[1]) ? $args[1] : true;
} else {
$name = $args[0];
$children = isset($args[1]) ? $args[1] : [];
$defaults = isset($args[2]) ? $args[2] : true;
}
parent::__construct($this, $name, $children, $defaults);
}
/**
* Returns the current document type.
*
* @return int
*/
public function getDocumentType()
{
return self::UNKNOWN;
}
/**
* Creates a new component or property.
*
* If it's a known component, we will automatically call createComponent.
* otherwise, we'll assume it's a property and call createProperty instead.
*
* @param string $name
* @param string $arg1,... Unlimited number of args
*
* @return mixed
*/
public function create($name)
{
if (isset(static::$componentMap[strtoupper($name)])) {
return call_user_func_array([$this, 'createComponent'], func_get_args());
} else {
return call_user_func_array([$this, 'createProperty'], func_get_args());
}
}
/**
* Creates a new component.
*
* This method automatically searches for the correct component class, based
* on its name.
*
* 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 $name
* @param array $children
* @param bool $defaults
*
* @return Component
*/
public function createComponent($name, ?array $children = null, $defaults = true)
{
$name = strtoupper($name);
$class = Component::class;
if (isset(static::$componentMap[$name])) {
$class = static::$componentMap[$name];
}
if (is_null($children)) {
$children = [];
}
return new $class($this, $name, $children, $defaults);
}
/**
* Factory method for creating new properties.
*
* This method automatically searches for the correct property class, based
* on its name.
*
* You can specify the parameters either in key=>value syntax, in which case
* parameters will automatically be created, or you can just pass a list of
* Parameter objects.
*
* @param string $name
* @param mixed $value
* @param array $parameters
* @param string $valueType Force a specific valuetype, such as URI or TEXT
*/
public function createProperty($name, $value = null, ?array $parameters = null, $valueType = null, ?int $lineIndex = null, ?string $lineString = null): Property
{
// If there's a . in the name, it means it's prefixed by a groupname.
if (false !== ($i = strpos($name, '.'))) {
$group = substr($name, 0, $i);
$name = strtoupper(substr($name, $i + 1));
} else {
$name = strtoupper($name);
$group = null;
}
$class = null;
// If a VALUE parameter is supplied, we have to use that
// According to https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
// If the property's value is the default value type, then this
// parameter need not be specified. However, if the property's
// default value type is overridden by some other allowable value
// type, then this parameter MUST be specified.
if (!$valueType) {
$valueType = $parameters['VALUE'] ?? null;
}
if ($valueType) {
// The valueType argument comes first to figure out the correct
// class.
$class = $this->getClassNameForPropertyValue($valueType);
}
// If the value parameter is not set or set to something we do not recognize
// we do not attempt to interpret or parse the datass value as specified in
// https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.20
// So when we so far did not get a class-name, we use the default for the property
if (is_null($class)) {
$class = $this->getClassNameForPropertyName($name);
}
if (is_null($parameters)) {
$parameters = [];
}
return new $class($this, $name, $value, $parameters, $group, $lineIndex, $lineString);
}
/**
* This method returns a full class-name for a value parameter.
*
* For instance, DTSTART may have VALUE=DATE. In that case we will look in
* our valueMap table and return the appropriate class name.
*
* This method returns null if we don't have a specialized class.
*
* @param string $valueParam
*
* @return string|null
*/
public function getClassNameForPropertyValue($valueParam)
{
$valueParam = strtoupper($valueParam);
if (isset(static::$valueMap[$valueParam])) {
return static::$valueMap[$valueParam];
}
}
/**
* Returns the default class for a property name.
*
* @param string $propertyName
*
* @return string
*/
public function getClassNameForPropertyName($propertyName)
{
if (isset(static::$propertyMap[$propertyName])) {
return static::$propertyMap[$propertyName];
} else {
return Property\Unknown::class;
}
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Sabre\VObject;
use ArrayIterator;
use LogicException;
/**
* VObject ElementList.
*
* This class represents a list of elements. Lists are the result of queries,
* such as doing $vcalendar->vevent where there's multiple VEVENT objects.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class ElementList extends ArrayIterator
{
/* {{{ ArrayAccess Interface */
/**
* Sets an item through ArrayAccess.
*
* @param int $offset
* @param mixed $value
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
throw new LogicException('You can not add new objects to an ElementList');
}
/**
* Sets an item through ArrayAccess.
*
* This method just forwards the request to the inner iterator
*
* @param int $offset
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
throw new LogicException('You can not remove objects from an ElementList');
}
/* }}} */
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Sabre\VObject;
/**
* Exception thrown by parser when the end of the stream has been reached,
* before this was expected.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class EofException extends ParseException
{
}

View File

@@ -0,0 +1,185 @@
<?php
namespace Sabre\VObject;
/**
* FreeBusyData is a helper class that manages freebusy information.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class FreeBusyData
{
/**
* Start timestamp.
*
* @var int
*/
protected $start;
/**
* End timestamp.
*
* @var int
*/
protected $end;
/**
* A list of free-busy times.
*
* @var array
*/
protected $data;
public function __construct($start, $end)
{
$this->start = $start;
$this->end = $end;
$this->data = [];
$this->data[] = [
'start' => $this->start,
'end' => $this->end,
'type' => 'FREE',
];
}
/**
* Adds free or busytime to the data.
*
* @param int $start
* @param int $end
* @param string $type FREE, BUSY, BUSY-UNAVAILABLE or BUSY-TENTATIVE
*/
public function add($start, $end, $type)
{
if ($start > $this->end || $end < $this->start) {
// This new data is outside our timerange.
return;
}
if ($start < $this->start) {
// The item starts before our requested time range
$start = $this->start;
}
if ($end > $this->end) {
// The item ends after our requested time range
$end = $this->end;
}
// Finding out where we need to insert the new item.
$currentIndex = 0;
while ($start > $this->data[$currentIndex]['end']) {
++$currentIndex;
}
// The standard insertion point will be one _after_ the first
// overlapping item.
$insertStartIndex = $currentIndex + 1;
$newItem = [
'start' => $start,
'end' => $end,
'type' => $type,
];
$precedingItem = $this->data[$insertStartIndex - 1];
if ($this->data[$insertStartIndex - 1]['start'] === $start) {
// The old item starts at the exact same point as the new item.
--$insertStartIndex;
}
// Now we know where to insert the item, we need to know where it
// starts overlapping with items on the tail end. We need to start
// looking one item before the insertStartIndex, because it's possible
// that the new item 'sits inside' the previous old item.
if ($insertStartIndex > 0) {
$currentIndex = $insertStartIndex - 1;
} else {
$currentIndex = 0;
}
while ($end > $this->data[$currentIndex]['end']) {
++$currentIndex;
}
// What we are about to insert into the array
$newItems = [
$newItem,
];
// This is the amount of items that are completely overwritten by the
// new item.
$itemsToDelete = $currentIndex - $insertStartIndex;
if ($this->data[$currentIndex]['end'] <= $end) {
++$itemsToDelete;
}
// If itemsToDelete was -1, it means that the newly inserted item is
// actually sitting inside an existing one. This means we need to split
// the item at the current position in two and insert the new item in
// between.
if (-1 === $itemsToDelete) {
$itemsToDelete = 0;
if ($newItem['end'] < $precedingItem['end']) {
$newItems[] = [
'start' => $newItem['end'] + 1,
'end' => $precedingItem['end'],
'type' => $precedingItem['type'],
];
}
}
array_splice(
$this->data,
$insertStartIndex,
$itemsToDelete,
$newItems
);
$doMerge = false;
$mergeOffset = $insertStartIndex;
$mergeItem = $newItem;
$mergeDelete = 1;
if (isset($this->data[$insertStartIndex - 1])) {
// Updating the start time of the previous item.
$this->data[$insertStartIndex - 1]['end'] = $start;
// If the previous and the current are of the same type, we can
// merge them into one item.
if ($this->data[$insertStartIndex - 1]['type'] === $this->data[$insertStartIndex]['type']) {
$doMerge = true;
--$mergeOffset;
++$mergeDelete;
$mergeItem['start'] = $this->data[$insertStartIndex - 1]['start'];
}
}
if (isset($this->data[$insertStartIndex + 1])) {
// Updating the start time of the next item.
$this->data[$insertStartIndex + 1]['start'] = $end;
// If the next and the current are of the same type, we can
// merge them into one item.
if ($this->data[$insertStartIndex + 1]['type'] === $this->data[$insertStartIndex]['type']) {
$doMerge = true;
++$mergeDelete;
$mergeItem['end'] = $this->data[$insertStartIndex + 1]['end'];
}
}
if ($doMerge) {
array_splice(
$this->data,
$mergeOffset,
$mergeDelete,
[$mergeItem]
);
}
}
public function getData()
{
return $this->data;
}
}

View File

@@ -0,0 +1,549 @@
<?php
namespace Sabre\VObject;
use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Recur\EventIterator;
use Sabre\VObject\Recur\NoInstancesException;
/**
* This class helps with generating FREEBUSY reports based on existing sets of
* objects.
*
* It only looks at VEVENT and VFREEBUSY objects from the sourcedata, and
* generates a single VFREEBUSY object.
*
* VFREEBUSY components are described in RFC5545, The rules for what should
* go in a single freebusy report is taken from RFC4791, section 7.10.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class FreeBusyGenerator
{
/**
* Input objects.
*
* @var array
*/
protected $objects = [];
/**
* Start of range.
*
* @var DateTimeInterface|null
*/
protected $start;
/**
* End of range.
*
* @var DateTimeInterface|null
*/
protected $end;
/**
* VCALENDAR object.
*
* @var Document
*/
protected $baseObject;
/**
* Reference timezone.
*
* When we are calculating busy times, and we come across so-called
* floating times (times without a timezone), we use the reference timezone
* instead.
*
* This is also used for all-day events.
*
* This defaults to UTC.
*
* @var DateTimeZone
*/
protected $timeZone;
/**
* A VAVAILABILITY document.
*
* If this is set, its information will be included when calculating
* freebusy time.
*
* @var Document
*/
protected $vavailability;
/**
* Creates the generator.
*
* Check the setTimeRange and setObjects methods for details about the
* arguments.
*
* @param DateTimeInterface $start
* @param DateTimeInterface $end
* @param mixed $objects
* @param DateTimeZone $timeZone
*/
public function __construct(?DateTimeInterface $start = null, ?DateTimeInterface $end = null, $objects = null, ?DateTimeZone $timeZone = null)
{
$this->setTimeRange($start, $end);
if ($objects) {
$this->setObjects($objects);
}
if (is_null($timeZone)) {
$timeZone = new DateTimeZone('UTC');
}
$this->setTimeZone($timeZone);
}
/**
* Sets the VCALENDAR object.
*
* If this is set, it will not be generated for you. You are responsible
* for setting things like the METHOD, CALSCALE, VERSION, etc..
*
* The VFREEBUSY object will be automatically added though.
*/
public function setBaseObject(Document $vcalendar)
{
$this->baseObject = $vcalendar;
}
/**
* Sets a VAVAILABILITY document.
*/
public function setVAvailability(Document $vcalendar)
{
$this->vavailability = $vcalendar;
}
/**
* Sets the input objects.
*
* You must either specify a vcalendar object as a string, or as the parse
* Component.
* It's also possible to specify multiple objects as an array.
*
* @param mixed $objects
*/
public function setObjects($objects)
{
if (!is_array($objects)) {
$objects = [$objects];
}
$this->objects = [];
foreach ($objects as $object) {
if (is_string($object) || is_resource($object)) {
$this->objects[] = Reader::read($object);
} elseif ($object instanceof Component) {
$this->objects[] = $object;
} else {
throw new \InvalidArgumentException('You can only pass strings or \\Sabre\\VObject\\Component arguments to setObjects');
}
}
}
/**
* Sets the time range.
*
* Any freebusy object falling outside of this time range will be ignored.
*
* @param DateTimeInterface $start
* @param DateTimeInterface $end
*/
public function setTimeRange(?DateTimeInterface $start = null, ?DateTimeInterface $end = null)
{
if (!$start) {
$start = new DateTimeImmutable(Settings::$minDate);
}
if (!$end) {
$end = new DateTimeImmutable(Settings::$maxDate);
}
$this->start = $start;
$this->end = $end;
}
/**
* Sets the reference timezone for floating times.
*/
public function setTimeZone(DateTimeZone $timeZone)
{
$this->timeZone = $timeZone;
}
/**
* Parses the input data and returns a correct VFREEBUSY object, wrapped in
* a VCALENDAR.
*
* @return Component
*/
public function getResult()
{
$fbData = new FreeBusyData(
$this->start->getTimeStamp(),
$this->end->getTimeStamp()
);
if ($this->vavailability) {
$this->calculateAvailability($fbData, $this->vavailability);
}
$this->calculateBusy($fbData, $this->objects);
return $this->generateFreeBusyCalendar($fbData);
}
/**
* This method takes a VAVAILABILITY component and figures out all the
* available times.
*/
protected function calculateAvailability(FreeBusyData $fbData, VCalendar $vavailability)
{
$vavailComps = iterator_to_array($vavailability->VAVAILABILITY);
usort(
$vavailComps,
function ($a, $b) {
// We need to order the components by priority. Priority 1
// comes first, up until priority 9. Priority 0 comes after
// priority 9. No priority implies priority 0.
//
// Yes, I'm serious.
$priorityA = isset($a->PRIORITY) ? (int) $a->PRIORITY->getValue() : 0;
$priorityB = isset($b->PRIORITY) ? (int) $b->PRIORITY->getValue() : 0;
if (0 === $priorityA) {
$priorityA = 10;
}
if (0 === $priorityB) {
$priorityB = 10;
}
return $priorityA - $priorityB;
}
);
// Now we go over all the VAVAILABILITY components and figure if
// there's any we don't need to consider.
//
// This is can be because of one of two reasons: either the
// VAVAILABILITY component falls outside the time we are interested in,
// or a different VAVAILABILITY component with a higher priority has
// already completely covered the time-range.
$old = $vavailComps;
$new = [];
foreach ($old as $vavail) {
list($compStart, $compEnd) = $vavail->getEffectiveStartEnd();
// We don't care about datetimes that are earlier or later than the
// start and end of the freebusy report, so this gets normalized
// first.
if (is_null($compStart) || $compStart < $this->start) {
$compStart = $this->start;
}
if (is_null($compEnd) || $compEnd > $this->end) {
$compEnd = $this->end;
}
// If the item fell out of the timerange, we can just skip it.
if ($compStart > $this->end || $compEnd < $this->start) {
continue;
}
// Going through our existing list of components to see if there's
// a higher priority component that already fully covers this one.
foreach ($new as $higherVavail) {
list($higherStart, $higherEnd) = $higherVavail->getEffectiveStartEnd();
if (
(is_null($higherStart) || $higherStart < $compStart) &&
(is_null($higherEnd) || $higherEnd > $compEnd)
) {
// Component is fully covered by a higher priority
// component. We can skip this component.
continue 2;
}
}
// We're keeping it!
$new[] = $vavail;
}
// Lastly, we need to traverse the remaining components and fill in the
// freebusydata slots.
//
// We traverse the components in reverse, because we want the higher
// priority components to override the lower ones.
foreach (array_reverse($new) as $vavail) {
$busyType = isset($vavail->BUSYTYPE) ? strtoupper($vavail->BUSYTYPE) : 'BUSY-UNAVAILABLE';
list($vavailStart, $vavailEnd) = $vavail->getEffectiveStartEnd();
// Making the component size no larger than the requested free-busy
// report range.
if (!$vavailStart || $vavailStart < $this->start) {
$vavailStart = $this->start;
}
if (!$vavailEnd || $vavailEnd > $this->end) {
$vavailEnd = $this->end;
}
// Marking the entire time range of the VAVAILABILITY component as
// busy.
$fbData->add(
$vavailStart->getTimeStamp(),
$vavailEnd->getTimeStamp(),
$busyType
);
// Looping over the AVAILABLE components.
if (isset($vavail->AVAILABLE)) {
foreach ($vavail->AVAILABLE as $available) {
list($availStart, $availEnd) = $available->getEffectiveStartEnd();
$fbData->add(
$availStart->getTimeStamp(),
$availEnd->getTimeStamp(),
'FREE'
);
if ($available->RRULE) {
// Our favourite thing: recurrence!!
$rruleIterator = new Recur\RRuleIterator(
$available->RRULE->getValue(),
$availStart
);
$rruleIterator->fastForward($vavailStart);
$startEndDiff = $availStart->diff($availEnd);
while ($rruleIterator->valid()) {
$recurStart = $rruleIterator->current();
$recurEnd = $recurStart->add($startEndDiff);
if ($recurStart > $vavailEnd) {
// We're beyond the legal timerange.
break;
}
if ($recurEnd > $vavailEnd) {
// Truncating the end if it exceeds the
// VAVAILABILITY end.
$recurEnd = $vavailEnd;
}
$fbData->add(
$recurStart->getTimeStamp(),
$recurEnd->getTimeStamp(),
'FREE'
);
$rruleIterator->next();
}
}
}
}
}
}
/**
* This method takes an array of iCalendar objects and applies its busy
* times on fbData.
*
* @param VCalendar[] $objects
*/
protected function calculateBusy(FreeBusyData $fbData, array $objects)
{
foreach ($objects as $key => $object) {
foreach ($object->getBaseComponents() as $component) {
switch ($component->name) {
case 'VEVENT':
$FBTYPE = 'BUSY';
if (isset($component->TRANSP) && ('TRANSPARENT' === strtoupper($component->TRANSP))) {
break;
}
if (isset($component->STATUS)) {
$status = strtoupper($component->STATUS);
if ('CANCELLED' === $status) {
break;
}
if ('TENTATIVE' === $status) {
$FBTYPE = 'BUSY-TENTATIVE';
}
}
$times = [];
if ($component->RRULE) {
try {
$iterator = new EventIterator($object, (string) $component->UID, $this->timeZone);
} catch (NoInstancesException $e) {
// This event is recurring, but it doesn't have a single
// instance. We are skipping this event from the output
// entirely.
unset($this->objects[$key]);
break;
}
if ($this->start) {
$iterator->fastForward($this->start);
}
$maxRecurrences = Settings::$maxRecurrences;
while ($iterator->valid() && --$maxRecurrences) {
$startTime = $iterator->getDTStart();
if ($this->end && $startTime > $this->end) {
break;
}
$times[] = [
$iterator->getDTStart(),
$iterator->getDTEnd(),
];
$iterator->next();
}
} else {
$startTime = $component->DTSTART->getDateTime($this->timeZone);
if ($this->end && $startTime > $this->end) {
break;
}
$endTime = null;
if (isset($component->DTEND)) {
$endTime = $component->DTEND->getDateTime($this->timeZone);
} elseif (isset($component->DURATION)) {
$duration = DateTimeParser::parseDuration((string) $component->DURATION);
$endTime = clone $startTime;
$endTime = $endTime->add($duration);
} elseif (!$component->DTSTART->hasTime()) {
$endTime = clone $startTime;
$endTime = $endTime->modify('+1 day');
} else {
// The event had no duration (0 seconds)
break;
}
$times[] = [$startTime, $endTime];
}
foreach ($times as $time) {
if ($this->end && $time[0] > $this->end) {
break;
}
if ($this->start && $time[1] < $this->start) {
break;
}
$fbData->add(
$time[0]->getTimeStamp(),
$time[1]->getTimeStamp(),
$FBTYPE
);
}
break;
case 'VFREEBUSY':
foreach ($component->FREEBUSY as $freebusy) {
$fbType = isset($freebusy['FBTYPE']) ? strtoupper($freebusy['FBTYPE']) : 'BUSY';
// Skipping intervals marked as 'free'
if ('FREE' === $fbType) {
continue;
}
$values = explode(',', $freebusy);
foreach ($values as $value) {
list($startTime, $endTime) = explode('/', $value);
$startTime = DateTimeParser::parseDateTime($startTime);
if ('P' === substr($endTime, 0, 1) || '-P' === substr($endTime, 0, 2)) {
$duration = DateTimeParser::parseDuration($endTime);
$endTime = clone $startTime;
$endTime = $endTime->add($duration);
} else {
$endTime = DateTimeParser::parseDateTime($endTime);
}
if ($this->start && $this->start > $endTime) {
continue;
}
if ($this->end && $this->end < $startTime) {
continue;
}
$fbData->add(
$startTime->getTimeStamp(),
$endTime->getTimeStamp(),
$fbType
);
}
}
break;
}
}
}
}
/**
* This method takes a FreeBusyData object and generates the VCALENDAR
* object associated with it.
*
* @return VCalendar
*/
protected function generateFreeBusyCalendar(FreeBusyData $fbData)
{
if ($this->baseObject) {
$calendar = $this->baseObject;
} else {
$calendar = new VCalendar();
}
$vfreebusy = $calendar->createComponent('VFREEBUSY');
$calendar->add($vfreebusy);
if ($this->start) {
$dtstart = $calendar->createProperty('DTSTART');
$dtstart->setDateTime($this->start);
$vfreebusy->add($dtstart);
}
if ($this->end) {
$dtend = $calendar->createProperty('DTEND');
$dtend->setDateTime($this->end);
$vfreebusy->add($dtend);
}
$tz = new \DateTimeZone('UTC');
$dtstamp = $calendar->createProperty('DTSTAMP');
$dtstamp->setDateTime(new DateTimeImmutable('now', $tz));
$vfreebusy->add($dtstamp);
foreach ($fbData->getData() as $busyTime) {
$busyType = strtoupper($busyTime['type']);
// Ignoring all the FREE parts, because those are already assumed.
if ('FREE' === $busyType) {
continue;
}
$busyTime[0] = new \DateTimeImmutable('@'.$busyTime['start'], $tz);
$busyTime[1] = new \DateTimeImmutable('@'.$busyTime['end'], $tz);
$prop = $calendar->createProperty(
'FREEBUSY',
$busyTime[0]->format('Ymd\\THis\\Z').'/'.$busyTime[1]->format('Ymd\\THis\\Z')
);
// Only setting FBTYPE if it's not BUSY, because BUSY is the
// default anyway.
if ('BUSY' !== $busyType) {
$prop['FBTYPE'] = $busyType;
}
$vfreebusy->add($prop);
}
return $calendar;
}
}

1003
vendor/sabre/vobject/lib/ITip/Broker.php vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<?php
namespace Sabre\VObject\ITip;
use Exception;
/**
* This message is emitted in case of serious problems with iTip messages.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class ITipException extends Exception
{
}

View File

@@ -0,0 +1,136 @@
<?php
namespace Sabre\VObject\ITip;
/**
* This class represents an iTip message.
*
* A message holds all the information relevant to the message, including the
* object itself.
*
* It should for the most part be treated as immutable.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Message
{
/**
* The object's UID.
*
* @var string
*/
public $uid;
/**
* The component type, such as VEVENT.
*
* @var string
*/
public $component;
/**
* Contains the ITip method, which is something like REQUEST, REPLY or
* CANCEL.
*
* @var string
*/
public $method;
/**
* The current sequence number for the event.
*
* @var int
*/
public $sequence;
/**
* The senders' email address.
*
* Note that this does not imply that this has to be used in a From: field
* if the message is sent by email. It may also be populated in Reply-To:
* or not at all.
*
* @var string
*/
public $sender;
/**
* The name of the sender. This is often populated from a CN parameter from
* either the ORGANIZER or ATTENDEE, depending on the message.
*
* @var string|null
*/
public $senderName;
/**
* The recipient's email address.
*
* @var string
*/
public $recipient;
/**
* The name of the recipient. This is usually populated with the CN
* parameter from the ATTENDEE or ORGANIZER property, if it's available.
*
* @var string|null
*/
public $recipientName;
/**
* After the message has been delivered, this should contain a string such
* as : 1.1;Sent or 1.2;Delivered.
*
* In case of a failure, this will hold the error status code.
*
* See:
* http://tools.ietf.org/html/rfc6638#section-7.3
*
* @var string
*/
public $scheduleStatus;
/**
* The iCalendar / iTip body.
*
* @var \Sabre\VObject\Component\VCalendar
*/
public $message;
/**
* This will be set to true, if the iTip broker considers the change
* 'significant'.
*
* In practice, this means that we'll only mark it true, if for instance
* DTSTART changed. This allows systems to only send iTip messages when
* significant changes happened. This is especially useful for iMip, as
* normally a ton of messages may be generated for normal calendar use.
*
* To see the list of properties that are considered 'significant', check
* out Sabre\VObject\ITip\Broker::$significantChangeProperties.
*
* @var bool
*/
public $significantChange = true;
/**
* Returns the schedule status as a string.
*
* For example:
* 1.2
*
* @return mixed bool|string
*/
public function getScheduleStatus()
{
if (!$this->scheduleStatus) {
return false;
} else {
list($scheduleStatus) = explode(';', $this->scheduleStatus);
return $scheduleStatus;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Sabre\VObject\ITip;
/**
* SameOrganizerForAllComponentsException.
*
* This exception is emitted when an event is encountered with more than one
* component (e.g.: exceptions), but the organizer is not identical in every
* component.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class SameOrganizerForAllComponentsException extends ITipException
{
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Sabre\VObject;
/**
* This exception is thrown whenever an invalid value is found anywhere in a
* iCalendar or vCard object.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class InvalidDataException extends \Exception
{
}

256
vendor/sabre/vobject/lib/Node.php vendored Normal file
View File

@@ -0,0 +1,256 @@
<?php
namespace Sabre\VObject;
use Sabre\Xml;
/**
* A node is the root class for every element in an iCalendar of vCard object.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
abstract class Node implements \IteratorAggregate, \ArrayAccess, \Countable, \JsonSerializable, Xml\XmlSerializable
{
/**
* The following constants are used by the validate() method.
*
* If REPAIR is set, the validator will attempt to repair any broken data
* (if possible).
*/
const REPAIR = 1;
/**
* If this option is set, the validator will operate on the vcards on the
* assumption that the vcards need to be valid for CardDAV.
*
* This means for example that the UID is required, whereas it is not for
* regular vcards.
*/
const PROFILE_CARDDAV = 2;
/**
* If this option is set, the validator will operate on iCalendar objects
* on the assumption that the vcards need to be valid for CalDAV.
*
* This means for example that calendars can only contain objects with
* identical component types and UIDs.
*/
const PROFILE_CALDAV = 4;
/**
* Reference to the parent object, if this is not the top object.
*
* @var Node
*/
public $parent;
/**
* Iterator override.
*
* @var ElementList
*/
protected $iterator = null;
/**
* The root document.
*
* @var Component
*/
protected $root;
/**
* Serializes the node into a mimedir format.
*
* @return string
*/
abstract public function serialize();
/**
* 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]
abstract public function jsonSerialize();
/**
* This method serializes the data into XML. This is used to create xCard or
* xCal documents.
*
* @param Xml\Writer $writer XML writer
*/
abstract public function xmlSerialize(Xml\Writer $writer): void;
/**
* 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()
{
$this->parent = null;
$this->root = null;
}
/* {{{ IteratorAggregator interface */
/**
* Returns the iterator for this object.
*
* @return ElementList
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
if (!is_null($this->iterator)) {
return $this->iterator;
}
return new ElementList([$this]);
}
/**
* Sets the overridden iterator.
*
* Note that this is not actually part of the iterator interface
*/
public function setIterator(ElementList $iterator)
{
$this->iterator = $iterator;
}
/**
* Validates the node for correctness.
*
* The following options are supported:
* Node::REPAIR - May attempt to automatically repair the problem.
*
* 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 - An inconsequential issue
* 3 - A severe issue.
*
* @param int $options
*
* @return array
*/
public function validate($options = 0)
{
return [];
}
/* }}} */
/* {{{ Countable interface */
/**
* Returns the number of elements.
*
* @return int
*/
#[\ReturnTypeWillChange]
public function count()
{
$it = $this->getIterator();
return $it->count();
}
/* }}} */
/* {{{ ArrayAccess Interface */
/**
* Checks if an item exists through ArrayAccess.
*
* This method just forwards the request to the inner iterator
*
* @param int $offset
*
* @return bool
*/
#[\ReturnTypeWillChange]
public function offsetExists($offset)
{
$iterator = $this->getIterator();
return $iterator->offsetExists($offset);
}
/**
* Gets an item through ArrayAccess.
*
* This method just forwards the request to the inner iterator
*
* @param int $offset
*
* @return mixed
*/
#[\ReturnTypeWillChange]
public function offsetGet($offset)
{
$iterator = $this->getIterator();
return $iterator->offsetGet($offset);
}
/**
* Sets an item through ArrayAccess.
*
* This method just forwards the request to the inner iterator
*
* @param int $offset
* @param mixed $value
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetSet($offset, $value)
{
$iterator = $this->getIterator();
$iterator->offsetSet($offset, $value);
// @codeCoverageIgnoreStart
//
// This method always throws an exception, so we ignore the closing
// brace
}
// @codeCoverageIgnoreEnd
/**
* Sets an item through ArrayAccess.
*
* This method just forwards the request to the inner iterator
*
* @param int $offset
*
* @return void
*/
#[\ReturnTypeWillChange]
public function offsetUnset($offset)
{
$iterator = $this->getIterator();
$iterator->offsetUnset($offset);
// @codeCoverageIgnoreStart
//
// This method always throws an exception, so we ignore the closing
// brace
}
// @codeCoverageIgnoreEnd
/* }}} */
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Sabre\VObject;
/**
* PHPUnit Assertions.
*
* This trait can be added to your unittest to make it easier to test iCalendar
* and/or vCards.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
trait PHPUnitAssertions
{
/**
* This method tests whether two vcards or icalendar objects are
* semantically identical.
*
* It supports objects being supplied as strings, streams or
* Sabre\VObject\Component instances.
*
* PRODID is removed from both objects as this is often changes and would
* just get in the way.
*
* CALSCALE will automatically get removed if it's set to GREGORIAN.
*
* Any property that has the value **ANY** will be treated as a wildcard.
*
* @param resource|string|Component $expected
* @param resource|string|Component $actual
* @param string $message
*/
public function assertVObjectEqualsVObject($expected, $actual, $message = '')
{
$getObj = function ($input) {
if (is_resource($input)) {
$input = stream_get_contents($input);
}
if (is_string($input)) {
$input = Reader::read($input);
}
if (!$input instanceof Component) {
$this->fail('Input must be a string, stream or VObject component');
}
unset($input->PRODID);
if ($input instanceof Component\VCalendar && 'GREGORIAN' === (string) $input->CALSCALE) {
unset($input->CALSCALE);
}
return $input;
};
$expected = $getObj($expected)->serialize();
$actual = $getObj($actual)->serialize();
// Finding wildcards in expected.
preg_match_all('|^([A-Z]+):\\*\\*ANY\\*\\*\r$|m', $expected, $matches, PREG_SET_ORDER);
foreach ($matches as $match) {
$actual = preg_replace(
'|^'.preg_quote($match[1], '|').':(.*)\r$|m',
$match[1].':**ANY**'."\r",
$actual
);
}
$this->assertEquals(
$expected,
$actual,
$message
);
}
}

368
vendor/sabre/vobject/lib/Parameter.php vendored Normal file
View File

@@ -0,0 +1,368 @@
<?php
namespace Sabre\VObject;
use ArrayIterator;
use Sabre\Xml;
/**
* VObject Parameter.
*
* This class represents a parameter. A parameter is always tied to a property.
* In the case of:
* DTSTART;VALUE=DATE:20101108
* VALUE=DATE would be the parameter name and value.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Parameter extends Node
{
/**
* Parameter name.
*
* @var string
*/
public $name;
/**
* vCard 2.1 allows parameters to be encoded without a name.
*
* We can deduce the parameter name based on its value.
*
* @var bool
*/
public $noName = false;
/**
* Parameter value.
*
* @var string
*/
protected $value;
/**
* Sets up the object.
*
* It's recommended to use the create:: factory method instead.
*
* @param string $name
* @param string $value
*/
public function __construct(Document $root, $name, $value = null)
{
$this->root = $root;
if (is_null($name)) {
$this->noName = true;
$this->name = static::guessParameterNameByValue($value);
} else {
$this->name = strtoupper($name);
}
// If guessParameterNameByValue() returns an empty string
// above, we're actually dealing with a parameter that has no value.
// In that case we have to move the value to the name.
if ('' === $this->name) {
$this->noName = false;
$this->name = strtoupper($value);
} else {
$this->setValue($value);
}
}
/**
* Try to guess property name by value, can be used for vCard 2.1 nameless parameters.
*
* Figuring out what the name should have been. Note that a ton of
* these are rather silly in 2014 and would probably rarely be
* used, but we like to be complete.
*
* @param string $value
*
* @return string
*/
public static function guessParameterNameByValue($value)
{
switch (strtoupper($value)) {
// Encodings
case '7-BIT':
case 'QUOTED-PRINTABLE':
case 'BASE64':
$name = 'ENCODING';
break;
// Common types
case 'WORK':
case 'HOME':
case 'PREF':
// Delivery Label Type
case 'DOM':
case 'INTL':
case 'POSTAL':
case 'PARCEL':
// Telephone types
case 'VOICE':
case 'FAX':
case 'MSG':
case 'CELL':
case 'PAGER':
case 'BBS':
case 'MODEM':
case 'CAR':
case 'ISDN':
case 'VIDEO':
// EMAIL types (lol)
case 'AOL':
case 'APPLELINK':
case 'ATTMAIL':
case 'CIS':
case 'EWORLD':
case 'INTERNET':
case 'IBMMAIL':
case 'MCIMAIL':
case 'POWERSHARE':
case 'PRODIGY':
case 'TLX':
case 'X400':
// Photo / Logo format types
case 'GIF':
case 'CGM':
case 'WMF':
case 'BMP':
case 'DIB':
case 'PICT':
case 'TIFF':
case 'PDF':
case 'PS':
case 'JPEG':
case 'MPEG':
case 'MPEG2':
case 'AVI':
case 'QTIME':
// Sound Digital Audio Type
case 'WAVE':
case 'PCM':
case 'AIFF':
// Key types
case 'X509':
case 'PGP':
$name = 'TYPE';
break;
// Value types
case 'INLINE':
case 'URL':
case 'CONTENT-ID':
case 'CID':
$name = 'VALUE';
break;
default:
$name = '';
}
return $name;
}
/**
* Updates the current value.
*
* This may be either a single, or multiple strings in an array.
*
* @param string|array $value
*/
public function setValue($value)
{
$this->value = $value;
}
/**
* Returns the current value.
*
* This method will always return a string, or null. If there were multiple
* values, it will automatically concatenate them (separated by comma).
*
* @return string|null
*/
public function getValue()
{
if (is_array($this->value)) {
return implode(',', $this->value);
} else {
return $this->value;
}
}
/**
* Sets multiple values for this parameter.
*/
public function setParts(array $value)
{
$this->value = $value;
}
/**
* Returns all values for this parameter.
*
* If there were no values, an empty array will be returned.
*
* @return array
*/
public function getParts()
{
if (is_array($this->value)) {
return $this->value;
} elseif (is_null($this->value)) {
return [];
} else {
return [$this->value];
}
}
/**
* Adds a value to this parameter.
*
* If the argument is specified as an array, all items will be added to the
* parameter value list.
*
* @param string|array $part
*/
public function addValue($part)
{
if (is_null($this->value)) {
$this->value = $part;
} else {
$this->value = array_merge((array) $this->value, (array) $part);
}
}
/**
* Checks if this parameter contains the specified value.
*
* This is a case-insensitive match. It makes sense to call this for for
* instance the TYPE parameter, to see if it contains a keyword such as
* 'WORK' or 'FAX'.
*
* @param string $value
*
* @return bool
*/
public function has($value)
{
return in_array(
strtolower($value),
array_map('strtolower', (array) $this->value)
);
}
/**
* Turns the object back into a serialized blob.
*
* @return string
*/
public function serialize()
{
$value = $this->getParts();
if (0 === count($value)) {
return $this->name.'=';
}
if (Document::VCARD21 === $this->root->getDocumentType() && $this->noName) {
return implode(';', $value);
}
return $this->name.'='.array_reduce(
$value,
function ($out, $item) {
if (!is_null($out)) {
$out .= ',';
}
// If there's no special characters in the string, we'll use the simple
// format.
//
// The list of special characters is defined as:
//
// Any character except CONTROL, DQUOTE, ";", ":", ","
//
// by the iCalendar spec:
// https://tools.ietf.org/html/rfc5545#section-3.1
//
// And we add ^ to that because of:
// https://tools.ietf.org/html/rfc6868
//
// But we've found that iCal (7.0, shipped with OSX 10.9)
// severely trips on + characters not being quoted, so we
// added + as well.
if (!preg_match('#(?: [\n":;\^,\+] )#x', $item)) {
return $out.$item;
} else {
// Enclosing in double-quotes, and using RFC6868 for encoding any
// special characters
$out .= '"'.strtr(
$item,
[
'^' => '^^',
"\n" => '^n',
'"' => '^\'',
]
).'"';
return $out;
}
}
);
}
/**
* 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()
{
return $this->value;
}
/**
* 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
{
foreach (explode(',', $this->value) as $value) {
$writer->writeElement('text', $value);
}
}
/**
* Called when this object is being cast to a string.
*
* @return string
*/
public function __toString()
{
return (string) $this->getValue();
}
/**
* Returns the iterator for this object.
*
* @return ElementList
*/
#[\ReturnTypeWillChange]
public function getIterator()
{
if (!is_null($this->iterator)) {
return $this->iterator;
}
return $this->iterator = new ArrayIterator((array) $this->value);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Sabre\VObject;
/**
* Exception thrown by Reader if an invalid object was attempted to be parsed.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class ParseException extends \Exception
{
}

190
vendor/sabre/vobject/lib/Parser/Json.php vendored Normal file
View File

@@ -0,0 +1,190 @@
<?php
namespace Sabre\VObject\Parser;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VCard;
use Sabre\VObject\Document;
use Sabre\VObject\EofException;
use Sabre\VObject\ParseException;
use Sabre\VObject\Property\FlatText;
use Sabre\VObject\Property\Text;
/**
* Json Parser.
*
* This parser parses both the jCal and jCard formats.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class Json extends Parser
{
/**
* The input data.
*
* @var array
*/
protected $input;
/**
* Root component.
*
* @var Document
*/
protected $root;
/**
* This method starts the parsing process.
*
* If the input was not supplied during construction, it's possible to pass
* it here instead.
*
* If either input or options are not supplied, the defaults will be used.
*
* @param resource|string|array|null $input
* @param int $options
*
* @return \Sabre\VObject\Document
*/
public function parse($input = null, $options = 0)
{
if (!is_null($input)) {
$this->setInput($input);
}
if (is_null($this->input)) {
throw new EofException('End of input stream, or no input supplied');
}
if (0 !== $options) {
$this->options = $options;
}
switch ($this->input[0]) {
case 'vcalendar':
$this->root = new VCalendar([], false);
break;
case 'vcard':
$this->root = new VCard([], false);
break;
default:
throw new ParseException('The root component must either be a vcalendar, or a vcard');
}
foreach ($this->input[1] as $prop) {
$this->root->add($this->parseProperty($prop));
}
if (isset($this->input[2])) {
foreach ($this->input[2] as $comp) {
$this->root->add($this->parseComponent($comp));
}
}
// Resetting the input so we can throw an feof exception the next time.
$this->input = null;
return $this->root;
}
/**
* Parses a component.
*
* @return \Sabre\VObject\Component
*/
public function parseComponent(array $jComp)
{
// We can remove $self from PHP 5.4 onward.
$self = $this;
$properties = array_map(
function ($jProp) use ($self) {
return $self->parseProperty($jProp);
},
$jComp[1]
);
if (isset($jComp[2])) {
$components = array_map(
function ($jComp) use ($self) {
return $self->parseComponent($jComp);
},
$jComp[2]
);
} else {
$components = [];
}
return $this->root->createComponent(
$jComp[0],
array_merge($properties, $components),
$defaults = false
);
}
/**
* Parses properties.
*
* @return \Sabre\VObject\Property
*/
public function parseProperty(array $jProp)
{
list(
$propertyName,
$parameters,
$valueType
) = $jProp;
$propertyName = strtoupper($propertyName);
// This is the default class we would be using if we didn't know the
// value type. We're using this value later in this function.
$defaultPropertyClass = $this->root->getClassNameForPropertyName($propertyName);
$parameters = (array) $parameters;
$value = array_slice($jProp, 3);
$valueType = strtoupper($valueType);
if (isset($parameters['group'])) {
$propertyName = $parameters['group'].'.'.$propertyName;
unset($parameters['group']);
}
$prop = $this->root->createProperty($propertyName, null, $parameters, $valueType);
$prop->setJsonValue($value);
// We have to do something awkward here. FlatText as well as Text
// represents TEXT values. We have to normalize these here. In the
// future we can get rid of FlatText once we're allowed to break BC
// again.
if (FlatText::class === $defaultPropertyClass) {
$defaultPropertyClass = Text::class;
}
// If the value type we received (e.g.: TEXT) was not the default value
// type for the given property (e.g.: BDAY), we need to add a VALUE=
// parameter.
if ($defaultPropertyClass !== get_class($prop)) {
$prop['VALUE'] = $valueType;
}
return $prop;
}
/**
* Sets the input data.
*
* @param resource|string|array $input
*/
public function setInput($input)
{
if (is_resource($input)) {
$input = stream_get_contents($input);
}
if (is_string($input)) {
$input = json_decode($input);
}
$this->input = $input;
}
}

View File

@@ -0,0 +1,710 @@
<?php
namespace Sabre\VObject\Parser;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VCard;
use Sabre\VObject\Document;
use Sabre\VObject\EofException;
use Sabre\VObject\Node;
use Sabre\VObject\ParseException;
/**
* MimeDir parser.
*
* This class parses iCalendar 2.0 and vCard 2.1, 3.0 and 4.0 files. This
* parser will return one of the following two objects from the parse method:
*
* Sabre\VObject\Component\VCalendar
* Sabre\VObject\Component\VCard
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
class MimeDir extends Parser
{
public const TOKEN_PROPNAME = 1;
public const TOKEN_PROPVALUE = 2;
public const TOKEN_PARAMNAME = 3;
public const TOKEN_PARAMVALUE = 4;
/**
* The input stream.
*
* @var resource
*/
protected $input;
/**
* Root component.
*
* @var Component
*/
protected $root;
/**
* By default all input will be assumed to be UTF-8.
*
* However, both iCalendar and vCard might be encoded using different
* character sets. The character set is usually set in the mime-type.
*
* If this is the case, use setEncoding to specify that a different
* encoding will be used. If this is set, the parser will automatically
* convert all incoming data to UTF-8.
*
* @var string
*/
protected $charset = 'UTF-8';
/**
* The list of character sets we support when decoding.
*
* This would be a const expression but for now we need to support PHP 5.5
*/
protected static $SUPPORTED_CHARSETS = [
'UTF-8',
'ISO-8859-1',
'Windows-1252',
];
/**
* Parses an iCalendar or vCard file.
*
* Pass a stream or a string. If null is parsed, the existing buffer is
* used.
*
* @param string|resource|null $input
* @param int $options
*
* @return \Sabre\VObject\Document
*/
public function parse($input = null, $options = 0)
{
$this->root = null;
if (!is_null($input)) {
$this->setInput($input);
}
if (!\is_resource($this->input)) {
// Null was passed as input, but there was no existing input buffer
// There is nothing to parse.
throw new ParseException('No input provided to parse');
}
if (0 !== $options) {
$this->options = $options;
}
$this->parseDocument();
return $this->root;
}
/**
* By default all input will be assumed to be UTF-8.
*
* However, both iCalendar and vCard might be encoded using different
* character sets. The character set is usually set in the mime-type.
*
* If this is the case, use setEncoding to specify that a different
* encoding will be used. If this is set, the parser will automatically
* convert all incoming data to UTF-8.
*
* @param string $charset
*/
public function setCharset($charset)
{
if (!in_array($charset, self::$SUPPORTED_CHARSETS)) {
throw new \InvalidArgumentException('Unsupported encoding. (Supported encodings: '.implode(', ', self::$SUPPORTED_CHARSETS).')');
}
$this->charset = $charset;
}
/**
* Sets the input buffer. Must be a string or stream.
*
* @param resource|string $input
*/
public function setInput($input)
{
// Resetting the parser
$this->lineIndex = 0;
$this->startLine = 0;
if (is_string($input)) {
// Converting to a stream.
$stream = fopen('php://temp', 'r+');
fwrite($stream, $input);
rewind($stream);
$this->input = $stream;
} elseif (is_resource($input)) {
$this->input = $input;
} else {
throw new \InvalidArgumentException('This parser can only read from strings or streams.');
}
}
/**
* Parses an entire document.
*/
protected function parseDocument()
{
$line = $this->readLine();
// BOM is ZERO WIDTH NO-BREAK SPACE (U+FEFF).
// It's 0xEF 0xBB 0xBF in UTF-8 hex.
if (3 <= strlen($line)
&& 0xef === ord($line[0])
&& 0xbb === ord($line[1])
&& 0xbf === ord($line[2])) {
$line = substr($line, 3);
}
switch (strtoupper($line)) {
case 'BEGIN:VCALENDAR':
$class = VCalendar::$componentMap['VCALENDAR'];
break;
case 'BEGIN:VCARD':
$class = VCard::$componentMap['VCARD'];
break;
default:
throw new ParseException('This parser only supports VCARD and VCALENDAR files');
}
$this->root = new $class([], false);
while (true) {
// Reading until we hit END:
try {
$line = $this->readLine();
} catch (EofException $oEx) {
$line = 'END:'.$this->root->name;
}
if ('END:' === strtoupper(substr($line, 0, 4))) {
break;
}
$result = $this->parseLine($line);
if ($result) {
$this->root->add($result);
}
}
$name = strtoupper(substr($line, 4));
if ($name !== $this->root->name) {
throw new ParseException('Invalid MimeDir file. expected: "END:'.$this->root->name.'" got: "END:'.$name.'"');
}
}
/**
* Parses a line, and if it hits a component, it will also attempt to parse
* the entire component.
*
* @param string $line Unfolded line
*
* @return Node
*/
protected function parseLine($line)
{
// Start of a new component
if ('BEGIN:' === strtoupper(substr($line, 0, 6))) {
if (substr($line, 6) === $this->root->name) {
throw new ParseException('Invalid MimeDir file. Unexpected component: "'.$line.'" in document type '.$this->root->name);
}
$component = $this->root->createComponent(substr($line, 6), [], false);
while (true) {
// Reading until we hit END:
$line = $this->readLine();
if ('END:' === strtoupper(substr($line, 0, 4))) {
break;
}
$result = $this->parseLine($line);
if ($result) {
$component->add($result);
}
}
$name = strtoupper(substr($line, 4));
if ($name !== $component->name) {
throw new ParseException('Invalid MimeDir file. expected: "END:'.$component->name.'" got: "END:'.$name.'"');
}
return $component;
} else {
// Property reader
$property = $this->readProperty($line);
if (!$property) {
// Ignored line
return false;
}
return $property;
}
}
/**
* We need to look ahead 1 line every time to see if we need to 'unfold'
* the next line.
*
* If that was not the case, we store it here.
*
* @var string|null
*/
protected $lineBuffer;
/**
* The real current line number.
*/
protected $lineIndex = 0;
/**
* In the case of unfolded lines, this property holds the line number for
* the start of the line.
*
* @var int
*/
protected $startLine = 0;
/**
* Contains a 'raw' representation of the current line.
*
* @var string
*/
protected $rawLine;
/**
* Reads a single line from the buffer.
*
* This method strips any newlines and also takes care of unfolding.
*
* @throws \Sabre\VObject\EofException
*
* @return string
*/
protected function readLine()
{
if (!\is_null($this->lineBuffer)) {
$rawLine = $this->lineBuffer;
$this->lineBuffer = null;
} else {
do {
$eof = \feof($this->input);
$rawLine = \fgets($this->input);
if ($eof || (\feof($this->input) && false === $rawLine)) {
throw new EofException('End of document reached prematurely');
}
if (false === $rawLine) {
throw new ParseException('Error reading from input stream');
}
$rawLine = \rtrim($rawLine, "\r\n");
} while ('' === $rawLine); // Skipping empty lines
++$this->lineIndex;
}
$line = $rawLine;
$this->startLine = $this->lineIndex;
// Looking ahead for folded lines.
while (true) {
$nextLine = \rtrim(\fgets($this->input), "\r\n");
++$this->lineIndex;
if (!$nextLine) {
break;
}
if ("\t" === $nextLine[0] || ' ' === $nextLine[0]) {
$curLine = \substr($nextLine, 1);
$line .= $curLine;
$rawLine .= "\n ".$curLine;
} else {
$this->lineBuffer = $nextLine;
break;
}
}
$this->rawLine = $rawLine;
return $line;
}
/**
* Reads a property or component from a line.
*/
protected function readProperty($line)
{
if ($this->options & self::OPTION_FORGIVING) {
$propNameToken = 'A-Z0-9\-\._\\/';
} else {
$propNameToken = 'A-Z0-9\-\.';
}
$paramNameToken = 'A-Z0-9\-';
$safeChar = '^";:,';
$qSafeChar = '^"';
$regex = "/
^(?P<name> [$propNameToken]+ ) (?=[;:]) # property name
|
(?<=:)(?P<propValue> .+)$ # property value
|
;(?P<paramName> [$paramNameToken]+) (?=[=;:]) # parameter name
|
(=|,)(?P<paramValue> # parameter value
(?: [$safeChar]*) |
\"(?: [$qSafeChar]+)\"
) (?=[;:,])
/xi";
//echo $regex, "\n"; exit();
preg_match_all($regex, $line, $matches, PREG_SET_ORDER);
$property = [
'name' => null,
'parameters' => [],
'value' => null,
];
/*
* Keep track on the last token we parsed in order to do
* better error checking
*/
$lastToken = null;
$lastParam = null;
/*
* Looping through all the tokens.
*
* Note that we are looping through them in reverse order, because if a
* sub-pattern matched, the subsequent named patterns will not show up
* in the result.
*/
foreach ($matches as $match) {
if (isset($match['paramValue'])) {
if ($match['paramValue'] && '"' === $match['paramValue'][0]) {
$value = substr($match['paramValue'], 1, -1);
} else {
$value = $match['paramValue'];
}
$value = $this->unescapeParam($value);
if (is_null($lastParam)) {
if ($this->options & self::OPTION_IGNORE_INVALID_LINES) {
// When the property can't be matched and the configuration
// option is set to ignore invalid lines, we ignore this line
// This can happen when servers provide faulty data as iCloud
// frequently does with X-APPLE-STRUCTURED-LOCATION
$lastToken = self::TOKEN_PARAMVALUE;
continue;
}
throw new ParseException('Invalid Mimedir file. Line starting at '.$this->startLine.' did not follow iCalendar/vCard conventions');
}
if ('=' == $match[0][0] && self::TOKEN_PARAMNAME != $lastToken) {
throw new ParseException('Invalid Mimedir file. Line starting at '.$this->startLine.': Missing parameter name for parameter value "'.$match['paramValue'].'"');
}
if (is_null($property['parameters'][$lastParam])) {
$property['parameters'][$lastParam] = $value;
} elseif (is_array($property['parameters'][$lastParam])) {
$property['parameters'][$lastParam][] = $value;
} elseif ($property['parameters'][$lastParam] === $value) {
// When the current value of the parameter is the same as the
// new one, then we can leave the current parameter as it is.
} else {
$property['parameters'][$lastParam] = [
$property['parameters'][$lastParam],
$value,
];
}
$lastToken = self::TOKEN_PARAMVALUE;
continue;
}
if (isset($match['paramName'])) {
$lastParam = strtoupper($match['paramName']);
if (!isset($property['parameters'][$lastParam])) {
$property['parameters'][$lastParam] = null;
}
$lastToken = self::TOKEN_PARAMNAME;
continue;
}
if (isset($match['propValue'])) {
$property['value'] = $match['propValue'];
$lastToken = self::TOKEN_PROPVALUE;
continue;
}
if (isset($match['name']) && 0 < strlen($match['name'])) {
$property['name'] = strtoupper($match['name']);
$lastToken = self::TOKEN_PROPNAME;
continue;
}
// @codeCoverageIgnoreStart
throw new \LogicException('This code should not be reachable');
// @codeCoverageIgnoreEnd
}
if (is_null($property['value'])) {
$property['value'] = '';
}
if (!isset($property['name']) || 0 == strlen($property['name'])) {
if ($this->options & self::OPTION_IGNORE_INVALID_LINES) {
return false;
}
throw new ParseException('Invalid Mimedir file. Line starting at '.$this->startLine.' did not follow iCalendar/vCard conventions');
}
// vCard 2.1 states that parameters may appear without a name, and only
// a value. We can deduce the value based on its name.
//
// Our parser will get those as parameters without a value instead, so
// we're filtering these parameters out first.
$namedParameters = [];
$namelessParameters = [];
foreach ($property['parameters'] as $name => $value) {
if (!is_null($value)) {
$namedParameters[$name] = $value;
} else {
$namelessParameters[] = $name;
}
}
$propObj = $this->root->createProperty($property['name'], null, $namedParameters, null, $this->startLine, $line);
foreach ($namelessParameters as $namelessParameter) {
$propObj->add(null, $namelessParameter);
}
if (isset($propObj['ENCODING']) && 'QUOTED-PRINTABLE' === strtoupper($propObj['ENCODING'])) {
$propObj->setQuotedPrintableValue($this->extractQuotedPrintableValue());
} else {
$charset = $this->charset;
if (Document::VCARD21 === $this->root->getDocumentType() && isset($propObj['CHARSET'])) {
// vCard 2.1 allows the character set to be specified per property.
$charset = (string) $propObj['CHARSET'];
}
switch (strtolower($charset)) {
case 'utf-8':
break;
case 'windows-1252':
case 'iso-8859-1':
$property['value'] = mb_convert_encoding($property['value'], 'UTF-8', $charset);
break;
default:
throw new ParseException('Unsupported CHARSET: '.$propObj['CHARSET']);
}
$propObj->setRawMimeDirValue($property['value']);
}
return $propObj;
}
/**
* Unescapes a property value.
*
* vCard 2.1 says:
* * Semi-colons must be escaped in some property values, specifically
* ADR, ORG and N.
* * Semi-colons must be escaped in parameter values, because semi-colons
* are also use to separate values.
* * No mention of escaping backslashes with another backslash.
* * newlines are not escaped either, instead QUOTED-PRINTABLE is used to
* span values over more than 1 line.
*
* vCard 3.0 says:
* * (rfc2425) Backslashes, newlines (\n or \N) and comma's must be
* escaped, all time time.
* * Comma's are used for delimiters in multiple values
* * (rfc2426) Adds to to this that the semi-colon MUST also be escaped,
* as in some properties semi-colon is used for separators.
* * Properties using semi-colons: N, ADR, GEO, ORG
* * Both ADR and N's individual parts may be broken up further with a
* comma.
* * Properties using commas: NICKNAME, CATEGORIES
*
* vCard 4.0 (rfc6350) says:
* * Commas must be escaped.
* * Semi-colons may be escaped, an unescaped semi-colon _may_ be a
* delimiter, depending on the property.
* * Backslashes must be escaped
* * Newlines must be escaped as either \N or \n.
* * Some compound properties may contain multiple parts themselves, so a
* comma within a semi-colon delimited property may also be unescaped
* to denote multiple parts _within_ the compound property.
* * Text-properties using semi-colons: N, ADR, ORG, CLIENTPIDMAP.
* * Text-properties using commas: NICKNAME, RELATED, CATEGORIES, PID.
*
* Even though the spec says that commas must always be escaped, the
* example for GEO in Section 6.5.2 seems to violate this.
*
* iCalendar 2.0 (rfc5545) says:
* * Commas or semi-colons may be used as delimiters, depending on the
* property.
* * Commas, semi-colons, backslashes, newline (\N or \n) are always
* escaped, unless they are delimiters.
* * Colons shall not be escaped.
* * Commas can be considered the 'default delimiter' and is described as
* the delimiter in cases where the order of the multiple values is
* insignificant.
* * Semi-colons are described as the delimiter for 'structured values'.
* They are specifically used in Semi-colons are used as a delimiter in
* REQUEST-STATUS, RRULE, GEO and EXRULE. EXRULE is deprecated however.
*
* Now for the parameters
*
* If delimiter is not set (empty string) this method will just return a string.
* If it's a comma or a semi-colon the string will be split on those
* characters, and always return an array.
*
* @param string $input
* @param string $delimiter
*
* @return string|string[]
*/
public static function unescapeValue($input, $delimiter = ';')
{
$regex = '# (?: (\\\\ (?: \\\\ | N | n | ; | , ) )';
if ($delimiter) {
$regex .= ' | ('.$delimiter.')';
}
$regex .= ') #x';
$matches = preg_split($regex, $input, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$resultArray = [];
$result = '';
foreach ($matches as $match) {
switch ($match) {
case '\\\\':
$result .= '\\';
break;
case '\N':
case '\n':
$result .= "\n";
break;
case '\;':
$result .= ';';
break;
case '\,':
$result .= ',';
break;
case $delimiter:
$resultArray[] = $result;
$result = '';
break;
default:
$result .= $match;
break;
}
}
$resultArray[] = $result;
return $delimiter ? $resultArray : $result;
}
/**
* Unescapes a parameter value.
*
* vCard 2.1:
* * Does not mention a mechanism for this. In addition, double quotes
* are never used to wrap values.
* * This means that parameters can simply not contain colons or
* semi-colons.
*
* vCard 3.0 (rfc2425, rfc2426):
* * Parameters _may_ be surrounded by double quotes.
* * If this is not the case, semi-colon, colon and comma may simply not
* occur (the comma used for multiple parameter values though).
* * If it is surrounded by double-quotes, it may simply not contain
* double-quotes.
* * This means that a parameter can in no case encode double-quotes, or
* newlines.
*
* vCard 4.0 (rfc6350)
* * Behavior seems to be identical to vCard 3.0
*
* iCalendar 2.0 (rfc5545)
* * Behavior seems to be identical to vCard 3.0
*
* Parameter escaping mechanism (rfc6868) :
* * This rfc describes a new way to escape parameter values.
* * New-line is encoded as ^n
* * ^ is encoded as ^^.
* * " is encoded as ^'
*
* @param string $input
*/
private function unescapeParam($input)
{
return
preg_replace_callback(
'#(\^(\^|n|\'))#',
function ($matches) {
switch ($matches[2]) {
case 'n':
return "\n";
case '^':
return '^';
case '\'':
return '"';
// @codeCoverageIgnoreStart
}
// @codeCoverageIgnoreEnd
},
$input
);
}
/**
* Gets the full quoted printable value.
*
* We need a special method for this, because newlines have both a meaning
* in vCards, and in QuotedPrintable.
*
* This method does not do any decoding.
*
* @return string
*/
private function extractQuotedPrintableValue()
{
// We need to parse the raw line again to get the start of the value.
//
// We are basically looking for the first colon (:), but we need to
// skip over the parameters first, as they may contain one.
$regex = '/^
(?: [^:])+ # Anything but a colon
(?: "[^"]")* # A parameter in double quotes
: # start of the value we really care about
(.*)$
/xs';
preg_match($regex, $this->rawLine, $matches);
$value = $matches[1];
// Removing the first whitespace character from every line. Kind of
// like unfolding, but we keep the newline.
$value = str_replace("\n ", "\n", $value);
// Microsoft products don't always correctly fold lines, they may be
// missing a whitespace. So if 'forgiving' is turned on, we will take
// those as well.
if ($this->options & self::OPTION_FORGIVING) {
while ('=' === substr($value, -1) && $this->lineBuffer) {
// Reading the line
$this->readLine();
// Grabbing the raw form
$value .= "\n".$this->rawLine;
}
}
return $value;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Sabre\VObject\Parser;
/**
* Abstract parser.
*
* This class serves as a base-class for the different parsers.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Evert Pot (http://evertpot.com/)
* @license http://sabre.io/license/ Modified BSD License
*/
abstract class Parser
{
/**
* Turning on this option makes the parser more forgiving.
*
* In the case of the MimeDir parser, this means that the parser will
* accept slashes and underscores in property names, and it will also
* attempt to fix Microsoft vCard 2.1's broken line folding.
*/
const OPTION_FORGIVING = 1;
/**
* If this option is turned on, any lines we cannot parse will be ignored
* by the reader.
*/
const OPTION_IGNORE_INVALID_LINES = 2;
/**
* Bitmask of parser options.
*
* @var int
*/
protected $options;
/**
* Creates the parser.
*
* Optionally, it's possible to parse the input stream here.
*
* @param mixed $input
* @param int $options any parser options (OPTION constants)
*/
public function __construct($input = null, $options = 0)
{
if (!is_null($input)) {
$this->setInput($input);
}
$this->options = $options;
}
/**
* This method starts the parsing process.
*
* If the input was not supplied during construction, it's possible to pass
* it here instead.
*
* If either input or options are not supplied, the defaults will be used.
*
* @param mixed $input
* @param int $options
*
* @return array
*/
abstract public function parse($input = null, $options = 0);
/**
* Sets the input data.
*
* @param mixed $input
*/
abstract public function setInput($input);
}

377
vendor/sabre/vobject/lib/Parser/XML.php vendored Normal file
View File

@@ -0,0 +1,377 @@
<?php
namespace Sabre\VObject\Parser;
use Sabre\VObject\Component;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VCard;
use Sabre\VObject\EofException;
use Sabre\VObject\ParseException;
use Sabre\Xml as SabreXml;
/**
* XML Parser.
*
* This parser parses both the xCal and xCard formats.
*
* @copyright Copyright (C) fruux GmbH (https://fruux.com/)
* @author Ivan Enderlin
* @license http://sabre.io/license/ Modified BSD License
*/
class XML extends Parser
{
const XCAL_NAMESPACE = 'urn:ietf:params:xml:ns:icalendar-2.0';
const XCARD_NAMESPACE = 'urn:ietf:params:xml:ns:vcard-4.0';
/**
* The input data.
*
* @var array
*/
protected $input;
/**
* A pointer/reference to the input.
*
* @var array
*/
private $pointer;
/**
* Document, root component.
*
* @var \Sabre\VObject\Document
*/
protected $root;
/**
* Creates the parser.
*
* Optionally, it's possible to parse the input stream here.
*
* @param mixed $input
* @param int $options any parser options (OPTION constants)
*/
public function __construct($input = null, $options = 0)
{
if (0 === $options) {
$options = parent::OPTION_FORGIVING;
}
parent::__construct($input, $options);
}
/**
* Parse xCal or xCard.
*
* @param resource|string $input
* @param int $options
*
* @throws \Exception
*
* @return \Sabre\VObject\Document
*/
public function parse($input = null, $options = 0)
{
if (!is_null($input)) {
$this->setInput($input);
}
if (0 !== $options) {
$this->options = $options;
}
if (is_null($this->input)) {
throw new EofException('End of input stream, or no input supplied');
}
switch ($this->input['name']) {
case '{'.self::XCAL_NAMESPACE.'}icalendar':
$this->root = new VCalendar([], false);
$this->pointer = &$this->input['value'][0];
$this->parseVCalendarComponents($this->root);
break;
case '{'.self::XCARD_NAMESPACE.'}vcards':
foreach ($this->input['value'] as &$vCard) {
$this->root = new VCard(['version' => '4.0'], false);
$this->pointer = &$vCard;
$this->parseVCardComponents($this->root);
// We just parse the first <vcard /> element.
break;
}
break;
default:
throw new ParseException('Unsupported XML standard');
}
return $this->root;
}
/**
* Parse a xCalendar component.
*/
protected function parseVCalendarComponents(Component $parentComponent)
{
foreach ($this->pointer['value'] ?: [] as $children) {
switch (static::getTagName($children['name'])) {
case 'properties':
$this->pointer = &$children['value'];
$this->parseProperties($parentComponent);
break;
case 'components':
$this->pointer = &$children;
$this->parseComponent($parentComponent);
break;
}
}
}
/**
* Parse a xCard component.
*/
protected function parseVCardComponents(Component $parentComponent)
{
$this->pointer = &$this->pointer['value'];
$this->parseProperties($parentComponent);
}
/**
* Parse xCalendar and xCard properties.
*
* @param string $propertyNamePrefix
*/
protected function parseProperties(Component $parentComponent, $propertyNamePrefix = '')
{
foreach ($this->pointer ?: [] as $xmlProperty) {
list($namespace, $tagName) = SabreXml\Service::parseClarkNotation($xmlProperty['name']);
$propertyName = $tagName;
$propertyValue = [];
$propertyParameters = [];
$propertyType = 'text';
// A property which is not part of the standard.
if (self::XCAL_NAMESPACE !== $namespace
&& self::XCARD_NAMESPACE !== $namespace) {
$propertyName = 'xml';
$value = '<'.$tagName.' xmlns="'.$namespace.'"';
foreach ($xmlProperty['attributes'] as $attributeName => $attributeValue) {
$value .= ' '.$attributeName.'="'.str_replace('"', '\"', $attributeValue).'"';
}
$value .= '>'.$xmlProperty['value'].'</'.$tagName.'>';
$propertyValue = [$value];
$this->createProperty(
$parentComponent,
$propertyName,
$propertyParameters,
$propertyType,
$propertyValue
);
continue;
}
// xCard group.
if ('group' === $propertyName) {
if (!isset($xmlProperty['attributes']['name'])) {
continue;
}
$this->pointer = &$xmlProperty['value'];
$this->parseProperties(
$parentComponent,
strtoupper($xmlProperty['attributes']['name']).'.'
);
continue;
}
// Collect parameters.
foreach ($xmlProperty['value'] as $i => $xmlPropertyChild) {
if (!is_array($xmlPropertyChild)
|| 'parameters' !== static::getTagName($xmlPropertyChild['name'])) {
continue;
}
$xmlParameters = $xmlPropertyChild['value'];
foreach ($xmlParameters as $xmlParameter) {
$propertyParameterValues = [];
foreach ($xmlParameter['value'] as $xmlParameterValues) {
$propertyParameterValues[] = $xmlParameterValues['value'];
}
$propertyParameters[static::getTagName($xmlParameter['name'])]
= implode(',', $propertyParameterValues);
}
array_splice($xmlProperty['value'], $i, 1);
}
$propertyNameExtended = ($this->root instanceof VCalendar
? 'xcal'
: 'xcard').':'.$propertyName;
switch ($propertyNameExtended) {
case 'xcal:geo':
$propertyType = 'float';
$propertyValue['latitude'] = 0;
$propertyValue['longitude'] = 0;
foreach ($xmlProperty['value'] as $xmlRequestChild) {
$propertyValue[static::getTagName($xmlRequestChild['name'])]
= $xmlRequestChild['value'];
}
break;
case 'xcal:request-status':
$propertyType = 'text';
foreach ($xmlProperty['value'] as $xmlRequestChild) {
$propertyValue[static::getTagName($xmlRequestChild['name'])]
= $xmlRequestChild['value'];
}
break;
case 'xcal:freebusy':
$propertyType = 'freebusy';
// We don't break because we only want to set
// another property type.
// no break
case 'xcal:categories':
case 'xcal:resources':
case 'xcal:exdate':
foreach ($xmlProperty['value'] as $specialChild) {
$propertyValue[static::getTagName($specialChild['name'])]
= $specialChild['value'];
}
break;
case 'xcal:rdate':
$propertyType = 'date-time';
foreach ($xmlProperty['value'] as $specialChild) {
$tagName = static::getTagName($specialChild['name']);
if ('period' === $tagName) {
$propertyParameters['value'] = 'PERIOD';
$propertyValue[] = implode('/', $specialChild['value']);
} else {
$propertyValue[] = $specialChild['value'];
}
}
break;
default:
$propertyType = static::getTagName($xmlProperty['value'][0]['name']);
foreach ($xmlProperty['value'] as $value) {
$propertyValue[] = $value['value'];
}
if ('date' === $propertyType) {
$propertyParameters['value'] = 'DATE';
}
break;
}
$this->createProperty(
$parentComponent,
$propertyNamePrefix.$propertyName,
$propertyParameters,
$propertyType,
$propertyValue
);
}
}
/**
* Parse a component.
*/
protected function parseComponent(Component $parentComponent)
{
$components = $this->pointer['value'] ?: [];
foreach ($components as $component) {
$componentName = static::getTagName($component['name']);
$currentComponent = $this->root->createComponent(
$componentName,
null,
false
);
$this->pointer = &$component;
$this->parseVCalendarComponents($currentComponent);
$parentComponent->add($currentComponent);
}
}
/**
* Create a property.
*
* @param string $name
* @param array $parameters
* @param string $type
* @param mixed $value
*/
protected function createProperty(Component $parentComponent, $name, $parameters, $type, $value)
{
$property = $this->root->createProperty(
$name,
null,
$parameters,
$type
);
$parentComponent->add($property);
$property->setXmlValue($value);
}
/**
* Sets the input data.
*
* @param resource|string $input
*/
public function setInput($input)
{
if (is_resource($input)) {
$input = stream_get_contents($input);
}
if (is_string($input)) {
$reader = new SabreXml\Reader();
$reader->elementMap['{'.self::XCAL_NAMESPACE.'}period']
= XML\Element\KeyValue::class;
$reader->elementMap['{'.self::XCAL_NAMESPACE.'}recur']
= XML\Element\KeyValue::class;
$reader->xml($input);
$input = $reader->parse();
}
$this->input = $input;
}
/**
* Get tag name from a Clark notation.
*
* @param string $clarkedTagName
*
* @return string
*/
protected static function getTagName($clarkedTagName)
{
list(, $tagName) = SabreXml\Service::parseClarkNotation($clarkedTagName);
return $tagName;
}
}

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