diff options
Diffstat (limited to 'includes')
-rw-r--r-- | includes/backup.php | 81 | ||||
-rw-r--r-- | includes/banner.php | 24 | ||||
-rw-r--r-- | includes/bitset.php | 4 | ||||
-rw-r--r-- | includes/emergency.php | 2 | ||||
-rw-r--r-- | includes/footer.php | 7 | ||||
-rw-r--r-- | includes/functions.php | 100 | ||||
-rw-r--r-- | includes/header.php | 73 | ||||
-rw-r--r-- | includes/ical/LICENSE | 24 | ||||
-rw-r--r-- | includes/ical/bin/timezones.php | 20 | ||||
-rw-r--r-- | includes/ical/main.php | 7 | ||||
-rw-r--r-- | includes/ical/src/EventsList.php | 54 | ||||
-rw-r--r-- | includes/ical/src/Freq.php | 633 | ||||
-rw-r--r-- | includes/ical/src/IcalParser.php | 466 | ||||
-rw-r--r-- | includes/ical/src/Recurrence.php | 234 | ||||
-rw-r--r-- | includes/ical/src/WindowsTimezones.php | 214 | ||||
-rw-r--r-- | includes/keywords.php | 100 | ||||
-rw-r--r-- | includes/member.php | 2 | ||||
-rw-r--r-- | includes/planner.php | 11 | ||||
-rw-r--r-- | includes/pleasure.php | 2 | ||||
-rw-r--r-- | includes/random.php | 15 | ||||
-rw-r--r-- | includes/refresh.php | 16 | ||||
-rw-r--r-- | includes/restore.php | 130 | ||||
-rw-r--r-- | includes/session.php | 6 | ||||
-rw-r--r-- | includes/sysbanner.php | 2 | ||||
-rw-r--r-- | includes/system/compare.php | 38 |
25 files changed, 2224 insertions, 41 deletions
diff --git a/includes/backup.php b/includes/backup.php new file mode 100644 index 0000000..18ed6a4 --- /dev/null +++ b/includes/backup.php @@ -0,0 +1,81 @@ +<?php + +$root = array_filter(scandir("data"), function ($i) { + return !str_starts_with($i, "."); +}); +$files = []; +$data = [ + "date" => date('c'), + "files" => [] +]; + +foreach ($root as $file) { + if ($file === "backup.poniesbackup" || $file === "backup.ponieskey" || $file === "encrypted" || str_ends_with($file, ".poniesbackup")) continue; + + if (is_dir("data/$file")) { + foreach (array_filter(scandir("data/$file"), function ($i) { + return !str_starts_with($i, "."); + }) as $dirfile) { + if ($dirfile === "backup.poniesbackup" || $dirfile === "backup.ponieskey" || $dirfile === "encrypted" || str_ends_with($dirfile, ".poniesbackup")) continue; + + $files[] = [ + "dir" => $file, + "file" => $dirfile + ]; + } + } else { + $files[] = [ + "dir" => "", + "file" => $file + ]; + } +} + +foreach ($files as $file) { + $file["mime"] = mime_content_type("data/$file[dir]/$file[file]"); + $file["checksum"] = [ + sha1_file("data/$file[dir]/$file[file]"), + md5_file("data/$file[dir]/$file[file]") + ]; + $file["content"] = base64_encode(file_get_contents("data/$file[dir]/$file[file]")); + + $data["files"][] = $file; +} + +function pkcs7_pad($data, $size) { + $length = $size - strlen($data) % $size; + return $data . str_repeat(chr($length), $length); +} + +if (!file_exists("./data/backup.ponieskey")) { + $key = openssl_random_pseudo_bytes(512); + $iv = openssl_random_pseudo_bytes(16); + file_put_contents("./data/backup.ponieskey", base64_encode(json_encode([ + "iv" => bin2hex($iv), + "key" => bin2hex($key) + ]))); +} else { + $key_raw = json_decode(base64_decode(file_get_contents("./data/backup.ponieskey")), true); + $key = hex2bin($key_raw["key"]); + $iv = hex2bin($key_raw["iv"]); +} + +$payload = json_encode($data); +$encrypted = openssl_encrypt(pkcs7_pad($payload, 16), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + +file_put_contents("./data/backup.poniesbackup", $encrypted); +@mkdir("./data/encrypted"); + +$id = str_replace(":", "-", date('c')); +copy("./data/backup.poniesbackup", "./data/encrypted/" . $id . ".poniesbackup"); + +exec("scp ./data/encrypted/" . $id . ".poniesbackup fedora@bridlewood.equestria.dev:/opt/ponies"); +exec('ssh fedora@bridlewood.equestria.dev bash -c "cd /opt/ponies; ls -tp | grep -v \'/$\' | tail -n +20 | xargs -I {} rm -- {}"'); + +exec("scp ./data/encrypted/" . $id . ".poniesbackup root@canterlot.equestria.dev:/opt/ponies"); +exec('ssh root@canterlot.equestria.dev bash -c "cd /opt/ponies; ls -tp | grep -v \'/$\' | tail -n +20 | xargs -I {} rm -- {}"'); + +copy("./data/encrypted/" . $id . ".poniesbackup", "/opt/ponies/" . $id . ".poniesbackup"); +exec('bash -c "cd /opt/ponies; ls -tp | grep -v \'/$\' | tail -n +20 | xargs -I {} rm -- {}"'); + +unlink("./data/encrypted/" . $id . ".poniesbackup");
\ No newline at end of file diff --git a/includes/banner.php b/includes/banner.php index 6305857..2582b63 100644 --- a/includes/banner.php +++ b/includes/banner.php @@ -322,14 +322,14 @@ function getMemberBannerData(string $id, string $system, bool $french = false) { if ($metadata["host"] ?? false) { if (!$travelling[$member['id']]["travelling"]) { $badges[] = [ - "id" => "host", + "id" => "mcf", "color" => "primary", "html" => ( $french ? - '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Hôte</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' the one who fronts the most often in ' . getMemberPronouns($member['pronouns'])["possessive_det"] . ' system." class="badge rounded-pill bg-primary">Hôte</span>' + '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Fronteuse la plus présente</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' the one who fronts the most often in ' . getMemberPronouns($member['pronouns'])["possessive_det"] . ' system." class="badge rounded-pill bg-primary">Fronteuse la plus présente</span>' : - '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Host</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' the one who fronts the most often in ' . getMemberPronouns($member['pronouns'])["possessive_det"] . ' system." class="badge rounded-pill bg-primary">Host</span>' + '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Most common fronter</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' the one who fronts the most often in ' . getMemberPronouns($member['pronouns'])["possessive_det"] . ' system." class="badge rounded-pill bg-primary">Most common fronter</span>' ) ]; } @@ -338,7 +338,7 @@ function getMemberBannerData(string $id, string $system, bool $french = false) { if (($metadata["age_spells"] ?? false) && !$french) { $badges[] = [ "id" => "age_spells", - "color" => "primary", + "color" => "#6f42c1", "html" => '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Affected by age spells</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' can feel younger than ' . getMemberPronouns($member['pronouns'])["subjective"] . ' actually ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' when somepony else casts an age spell on ' . getMemberPronouns($member['pronouns'])["object"] . '." class="badge rounded-pill" style="background-color: #6f42c1;">Affected by age spells</span>' ]; } @@ -357,6 +357,22 @@ function getMemberBannerData(string $id, string $system, bool $french = false) { ]; } + if ($metadata["less_frequent"] ?? false) { + $badges[] = [ + "id" => "nonverbal", + "color" => "#fd7e14", + "html" => '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Fronts less often</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "front" : "fronts") . ' less often than ' . getMemberPronouns($member['pronouns'])["possessive_det"] . ' headmates, this can due to various reasons." class="badge rounded-pill" style="background-color:#fd7e14;">Fronts less often</span>' + ]; + } + + if ($metadata["nonverbal"] ?? false) { + $badges[] = [ + "id" => "nonverbal", + "color" => "#20c997", + "html" => '<span data-bs-toggle="tooltip" data-bs-html="true" title="<b>Non verbal IRL</b><br>' . ucfirst(getMemberPronouns($member['pronouns'])["subjective"]) . ' ' . (getMemberPronouns($member['pronouns'])["third"] ? "is" : "are") . ' non verbal in real life, although text communication is still possible." class="badge rounded-pill" style="background-color:#20c997;">Non verbal IRL</span>' + ]; + } + if ($member["name"] === "fusion") { $badges[] = [ "id" => "fusion", diff --git a/includes/bitset.php b/includes/bitset.php index 6483669..2e5a645 100644 --- a/includes/bitset.php +++ b/includes/bitset.php @@ -7,6 +7,8 @@ function parseBitset ($bitset) { $median = substr($bin, 10 + 16, 1) !== "0"; $little = bindec(substr($bin, 11 + 16, 2)); $food = bindec(substr($bin, 16, 2)); + $nonverbal = substr($bin, 15, 1) !== "0"; + $lessFrequent = substr($bin, 14, 1) !== "0"; $magic = bindec(substr($bin, 2 + 16, 3)); $sensitivity = bindec(substr($bin, 5 + 16, 3)); $protector = substr($bin, 13 + 16, 1) !== "0"; @@ -63,6 +65,8 @@ function parseBitset ($bitset) { 'sensitivity' => $sensitivity, 'food' => $food, 'plush' => $plush, + 'nonverbal' => $nonverbal, + 'less_frequent' => $lessFrequent, 'age_spells' => $age, 'species' => array_filter([ $species1, diff --git a/includes/emergency.php b/includes/emergency.php index a86e090..5490985 100644 --- a/includes/emergency.php +++ b/includes/emergency.php @@ -1,4 +1,4 @@ -<h2>Emergency Alert +<h2>Emergency alert <details style="display: inline-block;font-size:12px;"> <summary class="text-muted" style="opacity:.5;"></summary> <label><input id="test-mode" type="checkbox"> Test Mode</label> · <label><input id="fake-requests" type="checkbox"> Fake Requests</label> diff --git a/includes/footer.php b/includes/footer.php index 4bf7877..4592b8e 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -35,8 +35,13 @@ if (!function_exists("timeAgo")) { <hr> <div class="container text-muted"> + <?php + + $refresh = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/refresh.json"), true); + + ?> © <?= date("Y") ?> <a href="https://equestria.horse" target="_blank" class="text-muted">Equestria.dev Developers</a> · <a href="https://git.equestria.dev/equestria.dev/ponies.equestria.horse" target="_blank" class="text-muted">Source Code</a><br> - PluralKit data updated <?= trim(timeAgo(json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/refresh.json"), true)["timestamp"])) ?>, next update in <?php $t = 5 - round((time() - json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/refresh.json"), true)["timestamp"]) / 60); ?><?= $t > 1 ? $t . " minutes" : ($t > 0 ? "a minute" : "a few seconds") ?> + <a href="/-/debug" class="text-muted" style="text-decoration: none;">Data updated <?= trim(timeAgo($refresh["timestamp"])) ?> (<?= date('D j M, G:i:s T', $refresh["timestamp"]) ?>; took <?= round($refresh["duration"] * 1000) ?> ms, <?= count($refresh["restored"]) > 0 ? (count($refresh["restored"]) > 1 ? "reported " . count($refresh["restored"]) . " failures" : "reported 1 failure") : "no failures reported" ?>)</a> <br><br><br> </div> diff --git a/includes/functions.php b/includes/functions.php index 51317ca..db4a8df 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -34,6 +34,26 @@ if (!function_exists("getSystemMember")) { } } +if (!function_exists("getMemberWithoutSystem")) { + function getMemberWithoutSystem(string $id) { + $member = null; + + $members1 = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/ynmuc-members.json"), true); + foreach ($members1 as $m) { + $m["_system"] = "ynmuc"; + if ($m["id"] === $id) $member = $m; + } + + $members2 = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/gdapd-members.json"), true); + foreach ($members2 as $m) { + $m["_system"] = "gdapd"; + if ($m["id"] === $id) $member = $m; + } + + return $member; + } +} + if (!function_exists("showMembersFromList")) { function showMembersFromList(array $list) { foreach ($list as $member) { if ($member['name'] !== "unknown" && $member['name'] !== "fusion") { @@ -167,6 +187,86 @@ if (!function_exists("timeAgo")) { } } +if (!function_exists("timeIn")) { + function timeIn($time): string { + if (!is_numeric($time)) { + $time = strtotime($time); + } + + $periods = ["second", "minute", "hour", "day", "week", "month", "year", "age"]; + $lengths = array("60", "60", "24", "7", "4.35", "12", "100"); + + $now = time(); + + $difference = $time - $now; + if ($difference <= 10 && $difference >= 0) { + return $tense = "now"; + } elseif ($difference > 0) { + $tense = "in"; + } else { + $tense = "ago"; + } + + for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths)-1; $j++) { + $difference /= $lengths[$j]; + } + + $difference = round($difference); + + $period = $periods[$j] . ($difference >1 ? "s" :''); + return "{$tense} {$difference} {$period}"; + } +} + +if (!function_exists("duration")) { + function duration($seconds) { + if ($seconds >= 60) { + if (floor($seconds / 60) >= 60) { + if (floor($seconds / 3600) >= 24) { + $days = floor($seconds / 86400); + return $days . " day" . ($days > 1 ? "s" : ""); + } else { + $hours = floor($seconds / 3600); + return $hours . " hour" . ($hours > 1 ? "s" : ""); + } + } else { + $minutes = floor($seconds / 60); + return $minutes . " minute" . ($minutes > 1 ? "s" : ""); + } + } else { + return $seconds . " seconds"; + } + } +} + +if (!function_exists("relativeDate")) { + function relativeDate($date, $showTime = true) { + if (!is_numeric($date)) $date = strtotime($date); + + if (!$showTime) { + if (date('Y-m-d', $date) === date('Y-m-d')) { + return "today"; + } elseif (date('Y-m-d', $date) === date('Y-m-d', time() + 86400)) { + return "tomorrow"; + } elseif ($date < time() + 518400) { + return date('l', $date); + } else { + return date('D j M', $date); + } + } else { + if (date('Y-m-d', $date) === date('Y-m-d')) { + return "today, <span class='time-adjust'>" . date('H:i', $date) . "</span>"; + } elseif (date('Y-m-d', $date) === date('Y-m-d', time() + 86400)) { + return "tomorrow, <span class='time-adjust'>" . date('H:i', $date) . "</span>"; + } elseif ($date < time() + 518400) { + return date('l', $date) . ", <span class='time-adjust'>" . date('H:i', $date) . "</span>"; + } else { + return date('D j M', $date) . ", <span class='time-adjust'>" . date('H:i', $date) . "</span>"; + } + } + } +} + if (!function_exists("getMemberSystem")) { function getMemberSystem(string $id) { $list = scoreOrderGlobal(); diff --git a/includes/header.php b/includes/header.php index 8ce8389..1f71aa5 100644 --- a/includes/header.php +++ b/includes/header.php @@ -29,6 +29,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/session.php"; global $isLogg require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/banner.php"; require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/rainbow.php"; require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/main.php"; ?> <!doctype html> @@ -230,12 +231,33 @@ require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; grid-template-columns: 1fr !important; text-align: left; } + + #member-icon-mobile { + display: inline-block !important; + } + + #system-info { + grid-template-columns: 1fr !important; + } + + #member-icon, #member-icon-outer { + display: none !important; + } + + #member-relations { + grid-template-columns: 1fr !important; + text-align: left; + } } #page-content a { color: #afd0ff; } + #page-content .btn-outline-light:hover { + color: black !important; + } + #page-content a:hover { opacity: .75; } @@ -616,7 +638,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; } .navbar-collapse.show { - z-index: 999; + z-index: 99999; } @media (max-width: 991px) { @@ -637,6 +659,25 @@ require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; text-align: left; } } + + .linked-card { + opacity: 1 !important; + color: white !important; + text-decoration: none !important; + } + + .linked-card:hover { + opacity: .75 !important; + } + + .linked-card:active { + opacity: .5 !important; + } + + .navbar-brand { + position: relative; + z-index: 9999; + } </style> </head> <body> @@ -712,28 +753,48 @@ require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; <li><hr class="dropdown-divider"></li> <li><h5 class="dropdown-header">Applications</h5></li> + <li><a class="dropdown-item" href="/-/dashboard"> + <img src="/assets/icons/dashboard.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> + <span style="vertical-align: middle;">Dashboard</span> + </a></li> <li><a class="dropdown-item" href="/-/fronting"> <img src="/assets/icons/fronting.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> <span style="vertical-align: middle;">Front planner</span> </a></li> + <!--<li><a class="dropdown-item" href="/-/actions"> + <img src="/assets/icons/actions.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> + <span style="vertical-align: middle;">Actions database</span> + </a></li> + <li><a class="dropdown-item" href="/-/rules"> + <img src="/assets/icons/rules.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> + <span style="vertical-align: middle;">Systems rules</span> + </a></li> + <li><a class="dropdown-item" href="/-/nicknames"> + <img src="/assets/icons/nicknames.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> + <span style="vertical-align: middle;">Relationships nicknames</span> + </a></li> <li><a class="dropdown-item" href="/-/together"> <img src="/assets/icons/together.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> <span style="vertical-align: middle;">Watch Together</span> - </a></li> + </a></li>--> <li><a class="dropdown-item" href="/-/travelling"> <img src="/assets/icons/travel.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> <span style="vertical-align: middle;">System travels manager</span> </a></li> - <li><hr class="dropdown-divider"></li> - - <li><h5 class="dropdown-header">Utilities</h5></li> - <li><a class="dropdown-item" href="/-/splitting"> + <!--<li><a class="dropdown-item" href="/-/splitting"> <img src="/assets/icons/form.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> <span style="vertical-align: middle;">Members by splitting date</span> </a></li> <li><a class="dropdown-item" href="/-/bitset"> <img src="/assets/icons/bitset.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> <span style="vertical-align: middle;">Bitset calculator</span> + </a></li>--> + <li><hr class="dropdown-divider"></li> + + <li><h5 class="dropdown-header">Debugging</h5></li> + <li><a class="dropdown-item" href="/-/debug"> + <img src="/assets/icons/debug.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> + <span style="vertical-align: middle;">Data updater debugging</span> </a></li> <li><a class="dropdown-item" href="/-/score"> <img src="/assets/icons/score.svg" class="dropdown-icon" alt="" style="width:24px;vertical-align: middle;"> diff --git a/includes/ical/LICENSE b/includes/ical/LICENSE new file mode 100644 index 0000000..ce7d41d --- /dev/null +++ b/includes/ical/LICENSE @@ -0,0 +1,24 @@ +Copyright (c) 2014-2022, Roman Ožana +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 of the <organization> 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 <COPYRIGHT HOLDER> 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. diff --git a/includes/ical/bin/timezones.php b/includes/ical/bin/timezones.php new file mode 100644 index 0000000..3bf3708 --- /dev/null +++ b/includes/ical/bin/timezones.php @@ -0,0 +1,20 @@ +<?php + +/** + * This file generates a map from windows timezones to tz database timezones + * + * @author Clement Wong <cw@clement.hk> + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ +$windows_timezones = []; +$windowstimezonexml = new DOMDocument(); +$windowstimezonexml->load('https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml'); +$zones = $windowstimezonexml->getElementsByTagName('mapZone'); +foreach ($zones as $zone) { + if ($zone->getAttribute('territory') === '001') { + $windows_timezones[$zone->getAttribute('other')] = $zone->getAttribute('type'); + } +} + +file_put_contents(__DIR__ . '/../src/WindowsTimezones.php', "<?php\n\$windows_timezones = " . var_export($windows_timezones, true) . ';'); + diff --git a/includes/ical/main.php b/includes/ical/main.php new file mode 100644 index 0000000..56d00af --- /dev/null +++ b/includes/ical/main.php @@ -0,0 +1,7 @@ +<?php + +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/src/EventsList.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/src/Freq.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/src/IcalParser.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/src/Recurrence.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/ical/src/WindowsTimezones.php";
\ No newline at end of file diff --git a/includes/ical/src/EventsList.php b/includes/ical/src/EventsList.php new file mode 100644 index 0000000..3325368 --- /dev/null +++ b/includes/ical/src/EventsList.php @@ -0,0 +1,54 @@ +<?php + +namespace om; + +/** + * Copyright (c) 2004-2022 Roman Ožana (https://ozana.cz) + * + * @license BSD-3-Clause + * @author Roman Ožana <roman@ozana.cz> + */ +class EventsList extends \ArrayObject { + + /** + * Return array of Events + * + * @return array + */ + public function getArrayCopy(): array { + return array_values(parent::getArrayCopy()); + } + + /** + * Return sorted EventList (the newest dates are first) + * + * @return $this + */ + public function sorted(): EventsList { + $this->uasort(static function ($a, $b): int { + if ($a['DTSTART'] === $b['DTSTART']) { + return 0; + } + return ($a['DTSTART'] < $b['DTSTART']) ? -1 : 1; + }); + + return $this; + } + + /** + * Return reversed sorted EventList (the oldest dates are first) + * + * @return $this + */ + public function reversed(): EventsList { + $this->uasort(static function ($a, $b): int { + if ($a['DTSTART'] === $b['DTSTART']) { + return 0; + } + return ($a['DTSTART'] > $b['DTSTART']) ? -1 : 1; + }); + + return $this; + } + +}
\ No newline at end of file diff --git a/includes/ical/src/Freq.php b/includes/ical/src/Freq.php new file mode 100644 index 0000000..8557683 --- /dev/null +++ b/includes/ical/src/Freq.php @@ -0,0 +1,633 @@ +<?php + +namespace om; + +use DateTime; +use DateTimeZone; +use Exception; + +/** + * Class taken from https://github.com/coopTilleuls/intouch-iCalendar.git (Freq.php) + * + * @author PC Drew <pc@schoolblocks.com> + */ + +/** + * A class to store Frequency-rules in. Will allow a easy way to find the + * last and next occurrence of the rule. + * + * No - this is so not pretty. But.. ehh.. You do it better, and I will + * gladly accept patches. + * + * Created by trail-and-error on the examples given in the RFC. + * + * TODO: Update to a better way of doing calculating the different options. + * Instead of only keeping track of the best of the current dates found + * it should instead keep a array of all the calculated dates within the + * period. + * This should fix the issues with multi-rule + multi-rule interference, + * and make it possible to implement the SETPOS rule. + * By pushing the next period onto the stack as the last option will + * (hopefully) remove the need for the awful simpleMode + * + * @author Morten Fangel (C) 2008 + * @author Michael Kahn (C) 2013 + * @license http://creativecommons.org/licenses/by-sa/2.5/dk/deed.en_GB CC-BY-SA-DK + */ +class Freq { + + protected array $weekdays = [ + 'MO' => 'monday', + 'TU' => 'tuesday', + 'WE' => 'wednesday', + 'TH' => 'thursday', + 'FR' => 'friday', + 'SA' => 'saturday', + 'SU' => 'sunday', + ]; + protected array $knownRules = [ + 'month', + 'weekno', + 'day', + 'monthday', + 'yearday', + 'hour', + 'minute', + ]; //others : 'setpos', 'second' + + protected array $ruleModifiers = ['wkst']; + protected bool $simpleMode = true; + + protected array $rules = ['freq' => 'yearly', 'interval' => 1]; + protected int $start = 0; + protected string $freq = ''; + + protected array $excluded; //EXDATE + protected array $added; //RDATE + + protected $cache; // getAllOccurrences() + + /** + * Constructs a new Frequency-rule + * + * @param string|array $rule + * @param int $start Unix-timestamp (important : Need to be the start of Event) + * @param array $excluded of int (timestamps), see EXDATE documentation + * @param array $added of int (timestamps), see RDATE documentation + * @throws Exception + */ + public function __construct($rule, int $start, array $excluded = [], array $added = []) { + $this->start = $start; + $this->excluded = []; + + $rules = []; + foreach ($rule as $k => $v) { + $this->rules[strtolower($k)] = $v; + } + + if (isset($this->rules['until']) && is_string($this->rules['until'])) { + $this->rules['until'] = strtotime($this->rules['until']); + } elseif ($this->rules['until'] instanceof DateTime) { + $this->rules['until'] = $this->rules['until']->getTimestamp(); + } + $this->freq = strtolower($this->rules['freq']); + + foreach ($this->knownRules as $rule) { + if (isset($this->rules['by' . $rule])) { + if ($this->isPrerule($rule, $this->freq)) { + $this->simpleMode = false; + } + } + } + + if (!$this->simpleMode) { + if (!(isset($this->rules['byday']) || isset($this->rules['bymonthday']) || isset($this->rules['byyearday']))) { + $this->rules['bymonthday'] = date('d', $this->start); + } + } + + //set until, and cache + if (isset($this->rules['count'])) { + + $cache[$ts] = $ts = $this->start; + for ($n = 1; $n < $this->rules['count']; $n++) { + $ts = $this->findNext($ts); + $cache[$ts] = $ts; + } + $this->rules['until'] = $ts; + + //EXDATE + if (!empty($excluded)) { + foreach ($excluded as $ts) { + unset($cache[$ts]); + } + } + //RDATE + if (!empty($added)) { + $cache = array_unique(array_merge(array_values($cache), $added)); + asort($cache); + } + + $this->cache = array_values($cache); + } + + $this->excluded = $excluded; + $this->added = $added; + } + + private function isPrerule(string $rule, string $freq): bool { + if ($rule === 'year') { + return false; + } + if ($rule === 'month' && $freq === 'yearly') { + return true; + } + if ($rule === 'monthday' && in_array($freq, ['yearly', 'monthly']) && !isset($this->rules['byday'])) { + return true; + } + // TODO: is it faster to do monthday first, and ignore day if monthday exists? - prolly by a factor of 4.. + if ($rule === 'yearday' && $freq === 'yearly') { + return true; + } + if ($rule === 'weekno' && $freq === 'yearly') { + return true; + } + if ($rule === 'day' && in_array($freq, ['yearly', 'monthly', 'weekly'])) { + return true; + } + if ($rule === 'hour' && in_array($freq, ['yearly', 'monthly', 'weekly', 'daily'])) { + return true; + } + if ($rule === 'minute') { + return true; + } + + return false; + } + + /** + * Calculates the next time after the given offset that the rule + * will apply. + * + * The approach to finding the next is as follows: + * First we establish a timeframe to find timestamps in. This is + * between $offset and the end of the period that $offset is in. + * + * We then loop though all the rules (that is a Prerule in the + * current freq.), and finds the smallest timestamp inside the + * timeframe. + * + * If we find something, we check if the date is a valid recurrence + * (with validDate). If it is, we return it. Otherwise we try to + * find a new date inside the same timeframe (but using the new- + * found date as offset) + * + * If no new timestamps were found in the period, we try in the + * next period + * + * @param int $offset + * @return int|bool + * @throws Exception + */ + public function findNext(int $offset) { + if (!empty($this->cache)) { + foreach ($this->cache as $ts) { + if ($ts > $offset) { + return $ts; + } + } + } + + $debug = false; + + //make sure the offset is valid + if ($offset === false || (isset($this->rules['until']) && $offset > $this->rules['until'])) { + if ($debug) printf("STOP: %s\n", date('r', $offset)); + return false; + } + + $found = true; + + //set the timestamp of the offset (ignoring hours and minutes unless we want them to be + //part of the calculations. + if ($debug) printf("O: %s\n", date('r', $offset)); + $hour = (in_array($this->freq, ['hourly', 'minutely']) && $offset > $this->start) ? date('H', $offset) : date( + 'H', $this->start + ); + $minute = (($this->freq === 'minutely' || isset($this->rules['byminute'])) && $offset > $this->start) ? date( + 'i', $offset + ) : date('i', $this->start); + $t = mktime($hour, $minute, date('s', $this->start), date('m', $offset), date('d', $offset), date('Y', $offset)); + if ($debug) printf("START: %s\n", date('r', $t)); + + if ($this->simpleMode) { + if ($offset < $t) { + $ts = $t; + if ($ts && in_array($ts, $this->excluded, true)) { + $ts = $this->findNext($ts); + } + } else { + $ts = $this->findStartingPoint($t, $this->rules['interval'], false); + if (!$this->validDate($ts)) { + $ts = $this->findNext($ts); + } + } + + return $ts; + } + + //EOP needs to have the same TIME as START ($t) + $tO = new DateTime('@' . $t, new DateTimeZone('UTC')); + + $eop = $this->findEndOfPeriod($offset); + $eopO = new DateTime('@' . $eop, new DateTimeZone('UTC')); + $eopO->setTime($tO->format('H'), $tO->format('i'), $tO->format('s')); + $eop = $eopO->getTimestamp(); + unset($eopO, $tO); + + if ($debug) { + echo 'EOP: ' . date('r', $eop) . "\n"; + } + foreach ($this->knownRules as $rule) { + if ($found && isset($this->rules['by' . $rule])) { + if ($this->isPrerule($rule, $this->freq)) { + $subRules = explode(',', $this->rules['by' . $rule]); + $_t = null; + foreach ($subRules as $subRule) { + $imm = call_user_func_array([$this, "ruleBy$rule"], [$subRule, $t]); + if ($imm === false) { + break; + } + if ($debug) { + printf("%s: %s A: %d\n", strtoupper($rule), date('r', $imm), intval($imm > $offset && $imm < $eop)); + } + if ($imm > $offset && $imm <= $eop && ($_t == null || $imm < $_t)) { + $_t = $imm; + } + } + if ($_t !== null) { + $t = $_t; + } else { + $found = $this->validDate($t); + } + } + } + } + + if ($offset < $this->start && $this->start < $t) { + $ts = $this->start; + } elseif ($found && ($t != $offset)) { + if ($this->validDate($t)) { + if ($debug) echo 'OK' . "\n"; + $ts = $t; + } else { + if ($debug) echo 'Invalid' . "\n"; + $ts = $this->findNext($t); + } + } else { + if ($debug) echo 'Not found' . "\n"; + $ts = $this->findNext($this->findStartingPoint($offset, $this->rules['interval'])); + } + if ($ts && in_array($ts, $this->excluded, true)) { + return $this->findNext($ts); + } + + return $ts; + } + + /** + * Finds the starting point for the next rule. It goes $interval + * 'freq' forward in time since the given offset + * + * @param int $offset + * @param int $interval + * @param boolean $truncate + * @return int + */ + private function findStartingPoint(int $offset, int $interval, $truncate = true): int { + $_freq = ($this->freq === 'daily') ? 'day__' : $this->freq; + $t = '+' . $interval . ' ' . substr($_freq, 0, -2) . 's'; + if ($_freq === 'monthly' && $truncate) { + if ($interval > 1) { + $offset = strtotime('+' . ($interval - 1) . ' months ', $offset); // FIXME return type int|false + } + $t = '+' . (date('t', $offset) - date('d', $offset) + 1) . ' days'; + } + + $sp = strtotime($t, $offset); + + if ($truncate) { + $sp = $this->truncateToPeriod($sp, $this->freq); + } + + return $sp; + } + + /** + * Resets the timestamp to the beginning of the + * period specified by freq + * + * Yes - the fall-through is on purpose! + * + * @param int $time + * @param string $freq + * @return int + */ + private function truncateToPeriod(int $time, string $freq): int { + $date = getdate($time); + switch ($freq) { + case 'yearly': + $date['mon'] = 1; + case 'monthly': + $date['mday'] = 1; + case 'daily': + $date['hours'] = 0; + case 'hourly': + $date['minutes'] = 0; + case 'minutely': + $date['seconds'] = 0; + break; + case 'weekly': + if (date('N', $time) == 1) { // FIXME wrong compare, date return string|false + $date['hours'] = 0; + $date['minutes'] = 0; + $date['seconds'] = 0; + } else { + $date = getdate(strtotime('last monday 0:00', $time)); + } + break; + } + return mktime($date['hours'], $date['minutes'], $date['seconds'], $date['mon'], $date['mday'], $date['year']); + } + + private function validDate($t): bool { + if (isset($this->rules['until']) && $t > $this->rules['until']) { + return false; + } + + if (in_array($t, $this->excluded, true)) { + return false; + } + + if (isset($this->rules['bymonth'])) { + $months = explode(',', $this->rules['bymonth']); + if (!in_array(date('m', $t), $months, true)) { + return false; + } + } + if (isset($this->rules['byday'])) { + $days = explode(',', $this->rules['byday']); + foreach ($days as $i => $k) { + $days[$i] = $this->weekdays[preg_replace('/[^A-Z]/', '', $k)]; + } + if (!in_array(strtolower(date('l', $t)), $days, true)) { + return false; + } + } + if (isset($this->rules['byweekno'])) { + $weeks = explode(',', $this->rules['byweekno']); + if (!in_array(date('W', $t), $weeks, true)) { + return false; + } + } + if (isset($this->rules['bymonthday'])) { + $weekdays = explode(',', $this->rules['bymonthday']); + foreach ($weekdays as $i => $k) { + if ($k < 0) { + $weekdays[$i] = date('t', $t) + $k + 1; + } + } + if (!in_array(date('d', $t), $weekdays, true)) { + return false; + } + } + if (isset($this->rules['byhour'])) { + $hours = explode(',', $this->rules['byhour']); + if (!in_array(date('H', $t), $hours, true)) { + return false; + } + } + + return true; + } + + /** + * Finds the earliest timestamp possible outside this period. + * + * @param int $offset + * @return int + */ + public function findEndOfPeriod($offset = 0) { + return $this->findStartingPoint($offset, 1, false); + } + + /** + * Returns the previous (most recent) occurrence of the rule from the + * given offset + * + * @param int $offset + * @return int + * @throws Exception + */ + public function previousOccurrence(int $offset) { + if (!empty($this->cache)) { + $t2 = $this->start; + foreach ($this->cache as $ts) { + if ($ts >= $offset) { + return $t2; + } + $t2 = $ts; + } + } else { + $ts = $this->start; + while (($t2 = $this->findNext($ts)) < $offset) { + if ($t2 == false) { + break; + } + $ts = $t2; + } + } + + return $ts; + } + + /** + * Returns the next occurrence of this rule after the given offset + * + * @param int $offset + * @return int + * @throws Exception + */ + public function nextOccurrence(int $offset) { + if ($offset < $this->start) { + return $this->firstOccurrence(); + } + return $this->findNext($offset); + } + + /** + * Finds the first occurrence of the rule. + * + * @return int timestamp + * @throws Exception + */ + public function firstOccurrence() { + $t = $this->start; + if (in_array($t, $this->excluded)) { + $t = $this->findNext($t); + } + + return $t; + } + + /** + * Finds the absolute last occurrence of the rule from the given offset. + * Builds also the cache, if not set before... + * + * @return int timestamp + * @throws Exception + */ + public function lastOccurrence() { + //build cache if not done + $this->getAllOccurrences(); + //return last timestamp in cache + return end($this->cache); + } + + /** + * Returns all timestamps array(), build the cache if not made before + * + * @return array + * @throws Exception + */ + public function getAllOccurrences() { + if (empty($this->cache)) { + $cache = []; + + //build cache + $next = $this->firstOccurrence(); + while ($next) { + $cache[] = $next; + $next = $this->findNext($next); + } + if (!empty($this->added)) { + $cache = array_unique(array_merge($cache, $this->added)); + asort($cache); + } + $this->cache = $cache; + } + + return $this->cache; + } + + /** + * Applies the BYDAY rule to the given timestamp + * + * @param string $rule + * @param int $t + * @return int + */ + private function ruleByDay(string $rule, int $t): int { + $dir = ($rule[0] === '-') ? -1 : 1; + $dir_t = ($dir === 1) ? 'next' : 'last'; + + $d = $this->weekdays[substr($rule, -2)]; + $s = $dir_t . ' ' . $d . ' ' . date('H:i:s', $t); + + if ($rule == substr($rule, -2)) { + if (date('l', $t) == ucfirst($d)) { + $s = 'today ' . date('H:i:s', $t); + } + + $_t = strtotime($s, $t); + + if ($_t == $t && in_array($this->freq, ['weekly', 'monthly', 'yearly'])) { + // Yes. This is not a great idea.. but hey, it works.. for now + $s = 'next ' . $d . ' ' . date('H:i:s', $t); + $_t = strtotime($s, $_t); + } + + return $_t; + } else { + $_f = $this->freq; + if (isset($this->rules['bymonth']) && $this->freq === 'yearly') { + $this->freq = 'monthly'; + } + if ($dir === -1) { + $_t = $this->findEndOfPeriod($t); + } else { + $_t = $this->truncateToPeriod($t, $this->freq); + } + $this->freq = $_f; + + $c = preg_replace('/[^0-9]/', '', $rule); + $c = ($c == '') ? 1 : $c; + + $n = $_t; + while ($c > 0) { + if ($dir === 1 && $c == 1 && date('l', $t) == ucfirst($d)) { + $s = 'today ' . date('H:i:s', $t); + } + $n = strtotime($s, $n); + $c--; + } + + return $n; + } + } + + private function ruleByMonth($rule, int $t) { + $_t = mktime(date('H', $t), date('i', $t), date('s', $t), $rule, date('d', $t), date('Y', $t)); + if ($t == $_t && isset($this->rules['byday'])) { + // TODO: this should check if one of the by*day's exists, and have a multi-day value + return false; + } else { + return $_t; + } + } + + private function ruleByMonthday($rule, int $t) { + if ($rule < 0) { + $rule = date('t', $t) + $rule + 1; + } + + return mktime(date('H', $t), date('i', $t), date('s', $t), date('m', $t), $rule, date('Y', $t)); + } + + private function ruleByYearday($rule, int $t) { + if ($rule < 0) { + $_t = $this->findEndOfPeriod(); + $d = '-'; + } else { + $_t = $this->truncateToPeriod($t, $this->freq); + $d = '+'; + } + $s = $d . abs($rule - 1) . ' days ' . date('H:i:s', $t); + + return strtotime($s, $_t); + } + + private function ruleByWeekno($rule, int $t) { + if ($rule < 0) { + $_t = $this->findEndOfPeriod(); + $d = '-'; + } else { + $_t = $this->truncateToPeriod($t, $this->freq); + $d = '+'; + } + + $sub = (date('W', $_t) == 1) ? 2 : 1; + $s = $d . abs($rule - $sub) . ' weeks ' . date('H:i:s', $t); + $_t = strtotime($s, $_t); + + return $_t; + } + + private function ruleByHour($rule, int $t) { + return mktime($rule, date('i', $t), date('s', $t), date('m', $t), date('d', $t), date('Y', $t)); + } + + private function ruleByMinute($rule, int $t) { + return mktime(date('h', $t), $rule, date('s', $t), date('m', $t), date('d', $t), date('Y', $t)); + } +} diff --git a/includes/ical/src/IcalParser.php b/includes/ical/src/IcalParser.php new file mode 100644 index 0000000..b4996e4 --- /dev/null +++ b/includes/ical/src/IcalParser.php @@ -0,0 +1,466 @@ +<?php + +namespace om; + +use DateInterval; +use DateTime; +use DateTimeZone; +use Exception; +use InvalidArgumentException; +use RuntimeException; + +/** + * Copyright (c) 2004-2022 Roman Ožana (https://ozana.cz) + * + * @license BSD-3-Clause + * @author Roman Ožana <roman@ozana.cz> + */ +class IcalParser { + + /** @var ?DateTimeZone */ + public ?DateTimeZone $timezone = null; + + /** @var array|null */ + public ?array $data = null; + + /** @var array */ + protected array $counters = []; + + /** @var array */ + private $windowsTimezones; + + public function __construct() { + $this->windowsTimezones = require __DIR__ . '/WindowsTimezones.php'; // load Windows timezones from separate file + } + + /** + * @param string $file + * @param callable|null $callback + * @return array|null + * @throws Exception + */ + public function parseFile(string $file, callable $callback = null): array { + if (!$handle = fopen($file, 'rb')) { + throw new RuntimeException('Can\'t open file' . $file . ' for reading'); + } + fclose($handle); + + return $this->parseString(file_get_contents($file), $callback); + } + + /** + * @param string $string + * @param callable|null $callback + * @param boolean $add if true the parsed string is added to existing data + * @return array|null + * @throws Exception + */ + public function parseString(string $string, callable $callback = null, bool $add = false): ?array { + if ($add === false) { + // delete old data + $this->data = []; + $this->counters = []; + } + + if (!preg_match('/BEGIN:VCALENDAR/', $string)) { + throw new InvalidArgumentException('Invalid ICAL data format'); + } + + $section = 'VCALENDAR'; + + // Replace \r\n with \n + $string = str_replace("\r\n", "\n", $string); + + // Unfold multi-line strings + $string = str_replace("\n ", '', $string); + + foreach (explode("\n", $string) as $row) { + + switch ($row) { + case 'BEGIN:DAYLIGHT': + case 'BEGIN:VALARM': + case 'BEGIN:VTIMEZONE': + case 'BEGIN:VFREEBUSY': + case 'BEGIN:VJOURNAL': + case 'BEGIN:STANDARD': + case 'BEGIN:VTODO': + case 'BEGIN:VEVENT': + $section = substr($row, 6); + $this->counters[$section] = isset($this->counters[$section]) ? $this->counters[$section] + 1 : 0; + continue 2; // while + case 'END:VEVENT': + $section = substr($row, 4); + $currCounter = $this->counters[$section]; + $event = $this->data[$section][$currCounter]; + if (!empty($event['RECURRENCE-ID'])) { + $this->data['_RECURRENCE_IDS'][$event['RECURRENCE-ID']] = $event; + } + + continue 2; // while + case 'END:DAYLIGHT': + case 'END:VALARM': + case 'END:VTIMEZONE': + case 'END:VFREEBUSY': + case 'END:VJOURNAL': + case 'END:STANDARD': + case 'END:VTODO': + continue 2; // while + + case 'END:VCALENDAR': + $veventSection = 'VEVENT'; + if (!empty($this->data[$veventSection])) { + foreach ($this->data[$veventSection] as $currCounter => $event) { + if (!empty($event['RRULE']) || !empty($event['RDATE'])) { + $recurrences = $this->parseRecurrences($event); + if (!empty($recurrences)) { + $this->data[$veventSection][$currCounter]['RECURRENCES'] = $recurrences; + } + + if (!empty($event['UID'])) { + $this->data["_RECURRENCE_COUNTERS_BY_UID"][$event['UID']] = $currCounter; + } + } + } + } + continue 2; // while + } + + [$key, $middle, $value] = $this->parseRow($row); + + if ($callback) { + // call user function for processing line + call_user_func($callback, $row, $key, $middle, $value, $section, $this->counters[$section]); + } else { + if ($section === 'VCALENDAR') { + $this->data[$key] = $value; + } else { + + // use an array since there can be multiple entries for this key. This does not + // break the current implementation--it leaves the original key alone and adds + // a new one specifically for the array of values. + + if ($newKey = $this->isMultipleKey($key)) { + $this->data[$section][$this->counters[$section]][$newKey][] = $value; + } + + // CATEGORIES can be multiple also but there is special case that there are comma separated categories + + if ($this->isMultipleKeyWithCommaSeparation($key)) { + + if (strpos($value, ',') !== false) { + $values = array_map('trim', preg_split('/(?<![^\\\\]\\\\),/', $value)); + } else { + $values = [$value]; + } + + foreach ($values as $value) { + $this->data[$section][$this->counters[$section]][$key][] = $value; + } + + } else { + $this->data[$section][$this->counters[$section]][$key] = $value; + } + + } + + } + } + + return ($callback) ? null : $this->data; + } + + /** + * @param $event + * @return array + * @throws Exception + */ + public function parseRecurrences($event): array { + $recurring = new Recurrence($event['RRULE']); + $exclusions = []; + $additions = []; + + if (!empty($event['EXDATES'])) { + foreach ($event['EXDATES'] as $exDate) { + if (is_array($exDate)) { + foreach ($exDate as $singleExDate) { + $exclusions[] = $singleExDate->getTimestamp(); + } + } else { + $exclusions[] = $exDate->getTimestamp(); + } + } + } + + if (!empty($event['RDATES'])) { + foreach ($event['RDATES'] as $rDate) { + if (is_array($rDate)) { + foreach ($rDate as $singleRDate) { + $additions[] = $singleRDate->getTimestamp(); + } + } else { + $additions[] = $rDate->getTimestamp(); + } + } + } + + $until = $recurring->getUntil(); + if ($until === false) { + //forever... limit to 3 years from now + $end = new DateTime('now'); + $end->add(new DateInterval('P3Y')); // + 3 years + $recurring->setUntil($end); + $until = $recurring->getUntil(); + } + + date_default_timezone_set($event['DTSTART']->getTimezone()->getName()); + $frequency = new Freq($recurring->rrule, $event['DTSTART']->getTimestamp(), $exclusions, $additions); + $recurrenceTimestamps = $frequency->getAllOccurrences(); + $recurrences = []; + foreach ($recurrenceTimestamps as $recurrenceTimestamp) { + $tmp = new DateTime('now', $event['DTSTART']->getTimezone()); + $tmp->setTimestamp($recurrenceTimestamp); + + $recurrenceIDDate = $tmp->format('Ymd'); + $recurrenceIDDateTime = $tmp->format('Ymd\THis'); + if (empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDate]) && + empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDateTime])) { + $gmtCheck = new DateTime('now', new DateTimeZone('UTC')); + $gmtCheck->setTimestamp($recurrenceTimestamp); + $recurrenceIDDateTimeZ = $gmtCheck->format('Ymd\THis\Z'); + if (empty($this->data['_RECURRENCE_IDS'][$recurrenceIDDateTimeZ])) { + $recurrences[] = $tmp; + } + } + } + + return $recurrences; + } + + private function parseRow($row): array { + preg_match('#^([\w-]+);?([\w-]+="[^"]*"|.*?):(.*)$#i', $row, $matches); + + $key = false; + $middle = null; + $value = null; + + if ($matches) { + $key = $matches[1]; + $middle = $matches[2]; + $value = $matches[3]; + $timezone = null; + + if ($key === 'X-WR-TIMEZONE' || $key === 'TZID') { + if (preg_match('#(\w+/\w+)$#i', $value, $matches)) { + $value = $matches[1]; + } + $value = $this->toTimezone($value); + $this->timezone = new DateTimeZone($value); + } + + // have some middle part ? + if ($middle && preg_match_all('#(?<key>[^=;]+)=(?<value>[^;]+)#', $middle, $matches, PREG_SET_ORDER)) { + $middle = []; + foreach ($matches as $match) { + if ($match['key'] === 'TZID') { + $match['value'] = trim($match['value'], "'\""); + $match['value'] = $this->toTimezone($match['value']); + try { + $middle[$match['key']] = $timezone = new DateTimeZone($match['value']); + } catch (Exception $e) { + $middle[$match['key']] = $match['value']; + } + } elseif ($match['key'] === 'ENCODING') { + if ($match['value'] === 'QUOTED-PRINTABLE') { + $value = quoted_printable_decode($value); + } + } + } + } + } + + // process simple dates with timezone + if (in_array($key, ['DTSTAMP', 'LAST-MODIFIED', 'CREATED', 'DTSTART', 'DTEND'], true)) { + try { + $value = new DateTime($value, ($timezone ?: $this->timezone)); + } catch (Exception $e) { + $value = null; + } + } elseif (in_array($key, ['EXDATE', 'RDATE'])) { + $values = []; + foreach (explode(',', $value) as $singleValue) { + try { + $values[] = new DateTime($singleValue, ($timezone ?: $this->timezone)); + } catch (Exception $e) { + // pass + } + } + if (count($values) === 1) { + $value = $values[0]; + } else { + $value = $values; + } + } + + if ($key === 'RRULE' && preg_match_all('#(?<key>[^=;]+)=(?<value>[^;]+)#', $value, $matches, PREG_SET_ORDER)) { + $middle = null; + $value = []; + foreach ($matches as $match) { + if (in_array($match['key'], ['UNTIL'])) { + try { + $value[$match['key']] = new DateTime($match['value'], ($timezone ?: $this->timezone)); + } catch (Exception $e) { + $value[$match['key']] = $match['value']; + } + } else { + $value[$match['key']] = $match['value']; + } + } + } + + //implement 4.3.11 Text ESCAPED-CHAR + $text_properties = [ + 'CALSCALE', 'METHOD', 'PRODID', 'VERSION', 'CATEGORIES', 'CLASS', 'COMMENT', 'DESCRIPTION', + 'LOCATION', 'RESOURCES', 'STATUS', 'SUMMARY', 'TRANSP', 'TZID', 'TZNAME', 'CONTACT', + 'RELATED-TO', 'UID', 'ACTION', 'REQUEST-STATUS', 'URL', + ]; + if (in_array($key, $text_properties, true) || strpos($key, 'X-') === 0) { + if (is_array($value)) { + foreach ($value as &$var) { + $var = strtr($var, ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ',']); + } + } else { + $value = strtr($value, ['\\\\' => '\\', '\\N' => "\n", '\\n' => "\n", '\\;' => ';', '\\,' => ',']); + } + } + + return [$key, $middle, $value]; + } + + /** + * Process timezone and return correct one... + * + * @param string $zone + * @return mixed|null + */ + private function toTimezone(string $zone) { + return $this->windowsTimezones[$zone] ?? $zone; + } + + public function isMultipleKey(string $key): ?string { + return (['ATTACH' => 'ATTACHMENTS', 'EXDATE' => 'EXDATES', 'RDATE' => 'RDATES'])[$key] ?? null; + } + + /** + * @param $key + * @return string|null + */ + public function isMultipleKeyWithCommaSeparation($key): ?string { + return (['X-CATEGORIES' => 'X-CATEGORIES', 'CATEGORIES' => 'CATEGORIES'])[$key] ?? null; + } + + public function getAlarms(): array { + return $this->data['VALARM'] ?? []; + } + + public function getTimezone(): array { + return $this->getTimezones(); + } + + public function getTimezones(): array { + return $this->data['VTIMEZONE'] ?? []; + } + + /** + * Return sorted event list as ArrayObject + * + * @deprecated use IcalParser::getEvents()->sorted() instead + */ + public function getSortedEvents(): \ArrayObject { + return $this->getEvents()->sorted(); + } + + public function getEvents(): EventsList { + $events = new EventsList(); + if (isset($this->data['VEVENT'])) { + foreach ($this->data['VEVENT'] as $iValue) { + $event = $iValue; + + if (empty($event['RECURRENCES'])) { + if (!empty($event['RECURRENCE-ID']) && !empty($event['UID']) && isset($event['SEQUENCE'])) { + $modifiedEventUID = $event['UID']; + $modifiedEventRecurID = $event['RECURRENCE-ID']; + $modifiedEventSeq = (int)$event['SEQUENCE']; + + if (isset($this->data['_RECURRENCE_COUNTERS_BY_UID'][$modifiedEventUID])) { + $counter = $this->data['_RECURRENCE_COUNTERS_BY_UID'][$modifiedEventUID]; + + $originalEvent = $this->data['VEVENT'][$counter]; + if (isset($originalEvent['SEQUENCE'])) { + $originalEventSeq = (int)$originalEvent['SEQUENCE']; + $originalEventFormattedStartDate = $originalEvent['DTSTART']->format('Ymd\THis'); + if ($modifiedEventRecurID === $originalEventFormattedStartDate && $modifiedEventSeq > $originalEventSeq) { + // this modifies the original event + $modifiedEvent = array_replace_recursive($originalEvent, $event); + $this->data['VEVENT'][$counter] = $modifiedEvent; + foreach ($events as $z => $event) { + if ($events[$z]['UID'] === $originalEvent['UID'] && + $events[$z]['SEQUENCE'] === $originalEvent['SEQUENCE']) { + // replace the original event with the modified event + $events[$z] = $modifiedEvent; + break; + } + } + $event = null; // don't add this to the $events[] array again + } elseif (!empty($originalEvent['RECURRENCES'])) { + for ($j = 0; $j < count($originalEvent['RECURRENCES']); $j++) { + $recurDate = $originalEvent['RECURRENCES'][$j]; + $formattedStartDate = $recurDate->format('Ymd\THis'); + if ($formattedStartDate === $modifiedEventRecurID) { + unset($this->data['VEVENT'][$counter]['RECURRENCES'][$j]); + $this->data['VEVENT'][$counter]['RECURRENCES'] = array_values($this->data['VEVENT'][$counter]['RECURRENCES']); + break; + } + } + } + } + } + } + + if (!empty($event)) { + $events->append($event); + } + } else { + $recurrences = $event['RECURRENCES']; + $event['RECURRING'] = true; + $event['DTEND'] = !empty($event['DTEND']) ? $event['DTEND'] : $event['DTSTART']; + $eventInterval = $event['DTSTART']->diff($event['DTEND']); + + $firstEvent = true; + foreach ($recurrences as $j => $recurDate) { + $newEvent = $event; + if (!$firstEvent) { + unset($newEvent['RECURRENCES']); + $newEvent['DTSTART'] = $recurDate; + $newEvent['DTEND'] = clone($recurDate); + $newEvent['DTEND']->add($eventInterval); + } + + $newEvent['RECURRENCE_INSTANCE'] = $j; + $events->append($newEvent); + $firstEvent = false; + } + } + } + } + return $events; + } + + /** + * @return \ArrayObject + * @deprecated use IcalParser::getEvents->reversed(); + */ + public function getReverseSortedEvents(): \ArrayObject { + return $this->getEvents()->reversed(); + } + +} diff --git a/includes/ical/src/Recurrence.php b/includes/ical/src/Recurrence.php new file mode 100644 index 0000000..15f39cd --- /dev/null +++ b/includes/ical/src/Recurrence.php @@ -0,0 +1,234 @@ +<?php + +namespace om; + +use DateTime; +use Exception; + +/** + * Class taken from https://github.com/coopTilleuls/intouch-iCalendar.git (Recurrence.php) + * + * A wrapper for recurrence rules in iCalendar. Parses the given line and puts the + * recurrence rules in the correct field of this object. + * + * See http://tools.ietf.org/html/rfc2445 for more information. Page 39 and onward contains more + * information on the recurrence rules themselves. Page 116 and onward contains + * some great examples which were often used for testing. + * + * @author Steven Oxley + * @author Michael Kahn (C) 2013 + * @license http://creativecommons.org/licenses/by-sa/2.5/dk/deed.en_GB CC-BY-SA-DK + */ +class Recurrence { + + public $rrule; + protected $freq; + protected $until; + protected $count; + protected $interval; + protected $bysecond; + protected $byminute; + protected $byhour; + protected $byday; + protected $bymonthday; + protected $byyearday; + protected $byweekno; + protected $bymonth; + protected $bysetpos; + protected $wkst; + /** + * A list of the properties that can have comma-separated lists for values. + * + * @var array + */ + protected array $listProperties = [ + 'bysecond', 'byminute', 'byhour', 'byday', 'bymonthday', + 'byyearday', 'byweekno', 'bymonth', 'bysetpos', + ]; + + /** + * Creates an recurrence object with a passed in line. Parses the line. + * + * @param array $rrule an om\icalparser row array which will be parsed to get the + * desired information. + */ + public function __construct(array $rrule) { + $this->parseRrule($rrule); + } + + /** + * Parses an 'RRULE' array and sets the member variables of this object. + * Expects a string that looks like this: 'FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,TU,WE' + * + * @param $rrule + */ + protected function parseRrule($rrule): void { + $this->rrule = $rrule; + //loop through the properties in the line and set their associated + //member variables + foreach ($this->rrule as $propertyName => $propertyValue) { + //need the lower-case name for setting the member variable + $propertyName = strtolower($propertyName); + //split up the list of values into an array (if it's a list) + if (in_array($propertyName, $this->listProperties, true)) { + $propertyValue = explode(',', $propertyValue); + } + $this->$propertyName = $propertyValue; + } + } + + /** + * Returns the frequency - corresponds to FREQ in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getFreq() { + return $this->getMember('freq'); + } + + /** + * Retrieves the desired member variable and returns it (if it's set) + * + * @param string $member name of the member variable + * @return mixed the variable value (if set), false otherwise + */ + protected function getMember(string $member) { + return $this->$member ?? false; + } + + /** + * Returns when the event will go until - corresponds to UNTIL in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getUntil() { + return $this->getMember('until'); + } + + /** + * Set the $until member + * + * @param mixed $ts timestamp (int) / Valid DateTime format (string) + * @throws Exception + */ + public function setUntil($ts): void { + if ($ts instanceof DateTime) { + $dt = $ts; + } elseif (is_int($ts)) { + $dt = new DateTime('@' . $ts); + } else { + $dt = new DateTime($ts); + } + $this->until = $dt->format('Ymd\THisO'); + $this->rrule['until'] = $this->until; + } + + /** + * Returns the count of the times the event will occur (should only appear if 'until' + * does not appear) - corresponds to COUNT in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getCount() { + return $this->getMember('count'); + } + + /** + * Returns the interval - corresponds to INTERVAL in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getInterval() { + return $this->getMember('interval'); + } + + /** + * Returns the bysecond part of the event - corresponds to BYSECOND in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getBySecond() { + return $this->getMember('bysecond'); + } + + /** + * Returns the byminute information for the event - corresponds to BYMINUTE in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByMinute() { + return $this->getMember('byminute'); + } + + /** + * Corresponds to BYHOUR in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByHour() { + return $this->getMember('byhour'); + } + + /** + *Corresponds to BYDAY in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByDay() { + return $this->getMember('byday'); + } + + /** + * Corresponds to BYMONTHDAY in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByMonthDay() { + return $this->getMember('bymonthday'); + } + + /** + * Corresponds to BYYEARDAY in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByYearDay() { + return $this->getMember('byyearday'); + } + + /** + * Corresponds to BYWEEKNO in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByWeekNo() { + return $this->getMember('byweekno'); + } + + /** + * Corresponds to BYMONTH in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getByMonth() { + return $this->getMember('bymonth'); + } + + /** + * Corresponds to BYSETPOS in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getBySetPos() { + return $this->getMember('bysetpos'); + } + + /** + * Corresponds to WKST in RFC 2445. + * + * @return mixed string if the member has been set, false otherwise + */ + public function getWkst() { + return $this->getMember('wkst'); + } +} diff --git a/includes/ical/src/WindowsTimezones.php b/includes/ical/src/WindowsTimezones.php new file mode 100644 index 0000000..9bfa391 --- /dev/null +++ b/includes/ical/src/WindowsTimezones.php @@ -0,0 +1,214 @@ +<?php + +/** + * List of Windows Timezones + */ +return [ + 'Dateline Standard Time' => 'Etc/GMT+12', + '(UTC-12:00) International Date Line West' => 'Etc/GMT+12', + 'UTC-11' => 'Etc/GMT+11', + '(UTC-11:00) Coordinated Universal Time -11' => 'Etc/GMT+11', + 'Hawaiian Standard Time' => 'Pacific/Honolulu', + '(UTC-10:00) Hawaii' => 'Pacific/Honolulu', + 'Alaskan Standard Time' => 'America/Anchorage', + '(UTC-09:00) Alaska' => 'America/Anchorage', + 'Pacific Standard Time (Mexico)' => 'America/Santa_Isabel', + '(UTC-08:00) Baja California' => 'America/Santa_Isabel', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'Pacific Time' => 'America/Los_Angeles', + '(UTC-08:00) Pacific Time (US and Canada)' => 'America/Los_Angeles', + '(UTC-08:00) Pacific Time (US & Canada)' => 'America/Los_Angeles', + 'US Mountain Standard Time' => 'America/Phoenix', + '(UTC-07:00) Arizona' => 'America/Phoenix', + 'Mountain Standard Time (Mexico)' => 'America/Chihuahua', + '(UTC-07:00) Chihuahua, La Paz, Mazatlan' => 'America/Chihuahua', + 'Mountain Standard Time' => 'America/Denver', + 'Mountain Time' => 'America/Denver', + '(UTC-07:00) Mountain Time (US and Canada)' => 'America/Denver', + '(UTC-07:00) Mountain Time (US & Canada)' => 'America/Denver', + 'Central America Standard Time' => 'America/Guatemala', + '(UTC-06:00) Central America' => 'America/Guatemala', + 'Central Standard Time' => 'America/Chicago', + 'Central Time' => 'America/Chicago', + '(UTC-06:00) Central Time (US and Canada)' => 'America/Chicago', + '(UTC-06:00) Central Time (US & Canada)' => 'America/Chicago', + 'Central Standard Time (Mexico)' => 'America/Mexico_City', + '(UTC-06:00) Guadalajara, Mexico City, Monterrey' => 'America/Mexico_City', + 'Canada Central Standard Time' => 'America/Regina', + '(UTC-06:00) Saskatchewan' => 'America/Regina', + 'SA Pacific Standard Time' => 'America/Bogota', + '(UTC-05:00) Bogota, Lima, Quito' => 'America/Bogota', + 'Eastern Standard Time' => 'America/New_York', + 'Eastern Time' => 'America/New_York', + '(UTC-05:00) Eastern Time (US and Canada)' => 'America/New_York', + '(UTC-05:00) Eastern Time (US & Canada)' => 'America/New_York', + 'US Eastern Standard Time' => 'America/Indianapolis', + '(UTC-05:00) Indiana (East)' => 'America/Indianapolis', + 'Venezuela Standard Time' => 'America/Caracas', + '(UTC-04:30) Caracas' => 'America/Caracas', + 'Paraguay Standard Time' => 'America/Asuncion', + '(UTC-04:00) Asuncion' => 'America/Asuncion', + 'Atlantic Standard Time' => 'America/Halifax', + '(UTC-04:00) Atlantic Time (Canada)' => 'America/Halifax', + 'Central Brazilian Standard Time' => 'America/Cuiaba', + '(UTC-04:00) Cuiaba' => 'America/Cuiaba', + 'SA Western Standard Time' => 'America/La_Paz', + '(UTC-04:00) Georgetown, La Paz, Manaus, San Juan' => 'America/La_Paz', + 'Pacific SA Standard Time' => 'America/Santiago', + '(UTC-04:00) Santiago' => 'America/Santiago', + 'Newfoundland Standard Time' => 'America/St_Johns', + '(UTC-03:30) Newfoundland' => 'America/St_Johns', + 'E. South America Standard Time' => 'America/Sao_Paulo', + '(UTC-03:00) Brasilia' => 'America/Sao_Paulo', + 'Argentina Standard Time' => 'America/Buenos_Aires', + '(UTC-03:00) Buenos Aires' => 'America/Buenos_Aires', + 'SA Eastern Standard Time' => 'America/Cayenne', + '(UTC-03:00) Cayenne, Fortaleza' => 'America/Cayenne', + 'Greenland Standard Time' => 'America/Godthab', + '(UTC-03:00) Greenland' => 'America/Godthab', + 'Montevideo Standard Time' => 'America/Montevideo', + '(UTC-03:00) Montevideo' => 'America/Montevideo', + 'Bahia Standard Time' => 'America/Bahia', + 'UTC-02' => 'Etc/GMT+2', + '(UTC-02:00) Coordinated Universal Time -02' => 'Etc/GMT+2', + 'Azores Standard Time' => 'Atlantic/Azores', + '(UTC-01:00) Azores' => 'Atlantic/Azores', + 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', + '(UTC-01:00) Cabo Verde Is.' => 'Atlantic/Cape_Verde', + 'Morocco Standard Time' => 'Africa/Casablanca', + '(UTC) Casablanca' => 'Africa/Casablanca', + 'UTC' => 'Etc/GMT', + 'Microsoft/Utc' => 'Etc/GMT', + 'GMT Standard Time' => 'Europe/London', + '(UTC) Dublin, Edinburgh, Lisbon, London' => 'Europe/London', + 'Greenwich Standard Time' => 'Atlantic/Reykjavik', + '(UTC) Monrovia, Reykjavik' => 'Atlantic/Reykjavik', + 'W. Europe Standard Time' => 'Europe/Berlin', + '(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna' => 'Europe/Berlin', + '(UTC+01:00) Amsterdam\, Berlin\, Bern\, Rome\, Stockholm\, Vienna' => 'Europe/Berlin', + '(UTC+01:00) Amsterdam\, Berlin\, Bern\, Rom\, Stockholm\, Wien' => 'Europe/Berlin', + 'Central Europe Standard Time' => 'Europe/Budapest', + '(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague' => 'Europe/Budapest', + 'Romance Standard Time' => 'Europe/Paris', + '(UTC+01:00) Brussels, Copenhagen, Madrid, Paris' => 'Europe/Paris', + 'Central European Standard Time' => 'Europe/Warsaw', + '(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb' => 'Europe/Warsaw', + 'W. Central Africa Standard Time' => 'Africa/Lagos', + '(UTC+01:00) West Central Africa' => 'Africa/Lagos', + 'Namibia Standard Time' => 'Africa/Windhoek', + '(UTC+01:00) Windhoek' => 'Africa/Windhoek', + 'GTB Standard Time' => 'Europe/Bucharest', + '(UTC+02:00) Athens, Bucharest' => 'Europe/Bucharest', + 'Middle East Standard Time' => 'Asia/Beirut', + '(UTC+02:00) Beirut' => 'Asia/Beirut', + 'Egypt Standard Time' => 'Africa/Cairo', + '(UTC+02:00) Cairo' => 'Africa/Cairo', + 'Syria Standard Time' => 'Asia/Damascus', + '(UTC+02:00) Damascus' => 'Asia/Damascus', + 'South Africa Standard Time' => 'Africa/Johannesburg', + '(UTC+02:00) Harare, Pretoria' => 'Africa/Johannesburg', + 'FLE Standard Time' => 'Europe/Kiev', + '(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius' => 'Europe/Kiev', + 'Turkey Standard Time' => 'Europe/Istanbul', + '(UTC+02:00) Istanbul' => 'Europe/Istanbul', + 'Israel Standard Time' => 'Asia/Jerusalem', + '(UTC+02:00) Jerusalem' => 'Asia/Jerusalem', + 'Libya Standard Time' => 'Africa/Tripoli', + 'Jordan Standard Time' => 'Asia/Amman', + '(UTC+02:00) Amman' => 'Asia/Amman', + 'Arabic Standard Time' => 'Asia/Baghdad', + '(UTC+03:00) Baghdad' => 'Asia/Baghdad', + 'Kaliningrad Standard Time' => 'Europe/Kaliningrad', + '(UTC+03:00) Kaliningrad' => 'Europe/Kaliningrad', + 'Arab Standard Time' => 'Asia/Riyadh', + '(UTC+03:00) Kuwait, Riyadh' => 'Asia/Riyadh', + 'E. Africa Standard Time' => 'Africa/Nairobi', + '(UTC+03:00) Nairobi' => 'Africa/Nairobi', + 'Iran Standard Time' => 'Asia/Tehran', + '(UTC+03:30) Tehran' => 'Asia/Tehran', + 'Arabian Standard Time' => 'Asia/Dubai', + '(UTC+04:00) Abu Dhabi, Muscat' => 'Asia/Dubai', + 'Azerbaijan Standard Time' => 'Asia/Baku', + '(UTC+04:00) Baku' => 'Asia/Baku', + 'Russian Standard Time' => 'Europe/Moscow', + '(UTC+04:00) Moscow, St. Petersburg, Volgograd' => 'Europe/Moscow', + 'Mauritius Standard Time' => 'Indian/Mauritius', + '(UTC+04:00) Port Louis' => 'Indian/Mauritius', + 'Georgian Standard Time' => 'Asia/Tbilisi', + '(UTC+04:00) Tbilisi' => 'Asia/Tbilisi', + 'Caucasus Standard Time' => 'Asia/Yerevan', + '(UTC+04:00) Yerevan' => 'Asia/Yerevan', + 'Afghanistan Standard Time' => 'Asia/Kabul', + '(UTC+04:30) Kabul' => 'Asia/Kabul', + 'West Asia Standard Time' => 'Asia/Tashkent', + '(UTC+05:00) Tashkent' => 'Asia/Tashkent', + 'Pakistan Standard Time' => 'Asia/Karachi', + '(UTC+05:00) Islamabad, Karachi' => 'Asia/Karachi', + 'India Standard Time' => 'Asia/Calcutta', + '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi' => 'Asia/Calcutta', + 'Sri Lanka Standard Time' => 'Asia/Colombo', + '(UTC+05:30) Sri Jayawardenepura' => 'Asia/Colombo', + 'Nepal Standard Time' => 'Asia/Katmandu', + '(UTC+05:45) Kathmandu' => 'Asia/Katmandu', + 'Central Asia Standard Time' => 'Asia/Almaty', + '(UTC+06:00) Astana' => 'Asia/Almaty', + 'Bangladesh Standard Time' => 'Asia/Dhaka', + '(UTC+06:00) Dhaka' => 'Asia/Dhaka', + 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', + '(UTC+06:00) Ekaterinburg' => 'Asia/Yekaterinburg', + 'Myanmar Standard Time' => 'Asia/Rangoon', + '(UTC+06:30) Yangon (Rangoon)' => 'Asia/Rangoon', + 'SE Asia Standard Time' => 'Asia/Bangkok', + '(UTC+07:00) Bangkok, Hanoi, Jakarta' => 'Asia/Bangkok', + 'N. Central Asia Standard Time' => 'Asia/Novosibirsk', + '(UTC+07:00) Novosibirsk' => 'Asia/Novosibirsk', + 'China Standard Time' => 'Asia/Shanghai', + '(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi' => 'Asia/Shanghai', + 'North Asia Standard Time' => 'Asia/Krasnoyarsk', + '(UTC+08:00) Krasnoyarsk' => 'Asia/Krasnoyarsk', + 'Singapore Standard Time' => 'Asia/Singapore', + '(UTC+08:00) Kuala Lumpur, Singapore' => 'Asia/Singapore', + 'W. Australia Standard Time' => 'Australia/Perth', + '(UTC+08:00) Perth' => 'Australia/Perth', + 'Taipei Standard Time' => 'Asia/Taipei', + '(UTC+08:00) Taipei' => 'Asia/Taipei', + 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', + '(UTC+08:00) Ulaanbaatar' => 'Asia/Ulaanbaatar', + 'North Asia East Standard Time' => 'Asia/Irkutsk', + '(UTC+09:00) Irkutsk' => 'Asia/Irkutsk', + 'Tokyo Standard Time' => 'Asia/Tokyo', + '(UTC+09:00) Osaka, Sapporo, Tokyo' => 'Asia/Tokyo', + 'Korea Standard Time' => 'Asia/Seoul', + '(UTC+09:00) Seoul' => 'Asia/Seoul', + 'Cen. Australia Standard Time' => 'Australia/Adelaide', + '(UTC+09:30) Adelaide' => 'Australia/Adelaide', + 'AUS Central Standard Time' => 'Australia/Darwin', + '(UTC+09:30) Darwin' => 'Australia/Darwin', + 'E. Australia Standard Time' => 'Australia/Brisbane', + '(UTC+10:00) Brisbane' => 'Australia/Brisbane', + 'AUS Eastern Standard Time' => 'Australia/Sydney', + '(UTC+10:00) Canberra, Melbourne, Sydney' => 'Australia/Sydney', + 'West Pacific Standard Time' => 'Pacific/Port_Moresby', + '(UTC+10:00) Guam, Port Moresby' => 'Pacific/Port_Moresby', + 'Tasmania Standard Time' => 'Australia/Hobart', + '(UTC+10:00) Hobart' => 'Australia/Hobart', + 'Yakutsk Standard Time' => 'Asia/Yakutsk', + '(UTC+10:00) Yakutsk' => 'Asia/Yakutsk', + 'Central Pacific Standard Time' => 'Pacific/Guadalcanal', + '(UTC+11:00) Solomon Is., New Caledonia' => 'Pacific/Guadalcanal', + 'Vladivostok Standard Time' => 'Asia/Vladivostok', + '(UTC+11:00) Vladivostok' => 'Asia/Vladivostok', + 'New Zealand Standard Time' => 'Pacific/Auckland', + '(UTC+12:00) Auckland, Wellington' => 'Pacific/Auckland', + 'UTC+12' => 'Etc/GMT-12', + '(UTC+12:00) Coordinated Universal Time +12' => 'Etc/GMT-12', + 'Fiji Standard Time' => 'Pacific/Fiji', + '(UTC+12:00) Fiji' => 'Pacific/Fiji', + 'Magadan Standard Time' => 'Asia/Magadan', + '(UTC+12:00) Magadan' => 'Asia/Magadan', + 'Tonga Standard Time' => 'Pacific/Tongatapu', + '(UTC+13:00) Nuku\'alofa' => 'Pacific/Tongatapu', + 'Samoa Standard Time' => 'Pacific/Apia', + '(UTC-11:00)Samoa' => 'Pacific/Apia', + 'W. Europe Standard Time 1' => 'Europe/Berlin', +]; diff --git a/includes/keywords.php b/includes/keywords.php new file mode 100644 index 0000000..4d6afaf --- /dev/null +++ b/includes/keywords.php @@ -0,0 +1,100 @@ +<?php + +function getKeyWords() { + $actions = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/actions.json"), true); + $toys = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/toys.json"), true); + $pages = []; + + foreach ($actions as $action) { + $base = strtolower($action["name"]); + $addKeywords = $action["keywords"]; + $keywords = [ + $base, + ucfirst($base), + ucwords($base) + ]; + + for ($i = 0; $i < strlen($base); $i++) { + $keywords[] = substr($base, 0, $i) . strtoupper(substr($base, $i, 1)) . substr($base, $i + 1, strlen($base) - $i - 1); + + for ($j = 0; $j < strlen($base); $j++) { + $keywords[] = substr($base, 0, $i) . strtoupper(substr($base, $i,$j)) . substr($base, $i + $j, strlen($base) - $i - $j); + } + } + + foreach ($addKeywords as $keyword) { + $keywords[] = $keyword; + $keywords[] = ucfirst($keyword); + $keywords[] = ucwords($keyword); + + for ($i = 0; $i < strlen($keyword); $i++) { + $keywords[] = substr($keyword, 0, $i) . strtoupper(substr($keyword, $i, 1)) . substr($keyword, $i + 1, strlen($keyword) - $i - 1); + + for ($j = 0; $j < strlen($keyword); $j++) { + $keywords[] = substr($keyword, 0, $i) . strtoupper(substr($keyword, $i, $j)) . substr($keyword, $i + $j, strlen($keyword) - $i - $j); + } + } + } + + $pages[$action["id"]] = [ + "keywords" => array_unique($keywords), + "link" => "/-/actions/$action[id]" + ]; + } + + foreach ($toys as $toy) { + $base = strtolower($toy["name"]); + $addKeywords = $toy["keywords"]; + $keywords = [ + $base, + ucfirst($base), + ucwords($base) + ]; + + for ($i = 0; $i < strlen($base); $i++) { + $keywords[] = substr($base, 0, $i) . strtoupper(substr($base, $i, 1)) . substr($base, $i + 1, strlen($base) - $i - 1); + + for ($j = 0; $j < strlen($base); $j++) { + $keywords[] = substr($base, 0, $i) . strtoupper(substr($base, $i, $j)) . substr($base, $i + $j, strlen($base) - $i - $j); + } + } + + foreach ($addKeywords as $keyword) { + $keywords[] = $keyword; + $keywords[] = ucfirst($keyword); + $keywords[] = ucwords($keyword); + + for ($i = 0; $i < strlen($keyword); $i++) { + $keywords[] = substr($keyword, 0, $i) . strtoupper(substr($keyword, $i, 1)) . substr($keyword, $i + 1, strlen($keyword) - $i - 1); + + for ($j = 0; $j < strlen($keyword); $j++) { + $keywords[] = substr($keyword, 0, $i) . strtoupper(substr($keyword, $i, $j)) . substr($keyword, $i + $j, strlen($keyword) - $i - $j); + } + } + } + + $pages[$toy["id"]] = [ + "keywords" => array_unique($keywords), + "link" => "/-/toys/$toy[id]" + ]; + } + + $keywords = []; + foreach ($pages as $page) { + foreach ($page["keywords"] as $keyword) { + $keywords[$keyword] = $page["link"]; + } + } + + return $keywords; +} + +function replaceKeyWords(string $input): string { + $keywords = getKeyWords(); + + foreach ($keywords as $keyword => $url) { + $input = str_replace($keyword, "<a href='$url'>$keyword</a>", $input); + } + + return $input; +}
\ No newline at end of file diff --git a/includes/member.php b/includes/member.php index d027fa7..f8050b0 100644 --- a/includes/member.php +++ b/includes/member.php @@ -165,7 +165,7 @@ if ($memberData["name"] === "fusion") { <li> <b>Score breakdown:</b> <code><?= $score["total"] ?></code> <ul> - <li><b>Host score:</b> <code><?= $score["host"] ?></code></li> + <li><b>Most common fronter score:</b> <code><?= $score["host"] ?></code></li> <li><b>Relationships score:</b> <code><?= $score["relations"] ?></code></li> <li><b>Fictive score:</b> <code><?= $score["fictive"] ?></code></li> <li><b>Median score:</b> <code><?= $score["median"] ?></code></li> diff --git a/includes/planner.php b/includes/planner.php index 72575cd..64d87e4 100644 --- a/includes/planner.php +++ b/includes/planner.php @@ -1,11 +1,20 @@ <?php +require_once $_SERVER["DOCUMENT_ROOT"] . "/includes/travelling.php"; global $travelling; +require_once $_SERVER["DOCUMENT_ROOT"] . "/includes/score.php"; +require_once $_SERVER["DOCUMENT_ROOT"] . "/includes/pronouns.php"; +require_once $_SERVER["DOCUMENT_ROOT"] . "/includes/bitset.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/session.php"; global $isLoggedIn; global $isUserLoggedIn; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/banner.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/rainbow.php"; +require_once $_SERVER['DOCUMENT_ROOT'] . "/includes/functions.php"; + $cloudburst = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/ynmuc-planner.json"), true); $raindrops = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/data/gdapd-planner.json"), true); ?> -<h2>Front Planner</h2> +<h2>Front planner</h2> <table id="planner"> <tbody> <tr class="planner-day"> diff --git a/includes/pleasure.php b/includes/pleasure.php index 1bbfcea..8b4835b 100644 --- a/includes/pleasure.php +++ b/includes/pleasure.php @@ -1,4 +1,4 @@ -<h2>Pleasure Alert +<h2>Pleasure alert <details style="display: inline-block;font-size:12px;"> <summary class="text-muted" style="opacity:.5;"></summary> <label><input id="test-mode" type="checkbox"> Test Mode</label> · <label><input id="fake-requests" type="checkbox"> Fake Requests</label> diff --git a/includes/random.php b/includes/random.php new file mode 100644 index 0000000..b6e7905 --- /dev/null +++ b/includes/random.php @@ -0,0 +1,15 @@ +<?php + +/** + * @throws Exception + */ +function random($length = 13): string { + if (function_exists("random_bytes")) { + $bytes = random_bytes(ceil($length / 2)); + } elseif (function_exists("openssl_random_pseudo_bytes")) { + $bytes = openssl_random_pseudo_bytes(ceil($length / 2)); + } else { + throw new Exception("No cryptographically secure random function available"); + } + return substr(bin2hex($bytes), 0, $length); +}
\ No newline at end of file diff --git a/includes/refresh.php b/includes/refresh.php index 79e3b7f..320af54 100644 --- a/includes/refresh.php +++ b/includes/refresh.php @@ -1,5 +1,6 @@ <?php +$app = json_decode(file_get_contents("./app.json"), true); $start = microtime(true); @mkdir("./data"); @@ -47,10 +48,14 @@ function getSystem(string $id) { getSystem("gdapd"); // Raindrops getSystem("ynmuc"); // Cloudburst +echo("Calendar\n"); +$currentOpStart = microtime(true); +file_put_contents("./data/calendar.ics", file_get_contents($app["calendar"])); +$times["calendar"] = microtime(true) - $currentOpStart; + echo("Downloading images.\n"); if (!file_exists("./data/images")) mkdir("./data/images"); -$currentOpStart = microtime(true); foreach (json_decode(file_get_contents("./data/gdapd-members.json"), true) as $member) { $currentOpStart2 = microtime(true); echo(" " . $member['id'] . "\n"); @@ -68,9 +73,7 @@ foreach (json_decode(file_get_contents("./data/gdapd-members.json"), true) as $m } $times["images-gdapd-" . $member['id']] = microtime(true) - $currentOpStart2; } -$times["images-gdapd"] = microtime(true) - $currentOpStart; -$currentOpStart = microtime(true); foreach (json_decode(file_get_contents("./data/ynmuc-members.json"), true) as $member) { $currentOpStart2 = microtime(true); echo(" " . $member['id'] . "\n"); @@ -88,7 +91,6 @@ foreach (json_decode(file_get_contents("./data/ynmuc-members.json"), true) as $m } $times["images-ynmuc-" . $member['id']] = microtime(true) - $currentOpStart2; } -$times["images-ynmuc"] = microtime(true) - $currentOpStart; $currentOpStart = microtime(true); function isJson($string): bool { @@ -104,11 +106,13 @@ foreach ($files as $file) { } $times["restore"] = microtime(true) - $currentOpStart; -$time = (microtime(true) - $start); +require_once "./backup.php"; + +$time = array_sum($times); echo("Completed in " . $time . " seconds.\n"); file_put_contents("./data/refresh.json", json_encode([ - "timestamp" => time(), + "timestamp" => microtime(true), "duration" => $time, "restored" => $restored, "times" => $times diff --git a/includes/restore.php b/includes/restore.php new file mode 100644 index 0000000..72748ab --- /dev/null +++ b/includes/restore.php @@ -0,0 +1,130 @@ +<?php + +function isJson($string): bool { + json_decode($string); + return (json_last_error() == JSON_ERROR_NONE); +} + +function pkcs7_unpad($data) { + return substr($data, 0, -ord($data[strlen($data) - 1])); +} + +function timeAgo($time): string { + if (!is_numeric($time)) { + $time = strtotime($time); + } + + $periods = ["second", "minute", "hour", "day", "week", "month", "year", "age"]; + $lengths = array("60", "60", "24", "7", "4.35", "12", "100"); + + $now = time(); + + $difference = $now - $time; + if ($difference <= 10 && $difference >= 0) { + return $tense = "now"; + } elseif ($difference > 0) { + $tense = "ago"; + } else { + $tense = "later"; + } + + for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths)-1; $j++) { + $difference /= $lengths[$j]; + } + + $difference = round($difference); + + $period = $periods[$j] . ($difference >1 ? "s" :''); + return "{$difference} {$period} {$tense}"; +} + +if (!isset($_SERVER['argv'][1]) || !isset($_SERVER['argv'][2])) { + echo("Usage: php " . $_SERVER['argv'][0] . " <file> <key>\n"); + die(); +} else { + $file = @file_get_contents($_SERVER['argv'][1]); + $raw = @file_get_contents($_SERVER['argv'][2]); + + if ($file === false) { + echo("Unable to open backup file\n"); + die(); + } + + if ($raw === false) { + echo("Unable to open key file\n"); + die(); + } + + $raw2 = base64_decode($raw); + + if (!isJson($raw2)) { + echo("Key file is corrupt\n"); + die(); + } + + $keydata = json_decode($raw2, true); + + if (!is_array($keydata) || !isset($keydata["iv"]) || !isset($keydata["key"])) { + echo("Key file is invalid\n"); + die(); + } + + $iv = hex2bin($keydata["iv"]); + $key = hex2bin($keydata["key"]); + + $decrypted = openssl_decrypt($file, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + + if ($decrypted === false) { + echo("Unable to decrypt backup\n"); + die(); + } + + $unpadded = pkcs7_unpad($decrypted); + + if (!is_string($unpadded)) { + echo("Unable to decrypt backup\n"); + die(); + } + + if (!isJson($unpadded)) { + echo("Backup is corrupt\n"); + die(); + } + + $data = json_decode($unpadded, true); + + if (!is_array($data) || !isset($data["date"]) || !isset($data["files"])) { + echo("Backup is invalid\n"); + die(); + } + + echo(realpath($_SERVER['argv'][1]) . "\n Key: " . $_SERVER['argv'][2] . "\n Date: " . date('r', strtotime($data["date"])) . " (" . timeAgo($data["date"]) . ")" . "\n Contents: " . count($data["files"]) . " files\n"); + + @mkdir("./_restored"); + + $index = 0; + foreach ($data["files"] as $file) { + if ($file["dir"] === "") { + print("[$index] /" . $file["file"] . "\n"); + } else { + print("[$index] /" . $file["dir"] . "/" . $file["file"] . "\n"); + } + + $content = base64_decode($file["content"]); + if (sha1($content) !== $file["checksum"][0]) { + print(" Backed up file is corrupted (SHA1 mismatch)\n Expected: " . $file["checksum"][0] . "\n Got: " . sha1($content) . "\n"); + die("Backup aborted.\n"); + } + if (md5($content) !== $file["checksum"][1]) { + print(" Backed up file is corrupted (MD5 mismatch)\n Expected: " . $file["checksum"][1] . "\n Got: " . md5($content) . "\n"); + die("Backup aborted.\n"); + } + + @mkdir("./_restored/" . $file["dir"], 0777, true); + file_put_contents("./_restored/" . $file["dir"] . "/" . $file["file"], $content); + + $index++; + } + + print("Restored backup to ./_restored; review files before restoring to production\n"); +}
\ No newline at end of file diff --git a/includes/session.php b/includes/session.php index 2157f5f..b440c9c 100644 --- a/includes/session.php +++ b/includes/session.php @@ -11,6 +11,12 @@ if (isset($_COOKIE['PEH2_SESSION_TOKEN'])) { if (file_exists($_SERVER['DOCUMENT_ROOT'] . "/includes/tokens/" . str_replace(".", "", str_replace("/", "", $_COOKIE['PEH2_SESSION_TOKEN'])))) { $_PROFILE = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includes/tokens/" . str_replace(".", "", str_replace("/", "", $_COOKIE['PEH2_SESSION_TOKEN']))), true); + + if (isset($_GET['invert'])) { + $_PROFILE["login"] = $_PROFILE["login"] === "raindrops" ? "cloudburst" : "raindrops"; + $_PROFILE["name"] = $_PROFILE["name"] === "Raindrops System" ? "Cloudburst System" : "Raindrops System"; + } + $isLoggedIn = true; } else { $isLoggedIn = false; diff --git a/includes/sysbanner.php b/includes/sysbanner.php index 5ffe000..3a2b8c1 100644 --- a/includes/sysbanner.php +++ b/includes/sysbanner.php @@ -18,7 +18,7 @@ $travelling = json_decode(file_get_contents($_SERVER['DOCUMENT_ROOT'] . "/includ <h3 style="height:max-content;"><?= $systemCommonName ?></h3> <div style="height:max-content;display:grid;grid-template-columns: repeat(5, 1fr);" id="member-card"> <span> - <b>Host: </b><?php + <b><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr>: </b><?php $foundHost = false; $hostGoneTravelling = false; diff --git a/includes/system/compare.php b/includes/system/compare.php index f597cf3..9714488 100644 --- a/includes/system/compare.php +++ b/includes/system/compare.php @@ -27,55 +27,55 @@ function getMember(string $id) { <span class="comparison-header-l2">Member</span> <span class="comparison-header-l3">Member</span> <span class="comparison-header-l4">Member</span> - <span class="comparison-header-l5">Mmbr.</span> + <span class="comparison-header-l5"><abbr title="Member" data-bs-toggle="tooltip">Mmbr.</abbr></span> </span> <span class="comparison-header comparison-item"> <span class="comparison-header-l0">Species</span> <span class="comparison-header-l1">Species</span> <span class="comparison-header-l2">Species</span> <span class="comparison-header-l3">Species</span> - <span class="comparison-header-l4">Spec.</span> - <span class="comparison-header-l5">Spec.</span> + <span class="comparison-header-l4"><abbr title="Species" data-bs-toggle="tooltip">Spec.</abbr></span> + <span class="comparison-header-l5"><abbr title="Species" data-bs-toggle="tooltip">Spec.</abbr></span> </span> <span class="comparison-header comparison-item"> <span class="comparison-header-l0">Relations</span> <span class="comparison-header-l1">Relations</span> <span class="comparison-header-l2">Relations</span> <span class="comparison-header-l3">Relations</span> - <span class="comparison-header-l4">Relt.</span> - <span class="comparison-header-l5">Relt.</span> + <span class="comparison-header-l4"><abbr title="Relations" data-bs-toggle="tooltip">Relt.</abbr></span> + <span class="comparison-header-l5"><abbr title="Relations" data-bs-toggle="tooltip">Relt.</abbr></span> </span> <span class="comparison-header comparison-item"> - <span class="comparison-header-l0">Host</span> - <span class="comparison-header-l1">Host</span> - <span class="comparison-header-l2">Host</span> - <span class="comparison-header-l3">Host</span> - <span class="comparison-header-l4">Hst.</span> - <span class="comparison-header-l5">Hst.</span> + <span class="comparison-header-l0"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> + <span class="comparison-header-l1"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> + <span class="comparison-header-l2"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> + <span class="comparison-header-l3"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> + <span class="comparison-header-l4"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> + <span class="comparison-header-l5"><abbr title="Most common fronter" data-bs-toggle="tooltip">MCF</abbr></span> </span> <span class="comparison-header comparison-item"> <span class="comparison-header-l0">Fictive</span> <span class="comparison-header-l1">Fictive</span> <span class="comparison-header-l2">Fictive</span> <span class="comparison-header-l3">Fictive</span> - <span class="comparison-header-l4">Fic.</span> - <span class="comparison-header-l5">Fic.</span> + <span class="comparison-header-l4"><abbr title="Fictive" data-bs-toggle="tooltip">Fic.</abbr></span> + <span class="comparison-header-l5"><abbr title="Fictive" data-bs-toggle="tooltip">Fic.</abbr></span> </span> <span class="comparison-header comparison-item"> <span class="comparison-header-l0">Little</span> <span class="comparison-header-l1">Little</span> <span class="comparison-header-l2">Little</span> <span class="comparison-header-l3">Little</span> - <span class="comparison-header-l4">Ltl.</span> - <span class="comparison-header-l5">Ltl.</span> + <span class="comparison-header-l4"><abbr title="Little" data-bs-toggle="tooltip">Ltl.</abbr></span> + <span class="comparison-header-l5"><abbr title="Little" data-bs-toggle="tooltip">Ltl.</abbr></span> </span> <span class="comparison-header comparison-item"> <span class="comparison-header-l0">Protector</span> <span class="comparison-header-l1">Protector</span> - <span class="comparison-header-l2">Protect.</span> - <span class="comparison-header-l3">Protect.</span> - <span class="comparison-header-l4">Prt.</span> - <span class="comparison-header-l5">Prt.</span> + <span class="comparison-header-l2"><abbr title="Protector" data-bs-toggle="tooltip">Protect.</abbr></span> + <span class="comparison-header-l3"><abbr title="Protector" data-bs-toggle="tooltip">Protect.</abbr></span> + <span class="comparison-header-l4"><abbr title="Protector" data-bs-toggle="tooltip">Prt.</abbr></span> + <span class="comparison-header-l5"><abbr title="Protector" data-bs-toggle="tooltip">Prt.</abbr></span> </span> <?php foreach (scoreOrder($members, $systemID) as $member): |