Compare commits
5 Commits
8818eb8e1d
...
8a3610c3b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a3610c3b2 | |||
| 502be95d7d | |||
| ef3d19db9a | |||
| 58a9c4351d | |||
| fe78426a5d |
Generated
+55
-55
@@ -1688,14 +1688,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz",
|
||||
"integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz",
|
||||
"integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -1709,8 +1709,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.7",
|
||||
"vitest": "4.1.7"
|
||||
"@vitest/browser": "4.1.9",
|
||||
"vitest": "4.1.9"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -1719,16 +1719,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz",
|
||||
"integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1737,13 +1737,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||
"integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1774,9 +1774,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz",
|
||||
"integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1787,13 +1787,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz",
|
||||
"integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1801,14 +1801,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz",
|
||||
"integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1817,9 +1817,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz",
|
||||
"integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1827,13 +1827,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/ui": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.7.tgz",
|
||||
"integrity": "sha512-TP6utB2yX6rsJNVRo2qAlsi48i1YwFTrLV2tnTtWqJaYX7m4lRCCLirZBjU6xC5m0RsPHr+L2+N+eIPhgEzFfw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz",
|
||||
"integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"fflate": "^0.8.2",
|
||||
"flatted": "^3.4.2",
|
||||
"pathe": "^2.0.3",
|
||||
@@ -1845,17 +1845,17 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vitest": "4.1.7"
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz",
|
||||
"integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -6233,19 +6233,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"version": "4.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz",
|
||||
"integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"@vitest/expect": "4.1.9",
|
||||
"@vitest/mocker": "4.1.9",
|
||||
"@vitest/pretty-format": "4.1.9",
|
||||
"@vitest/runner": "4.1.9",
|
||||
"@vitest/snapshot": "4.1.9",
|
||||
"@vitest/spy": "4.1.9",
|
||||
"@vitest/utils": "4.1.9",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -6273,12 +6273,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"@vitest/browser-playwright": "4.1.9",
|
||||
"@vitest/browser-preview": "4.1.9",
|
||||
"@vitest/browser-webdriverio": "4.1.9",
|
||||
"@vitest/coverage-istanbul": "4.1.9",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Chrono\Entity;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use KTXF\Utile\Timestamp;
|
||||
|
||||
final class EventRangeMetadata {
|
||||
|
||||
public static function fromStoreDocument(array $document): array {
|
||||
$entity = $document['data'] ?? null;
|
||||
if (!is_array($entity) || !isset($entity['startsOn'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$startsOn = new DateTimeImmutable($entity['startsOn']);
|
||||
$endsOn = isset($entity['endsOn']) ? new DateTimeImmutable($entity['endsOn']) : null;
|
||||
} catch (\Exception) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rangeStart = $startsOn;
|
||||
$rangeEnd = $endsOn;
|
||||
$pattern = $entity['pattern'] ?? null;
|
||||
if (is_array($pattern)) {
|
||||
$rangeEnd = self::constructRecurringRangeEnd($startsOn, $endsOn, $pattern);
|
||||
}
|
||||
|
||||
foreach (($entity['mutations'] ?? []) as $mutation) {
|
||||
if (!is_array($mutation) || ($mutation['mutationExclusion'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (isset($mutation['startsOn'])) {
|
||||
$mutationStart = new DateTimeImmutable($mutation['startsOn']);
|
||||
if ($mutationStart < $rangeStart) {
|
||||
$rangeStart = $mutationStart;
|
||||
}
|
||||
}
|
||||
if ($rangeEnd !== null && isset($mutation['endsOn'])) {
|
||||
$mutationEnd = new DateTimeImmutable($mutation['endsOn']);
|
||||
if ($mutationEnd > $rangeEnd) {
|
||||
$rangeEnd = $mutationEnd;
|
||||
}
|
||||
}
|
||||
} catch (\Exception) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'startsOn' => Timestamp::toEpoch($rangeStart),
|
||||
'endsOn' => $rangeEnd !== null ? Timestamp::toEpoch($rangeEnd) : null,
|
||||
];
|
||||
}
|
||||
|
||||
private static function parseRangeDateTime(mixed $value): ?DateTimeImmutable {
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function parseConclusionBoundary(mixed $value, DateTimeImmutable $reference): ?DateTimeImmutable {
|
||||
if (!is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) === 1) {
|
||||
return DateTimeImmutable::createFromFormat(
|
||||
'!Y-m-d H:i:s.u',
|
||||
sprintf('%s 23:59:59.999000', $value),
|
||||
$reference->getTimezone()
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
return self::parseRangeDateTime($value);
|
||||
}
|
||||
|
||||
private static function recurringDuration(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn): ?\DateInterval {
|
||||
if ($endsOn === null || $endsOn <= $startsOn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $startsOn->diff($endsOn);
|
||||
}
|
||||
|
||||
private static function positiveIntegerList(mixed $value): array {
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($value as $entry) {
|
||||
if (!is_numeric($entry)) {
|
||||
continue;
|
||||
}
|
||||
$integer = (int)$entry;
|
||||
if ($integer > 0) {
|
||||
$list[$integer] = $integer;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($list);
|
||||
return array_values($list);
|
||||
}
|
||||
|
||||
private static function signedIntegerList(mixed $value): array {
|
||||
if (!is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$list = [];
|
||||
foreach ($value as $entry) {
|
||||
if (!is_numeric($entry)) {
|
||||
continue;
|
||||
}
|
||||
$integer = (int)$entry;
|
||||
if ($integer !== 0) {
|
||||
$list[$integer] = $integer;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($list);
|
||||
return array_values($list);
|
||||
}
|
||||
|
||||
private static function recurrenceWeekdays(array $pattern, DateTimeImmutable $startsOn): array {
|
||||
$weekdays = self::positiveIntegerList($pattern['onDayOfWeek'] ?? null);
|
||||
if ($weekdays === []) {
|
||||
$weekdays = [(int)$startsOn->format('N')];
|
||||
}
|
||||
return array_values(array_filter($weekdays, static fn(int $value): bool => $value >= 1 && $value <= 7));
|
||||
}
|
||||
|
||||
private static function recurrenceMonths(array $pattern, DateTimeImmutable $startsOn): array {
|
||||
$months = self::positiveIntegerList($pattern['onMonthOfYear'] ?? null);
|
||||
if ($months === []) {
|
||||
$months = [(int)$startsOn->format('n')];
|
||||
}
|
||||
return array_values(array_filter($months, static fn(int $value): bool => $value >= 1 && $value <= 12));
|
||||
}
|
||||
|
||||
private static function monthCandidatesAbsolute(DateTimeImmutable $periodStart, array $days, DateTimeImmutable $startsOn): array {
|
||||
$days = $days !== [] ? $days : [(int)$startsOn->format('j')];
|
||||
$lastDay = (int)$periodStart->format('t');
|
||||
$candidates = [];
|
||||
|
||||
foreach ($days as $day) {
|
||||
if ($day < 1 || $day > $lastDay) {
|
||||
continue;
|
||||
}
|
||||
$candidates[] = $periodStart->setDate(
|
||||
(int)$periodStart->format('Y'),
|
||||
(int)$periodStart->format('n'),
|
||||
$day
|
||||
);
|
||||
}
|
||||
|
||||
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private static function nthWeekdayOfMonth(DateTimeImmutable $periodStart, int $weekday, int $position): ?DateTimeImmutable {
|
||||
if ($weekday < 1 || $weekday > 7 || $position === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$year = (int)$periodStart->format('Y');
|
||||
$month = (int)$periodStart->format('n');
|
||||
$lastDay = (int)$periodStart->format('t');
|
||||
|
||||
if ($position > 0) {
|
||||
$firstDayWeekday = (int)$periodStart->format('N');
|
||||
$offset = ($weekday - $firstDayWeekday + 7) % 7;
|
||||
$day = 1 + $offset + (($position - 1) * 7);
|
||||
if ($day > $lastDay) {
|
||||
return null;
|
||||
}
|
||||
return $periodStart->setDate($year, $month, $day);
|
||||
}
|
||||
|
||||
$lastDayDate = $periodStart->setDate($year, $month, $lastDay);
|
||||
$lastWeekday = (int)$lastDayDate->format('N');
|
||||
$offset = ($lastWeekday - $weekday + 7) % 7;
|
||||
$day = $lastDay - $offset;
|
||||
$steps = abs($position) - 1;
|
||||
$day -= $steps * 7;
|
||||
if ($day < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $periodStart->setDate($year, $month, $day);
|
||||
}
|
||||
|
||||
private static function monthCandidatesRelative(DateTimeImmutable $periodStart, array $weekdays, array $positions, DateTimeImmutable $startsOn): array {
|
||||
$weekdays = $weekdays !== [] ? $weekdays : [(int)$startsOn->format('N')];
|
||||
$positions = $positions !== [] ? $positions : [self::positionInMonth($startsOn)];
|
||||
$candidates = [];
|
||||
|
||||
foreach ($positions as $position) {
|
||||
foreach ($weekdays as $weekday) {
|
||||
$candidate = self::nthWeekdayOfMonth($periodStart, $weekday, $position);
|
||||
if ($candidate !== null) {
|
||||
$candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$candidates = array_values($candidates);
|
||||
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
private static function positionInMonth(DateTimeImmutable $date): int {
|
||||
$day = (int)$date->format('j');
|
||||
$lastDay = (int)$date->format('t');
|
||||
return ($day + 7 > $lastDay) ? -1 : (int)ceil($day / 7);
|
||||
}
|
||||
|
||||
private static function recurringCandidateStarts(DateTimeImmutable $startsOn, array $pattern, ?DateTimeImmutable $concludes, ?int $iterations): array {
|
||||
$precision = is_string($pattern['precision'] ?? null) ? strtolower((string)$pattern['precision']) : '';
|
||||
$interval = max(1, (int)($pattern['interval'] ?? 1));
|
||||
$occurrences = [];
|
||||
$limit = $iterations ?? 512;
|
||||
|
||||
$append = function (DateTimeImmutable $candidate) use (&$occurrences, $startsOn, $concludes, $iterations): bool {
|
||||
if ($candidate < $startsOn) {
|
||||
return true;
|
||||
}
|
||||
if ($concludes !== null && $candidate > $concludes) {
|
||||
return false;
|
||||
}
|
||||
$occurrences[] = $candidate;
|
||||
return $iterations === null || count($occurrences) < $iterations;
|
||||
};
|
||||
|
||||
switch ($precision) {
|
||||
case 'secondly':
|
||||
case 'minutely':
|
||||
case 'hourly':
|
||||
case 'daily':
|
||||
$step = 0;
|
||||
$weekdays = $precision === 'daily' && !empty($pattern['onDayOfWeek'])
|
||||
? self::recurrenceWeekdays($pattern, $startsOn)
|
||||
: [];
|
||||
while (count($occurrences) < $limit) {
|
||||
$candidate = match ($precision) {
|
||||
'secondly' => $startsOn->modify(sprintf('+%d seconds', $step * $interval)),
|
||||
'minutely' => $startsOn->modify(sprintf('+%d minutes', $step * $interval)),
|
||||
'hourly' => $startsOn->modify(sprintf('+%d hours', $step * $interval)),
|
||||
default => $startsOn->modify(sprintf('+%d days', $step * $interval)),
|
||||
};
|
||||
if ($candidate === false) {
|
||||
break;
|
||||
}
|
||||
if ($precision === 'daily' && $weekdays !== [] && !in_array((int)$candidate->format('N'), $weekdays, true)) {
|
||||
$step++;
|
||||
if ($concludes !== null && $candidate > $concludes) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!$append($candidate)) {
|
||||
break;
|
||||
}
|
||||
$step++;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'weekly':
|
||||
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
||||
$weekStart = $startsOn->modify(sprintf('-%d days', ((int)$startsOn->format('N')) - 1));
|
||||
if ($weekStart === false) {
|
||||
break;
|
||||
}
|
||||
for ($week = 0; count($occurrences) < $limit; $week++) {
|
||||
$periodStart = $weekStart->modify(sprintf('+%d days', $week * $interval * 7));
|
||||
if ($periodStart === false) {
|
||||
break;
|
||||
}
|
||||
foreach ($weekdays as $weekday) {
|
||||
$candidate = $periodStart->modify(sprintf('+%d days', $weekday - 1));
|
||||
if ($candidate === false) {
|
||||
continue;
|
||||
}
|
||||
if (!$append($candidate)) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
if ($concludes !== null && $periodStart > $concludes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'monthly':
|
||||
$isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative';
|
||||
$days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null);
|
||||
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
||||
$positions = self::signedIntegerList($pattern['onPosition'] ?? null);
|
||||
$monthAnchor = $startsOn->setDate((int)$startsOn->format('Y'), (int)$startsOn->format('n'), 1);
|
||||
for ($month = 0; count($occurrences) < $limit; $month++) {
|
||||
$periodStart = $monthAnchor->modify(sprintf('+%d months', $month * $interval));
|
||||
if ($periodStart === false) {
|
||||
break;
|
||||
}
|
||||
$candidates = $isRelative
|
||||
? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn)
|
||||
: self::monthCandidatesAbsolute($periodStart, $days, $startsOn);
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!$append($candidate)) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
if ($concludes !== null && $periodStart > $concludes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'yearly':
|
||||
$isRelative = strtolower((string)($pattern['pattern'] ?? 'absolute')) === 'relative';
|
||||
$months = self::recurrenceMonths($pattern, $startsOn);
|
||||
$days = self::positiveIntegerList($pattern['onDayOfMonth'] ?? null);
|
||||
$weekdays = self::recurrenceWeekdays($pattern, $startsOn);
|
||||
$positions = self::signedIntegerList($pattern['onPosition'] ?? null);
|
||||
$yearAnchor = $startsOn->setDate((int)$startsOn->format('Y'), 1, 1);
|
||||
for ($year = 0; count($occurrences) < $limit; $year++) {
|
||||
$periodYear = $yearAnchor->modify(sprintf('+%d years', $year * $interval));
|
||||
if ($periodYear === false) {
|
||||
break;
|
||||
}
|
||||
$candidates = [];
|
||||
foreach ($months as $month) {
|
||||
$periodStart = $periodYear->setDate((int)$periodYear->format('Y'), $month, 1);
|
||||
foreach (
|
||||
$isRelative
|
||||
? self::monthCandidatesRelative($periodStart, $weekdays, $positions, $startsOn)
|
||||
: self::monthCandidatesAbsolute($periodStart, $days, $startsOn)
|
||||
as $candidate
|
||||
) {
|
||||
$candidates[$candidate->format(DateTimeInterface::ATOM)] = $candidate;
|
||||
}
|
||||
}
|
||||
$candidates = array_values($candidates);
|
||||
usort($candidates, static fn(DateTimeImmutable $left, DateTimeImmutable $right): int => $left <=> $right);
|
||||
foreach ($candidates as $candidate) {
|
||||
if (!$append($candidate)) {
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
if ($concludes !== null && $periodYear > $concludes) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return $occurrences;
|
||||
}
|
||||
|
||||
private static function constructRecurringRangeEnd(DateTimeImmutable $startsOn, ?DateTimeImmutable $endsOn, array $pattern): ?DateTimeImmutable {
|
||||
$iterations = isset($pattern['iterations']) && is_numeric($pattern['iterations']) && (int)$pattern['iterations'] > 0
|
||||
? (int)$pattern['iterations']
|
||||
: null;
|
||||
$concludes = self::parseConclusionBoundary($pattern['concludes'] ?? null, $startsOn);
|
||||
|
||||
if ($iterations === null && $concludes === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$occurrences = self::recurringCandidateStarts($startsOn, $pattern, $concludes, $iterations);
|
||||
$lastStart = $occurrences !== [] ? end($occurrences) : $startsOn;
|
||||
if (!$lastStart instanceof DateTimeImmutable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$duration = self::recurringDuration($startsOn, $endsOn);
|
||||
return $duration !== null ? $lastStart->add($duration) : $lastStart;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use KTXF\Resource\Identifier\CollectionIdentifierInterface;
|
||||
use KTXF\Resource\Identifier\EntityIdentifierInterface;
|
||||
use KTXF\Resource\Identifier\ResourceIdentifier;
|
||||
use KTXF\Resource\Identifier\ServiceIdentifier;
|
||||
use KTXF\Utile\Timestamp;
|
||||
|
||||
/**
|
||||
* Abstract Node Base Class
|
||||
@@ -119,18 +120,14 @@ abstract class NodeBaseAbstract implements NodeBaseInterface {
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function created(): DateTimeImmutable|null {
|
||||
return isset($this->data[static::PROPERTY_CREATED])
|
||||
? new DateTimeImmutable($this->data[static::PROPERTY_CREATED])
|
||||
: null;
|
||||
return Timestamp::toDateTime($this->data[static::PROPERTY_CREATED] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function modified(): DateTimeImmutable|null {
|
||||
return isset($this->data[static::PROPERTY_MODIFIED])
|
||||
? new DateTimeImmutable($this->data[static::PROPERTY_MODIFIED])
|
||||
: null;
|
||||
return Timestamp::toDateTime($this->data[static::PROPERTY_MODIFIED] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Resource\Provider\Node;
|
||||
|
||||
use KTXF\Utile\Timestamp;
|
||||
|
||||
/**
|
||||
* Abstract Node Mutable Class
|
||||
*
|
||||
@@ -56,19 +58,21 @@ abstract class NodeMutableAbstract extends NodeBaseAbstract implements NodeMutab
|
||||
}
|
||||
|
||||
if (isset($data[static::PROPERTY_CREATED])) {
|
||||
if (!is_string($data[static::PROPERTY_CREATED])) {
|
||||
throw new \InvalidArgumentException("Created date must be a string in ISO 8601 format");
|
||||
$created = Timestamp::normalize($data[static::PROPERTY_CREATED]);
|
||||
if ($created === null) {
|
||||
throw new \InvalidArgumentException("Created date must be a valid epoch millisecond or ISO 8601 date");
|
||||
}
|
||||
$this->data[static::PROPERTY_CREATED] = $data[static::PROPERTY_CREATED];
|
||||
$this->data[static::PROPERTY_CREATED] = $created;
|
||||
} else {
|
||||
$this->data[static::PROPERTY_CREATED] = null;
|
||||
}
|
||||
|
||||
if (isset($data[static::PROPERTY_MODIFIED])) {
|
||||
if (!is_string($data[static::PROPERTY_MODIFIED])) {
|
||||
throw new \InvalidArgumentException("Modified date must be a string in ISO 8601 format");
|
||||
$modified = Timestamp::normalize($data[static::PROPERTY_MODIFIED]);
|
||||
if ($modified === null) {
|
||||
throw new \InvalidArgumentException("Modified date must be a valid epoch millisecond or ISO 8601 date");
|
||||
}
|
||||
$this->data[static::PROPERTY_MODIFIED] = $data[static::PROPERTY_MODIFIED];
|
||||
$this->data[static::PROPERTY_MODIFIED] = $modified;
|
||||
} else {
|
||||
$this->data[static::PROPERTY_MODIFIED] = null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Resource\Range;
|
||||
|
||||
interface IRange {
|
||||
use KTXF\Json\JsonDeserializable;
|
||||
|
||||
interface IRange extends JsonDeserializable {
|
||||
|
||||
/**
|
||||
* Gets the type of this range
|
||||
|
||||
@@ -9,32 +9,30 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Resource\Range;
|
||||
|
||||
use DateTimeInterface;
|
||||
|
||||
interface IRangeDate extends IRange {
|
||||
|
||||
/**
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getStart(): DateTimeInterface;
|
||||
public function getStart(): int;
|
||||
|
||||
/**
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function setStart(DateTimeInterface $value): void;
|
||||
public function setStart(int $value): void;
|
||||
|
||||
/**
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getEnd(): DateTimeInterface;
|
||||
public function getEnd(): int;
|
||||
|
||||
/**
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function setEnd(DateTimeInterface $value): void;
|
||||
public function setEnd(int $value): void;
|
||||
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace KTXF\Resource\Range;
|
||||
|
||||
class Range implements IRange {
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the range
|
||||
*
|
||||
|
||||
@@ -9,13 +9,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace KTXF\Resource\Range;
|
||||
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use KTXF\Utile\Timestamp;
|
||||
|
||||
class RangeDate extends Range implements IRangeDate {
|
||||
|
||||
protected DateTimeInterface $start;
|
||||
protected DateTimeInterface $end;
|
||||
protected int $start;
|
||||
protected int $end;
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (isset($data['start'])) {
|
||||
$start = Timestamp::normalize($data['start']);
|
||||
if ($start !== null) {
|
||||
$this->setStart($start);
|
||||
}
|
||||
}
|
||||
if (isset($data['end'])) {
|
||||
$end = Timestamp::normalize($data['end']);
|
||||
if ($end !== null) {
|
||||
$this->setEnd($end);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the range
|
||||
@@ -31,7 +51,7 @@ class RangeDate extends Range implements IRangeDate {
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getStart(): DateTimeInterface {
|
||||
public function getStart(): int {
|
||||
return $this->start;
|
||||
}
|
||||
|
||||
@@ -40,7 +60,7 @@ class RangeDate extends Range implements IRangeDate {
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function setStart(DateTimeInterface $value): void {
|
||||
public function setStart(int $value): void {
|
||||
$this->start = $value;
|
||||
}
|
||||
|
||||
@@ -49,7 +69,7 @@ class RangeDate extends Range implements IRangeDate {
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function getEnd(): DateTimeInterface {
|
||||
public function getEnd(): int {
|
||||
return $this->end;
|
||||
}
|
||||
|
||||
@@ -58,7 +78,7 @@ class RangeDate extends Range implements IRangeDate {
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public function setEnd(DateTimeInterface $value): void {
|
||||
public function setEnd(int $value): void {
|
||||
$this->end = $value;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,24 @@ class RangeTally extends Range implements IRangeTally {
|
||||
protected string|int $position = 0;
|
||||
protected int $tally = 32;
|
||||
|
||||
public function jsonDeserialize(array|string $data): static {
|
||||
if (is_string($data)) {
|
||||
$data = json_decode($data, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
if (isset($data['anchor'])) {
|
||||
$this->setAnchor(RangeAnchorType::from($data['anchor']));
|
||||
}
|
||||
if (isset($data['position'])) {
|
||||
$this->setPosition($data['position']);
|
||||
}
|
||||
if (isset($data['tally'])) {
|
||||
$this->setTally($data['tally']);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the range
|
||||
*
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
namespace KTXF\Utile;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
|
||||
final class Timestamp {
|
||||
|
||||
public static function now(): int {
|
||||
return self::toEpoch(new DateTimeImmutable('now'));
|
||||
}
|
||||
|
||||
public static function normalize(mixed $value): ?int {
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return self::toEpoch($value);
|
||||
}
|
||||
|
||||
if (is_int($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (is_float($value)) {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
if (is_numeric($value)) {
|
||||
return (int)$value;
|
||||
}
|
||||
|
||||
try {
|
||||
return self::toEpoch(new DateTimeImmutable($value));
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function toEpoch(DateTimeInterface $moment): int {
|
||||
$utcMoment = DateTimeImmutable::createFromInterface($moment)
|
||||
->setTimezone(new DateTimeZone('UTC'));
|
||||
return ((int)$utcMoment->format('U') * 1000) + (int)$utcMoment->format('v');
|
||||
}
|
||||
|
||||
public static function toDateTime(mixed $value): ?DateTimeImmutable {
|
||||
if ($value instanceof DateTimeImmutable) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return DateTimeImmutable::createFromInterface($value);
|
||||
}
|
||||
|
||||
if (is_int($value) || is_float($value) || (is_string($value) && is_numeric($value))) {
|
||||
$milliseconds = (int)$value;
|
||||
$seconds = intdiv($milliseconds, 1000);
|
||||
$microseconds = ($milliseconds % 1000) * 1000;
|
||||
return DateTimeImmutable::createFromFormat(
|
||||
'U u',
|
||||
sprintf('%d %06d', $seconds, $microseconds),
|
||||
new DateTimeZone('UTC')
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
try {
|
||||
return new DateTimeImmutable($value);
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function toIso8601(int $value): ?string {
|
||||
$moment = self::toDateTime($value);
|
||||
return $moment?->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user