Просмотр исходного кода

Merge pull request #1802 from causefx/v2-develop

V2 develop
causefx 4 лет назад
Родитель
Сommit
e29e770257
100 измененных файлов с 7126 добавлено и 2079 удалено
  1. 2 0
      .gitignore
  2. 4 0
      README.md
  3. 253 94
      api/classes/organizr.class.php
  4. 3 1
      api/composer.json
  5. 199 11
      api/composer.lock
  6. 27 1
      api/config/default.php
  7. 16 2
      api/functions.php
  8. 1 1
      api/functions/demo-functions.php
  9. 34 25
      api/functions/log-functions.php
  10. 47 42
      api/functions/normal-functions.php
  11. 98 27
      api/functions/option-functions.php
  12. 59 51
      api/functions/organizr-functions.php
  13. 26 14
      api/functions/sso-functions.php
  14. 119 10
      api/functions/upgrade-functions.php
  15. 67 0
      api/homepage/bookmarks.php
  16. 8 9
      api/homepage/couchpotato.php
  17. 6 2
      api/homepage/deluge.php
  18. 228 0
      api/homepage/donate.php
  19. 14 15
      api/homepage/emby.php
  20. 94 67
      api/homepage/ical.php
  21. 14 14
      api/homepage/jellyfin.php
  22. 29 11
      api/homepage/lidarr.php
  23. 5 5
      api/homepage/monitorr.php
  24. 11 12
      api/homepage/ombi.php
  25. 12 13
      api/homepage/overseerr.php
  26. 34 27
      api/homepage/plex.php
  27. 27 8
      api/homepage/radarr.php
  28. 8 8
      api/homepage/rtorrent.php
  29. 20 21
      api/homepage/sickrage.php
  30. 25 5
      api/homepage/sonarr.php
  31. 29 22
      api/homepage/tautulli.php
  32. 6 7
      api/homepage/trakt.php
  33. 0 43
      api/pages/custom/index.html
  34. 1 0
      api/pages/settings-settings-logs.php
  35. 1 1
      api/pages/settings-tab-editor-categories.php
  36. 3 3
      api/pages/settings-tab-editor-tabs.php
  37. 2 2
      api/pages/wizard.php
  38. 17 2
      api/v2/index.php
  39. 22 24
      api/v2/routes/connectionTester.php
  40. 0 27
      api/v2/routes/custom/index.html
  41. 38 0
      api/v2/routes/homepage.php
  42. 13 7
      api/v2/routes/root.php
  43. 50 0
      api/vendor/adldap2/adldap2/.github/workflows/tests.yaml
  44. 1 0
      api/vendor/adldap2/adldap2/.gitignore
  45. 1 1
      api/vendor/adldap2/adldap2/.travis.yml
  46. 5 0
      api/vendor/adldap2/adldap2/SECURITY.md
  47. 8 5
      api/vendor/adldap2/adldap2/composer.json
  48. 4 0
      api/vendor/adldap2/adldap2/phpunit.xml
  49. 12 4
      api/vendor/adldap2/adldap2/readme.md
  50. 1 1
      api/vendor/adldap2/adldap2/src/Configuration/DomainConfiguration.php
  51. 1 1
      api/vendor/adldap2/adldap2/src/Configuration/Validators/ClassValidator.php
  52. 1 1
      api/vendor/adldap2/adldap2/src/Models/Attributes/DistinguishedName.php
  53. 9 0
      api/vendor/adldap2/adldap2/src/Models/Model.php
  54. 4 0
      api/vendor/adldap2/adldap2/src/Models/User.php
  55. 9 8
      api/vendor/adldap2/adldap2/src/Query/Builder.php
  56. 2 0
      api/vendor/adldap2/adldap2/src/Query/Paginator.php
  57. 3 3
      api/vendor/adldap2/adldap2/src/Query/Processor.php
  58. 1 1
      api/vendor/adldap2/adldap2/src/Utilities.php
  59. 139 12
      api/vendor/composer/ClassLoader.php
  60. 340 789
      api/vendor/composer/InstalledVersions.php
  61. 2 2
      api/vendor/composer/autoload_files.php
  62. 3 0
      api/vendor/composer/autoload_psr4.php
  63. 8 3
      api/vendor/composer/autoload_real.php
  64. 17 2
      api/vendor/composer/autoload_static.php
  65. 207 10
      api/vendor/composer/installed.json
  66. 621 602
      api/vendor/composer/installed.php
  67. 26 0
      api/vendor/doctrine/collections/.doctrine-project.json
  68. 54 0
      api/vendor/doctrine/collections/CONTRIBUTING.md
  69. 19 0
      api/vendor/doctrine/collections/LICENSE
  70. 92 0
      api/vendor/doctrine/collections/README.md
  71. 37 0
      api/vendor/doctrine/collections/composer.json
  72. 26 0
      api/vendor/doctrine/collections/docs/en/derived-collections.rst
  73. 173 0
      api/vendor/doctrine/collections/docs/en/expression-builder.rst
  74. 102 0
      api/vendor/doctrine/collections/docs/en/expressions.rst
  75. 328 0
      api/vendor/doctrine/collections/docs/en/index.rst
  76. 26 0
      api/vendor/doctrine/collections/docs/en/lazy-collections.rst
  77. 8 0
      api/vendor/doctrine/collections/docs/en/sidebar.rst
  78. 385 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/AbstractLazyCollection.php
  79. 463 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/ArrayCollection.php
  80. 276 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Collection.php
  81. 225 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Criteria.php
  82. 265 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/ClosureExpressionVisitor.php
  83. 80 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Comparison.php
  84. 69 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/CompositeExpression.php
  85. 14 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Expression.php
  86. 59 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/ExpressionVisitor.php
  87. 33 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Value.php
  88. 181 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/ExpressionBuilder.php
  89. 30 0
      api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Selectable.php
  90. 17 0
      api/vendor/doctrine/collections/phpstan.neon.dist
  91. 65 0
      api/vendor/doctrine/collections/psalm.xml.dist
  92. 3 0
      api/vendor/simshaun/recurr/.gitattributes
  93. 4 0
      api/vendor/simshaun/recurr/.gitignore
  94. 50 0
      api/vendor/simshaun/recurr/LICENSE
  95. 164 0
      api/vendor/simshaun/recurr/README.md
  96. 41 0
      api/vendor/simshaun/recurr/composer.json
  97. 48 0
      api/vendor/simshaun/recurr/src/Recurr/DateExclusion.php
  98. 48 0
      api/vendor/simshaun/recurr/src/Recurr/DateInclusion.php
  99. 73 0
      api/vendor/simshaun/recurr/src/Recurr/DateInfo.php
  100. 571 0
      api/vendor/simshaun/recurr/src/Recurr/DateUtil.php

+ 2 - 0
.gitignore

@@ -88,6 +88,8 @@ config/users
 config/users*.db
 config/users*.bak.db
 config/tmp/*
+data/*
+data/config/*
 docs/api.json
 images/cache/*
 backups/*

+ 4 - 0
README.md

@@ -99,6 +99,10 @@ The optional parameters and GID and UID are described in the [readme](https://gi
 
 ![OrganizrSponsor](https://user-images.githubusercontent.com/16184466/53614287-a9b73480-3b96-11e9-9c8e-e32b4ae20c0d.png)
 
+### Seedboxes.cc 
+
+[![Seedboxes.cc](https://user-images.githubusercontent.com/16184466/154811062-201be154-6868-4a24-ade6-a26278935415.png)](https://www.seedboxes.cc)
+
 ### BrowserStack for allowing us to use their platform for testing
 
 [![BrowserStack](https://avatars2.githubusercontent.com/u/1119453?s=200&v=4g)](https://www.browserstack.com)

+ 253 - 94
api/classes/organizr.class.php

@@ -27,9 +27,11 @@ class Organizr
 	use UpgradeFunctions;
 
 	// Use homepage item functions
+	use BookmarksHomepageItem;
 	use CalendarHomepageItem;
 	use CouchPotatoHomepageItem;
 	use DelugeHomepageItem;
+	use DonateHomepageItem;
 	use EmbyHomepageItem;
 	use HealthChecksHomepageItem;
 	use HTMLHomepageItem;
@@ -63,7 +65,7 @@ class Organizr
 
 	// ===================================
 	// Organizr Version
-	public $version = '2.1.1140';
+	public $version = '2.1.1680';
 	// ===================================
 	// Quick php Version check
 	public $minimumPHP = '7.3';
@@ -108,7 +110,8 @@ class Organizr
 		// Set Start Execution Time
 		$this->timeExecution = $this->timeExecution();
 		// Set location path to user config path
-		$this->userConfigPath = dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php';
+		$this->chooseConfigFile();
+		//$this->userConfigPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php';
 		// Set location path to default config path
 		$this->defaultConfigPath = dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'default.php';
 		// Set current time
@@ -137,8 +140,8 @@ class Organizr
 		// Set Paths
 		$this->paths = array(
 			'Root Folder' => dirname(__DIR__, 2) . DIRECTORY_SEPARATOR,
-			'Cache Folder' => dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR,
-			'Tab Folder' => dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR,
+			'Cache Folder' => dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR,
+			'Tab Folder' => $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR,
 			'API Folder' => dirname(__DIR__, 1) . DIRECTORY_SEPARATOR,
 			'DB Folder' => ($this->hasDB()) ? $this->config['dbLocation'] : false
 		);
@@ -163,6 +166,25 @@ class Organizr
 		$this->disconnectDB();
 	}
 
+	public function chooseConfigFile()
+	{
+
+		$oldUserConfigPath = dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php';
+		$userConfigPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'config.php';
+		if (file_exists($userConfigPath) && file_exists($oldUserConfigPath)) {
+			$this->userConfigPath = $userConfigPath;
+		} elseif (file_exists($oldUserConfigPath)) {
+			$this->makeDir(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR);
+			if ($this->rcopy($oldUserConfigPath, $userConfigPath)) {
+				$this->userConfigPath = $userConfigPath;
+			} else {
+				$this->userConfigPath = $oldUserConfigPath;
+			}
+		} else {
+			$this->userConfigPath = $userConfigPath;
+		}
+	}
+
 	protected function connectDB()
 	{
 		if ($this->hasDB()) {
@@ -254,8 +276,8 @@ class Organizr
 	public function checkForOrganizrOAuth()
 	{
 		// Oauth?
-		if ($this->config['authProxyEnabled'] && $this->config['authProxyHeaderName'] !== '' && $this->config['authProxyWhitelist'] !== '') {
-			if (isset(getallheaders()[$this->config['authProxyHeaderName']])) {
+		if ($this->config['authProxyEnabled'] && ($this->config['authProxyHeaderName'] !== '' || $this->config['authProxyHeaderNameEmail'] !== '') && $this->config['authProxyWhitelist'] !== '') {
+			if (isset(getallheaders()[$this->config['authProxyHeaderName']]) || isset(getallheaders()[$this->config['authProxyHeaderNameEmail']])) {
 				$this->coookieSeconds('set', 'organizrOAuth', 'true', 20000, false);
 			}
 		}
@@ -677,25 +699,26 @@ class Organizr
 		if (!in_array($route, $GLOBALS['bypass'])) {
 			if ($this->isApprovedRequest($method, $data) === false) {
 				$this->setAPIResponse('error', 'Not authorized for current Route: ' . $route, 401);
-				$this->writeLog('success', 'Killed Attack From [' . (isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : 'No Referer') . ']', $this->user['username']);
+				$this->setLoggerChannel('API Security');
+				$this->logger->notice('Killed Attack From [' . ($_SERVER['HTTP_REFERER'] ?? 'No Referer') . ']');
 				return false;
 			}
 		}
 		return true;
 	}
 
-	public function apiData($request)
+	public function apiData($request, $decode = true)
 	{
 		switch ($request->getMethod()) {
 			case 'POST':
 				if (stripos($request->getHeaderLine('Content-Type'), 'application/json') !== false) {
-					return json_decode(file_get_contents('php://input', 'r'), true);
+					return $decode ? json_decode(file_get_contents('php://input', 'r'), true) : file_get_contents('php://input', 'r');
 				} else {
 					return $request->getParsedBody();
 				}
 			default:
 				if (stripos($request->getHeaderLine('Content-Type'), 'application/json') !== false) {
-					return json_decode(file_get_contents('php://input', 'r'), true);
+					return $decode ? json_decode(file_get_contents('php://input', 'r'), true) : file_get_contents('php://input', 'r');
 				} else {
 					return null;
 				}
@@ -754,7 +777,7 @@ class Organizr
 			<meta name="theme-color" content="#ffffff">
 		';
 		if ($this->config['favIcon'] !== '' && $rootPath !== '') {
-			$this->config['favIcon'] = str_replace('plugins/images/faviconCustom', $rootPath . 'plugins/images/faviconCustom', $this->config['favIcon']);
+			$this->config['favIcon'] = str_replace('data/favicon', $rootPath . 'data/favicon', $this->config['favIcon']);
 		}
 		return ($this->config['favIcon'] == '') ? $favicon : $this->config['favIcon'];
 	}
@@ -1096,13 +1119,62 @@ class Organizr
 		return $this->config;
 	}
 
-	public function status()
+	public function status($action = false)
+	{
+		$status = [];
+		$dependenciesActive = [];
+		$dependenciesInactive = [];
+		$extensions = ['PDO_SQLITE', 'PDO', 'SQLITE3', 'zip', 'cURL', 'openssl', 'simplexml', 'json', 'session', 'filter'];
+		$functions = ['hash', 'fopen', 'fsockopen', 'fwrite', 'fclose', 'readfile'];
+		foreach ($extensions as $check) {
+			if (extension_loaded($check)) {
+				array_push($dependenciesActive, $check);
+			} else {
+				array_push($dependenciesInactive, $check);
+			}
+		}
+		foreach ($functions as $check) {
+			if (function_exists($check)) {
+				array_push($dependenciesActive, $check);
+			} else {
+				array_push($dependenciesInactive, $check);
+			}
+		}
+		$status['writable'] = is_writable(dirname(__DIR__, 2));
+		$status['minVersion'] = (version_compare(PHP_VERSION, $this->minimumPHP) >= 0);
+		$status['os'] = $this->getOS();
+		$status['php'] = phpversion();
+		$status['userConfigPathExists'] = file_exists($this->userConfigPath);
+		if (!($status['minVersion'])) {
+			$status['action'] = 'php';
+			if ($action) {
+				header($this->getServerPath() . 'api/v2/organizr/error');
+				exit;
+			}
+		} elseif (count($dependenciesInactive) > 0) {
+			$status['action'] = 'dependencies';
+		} elseif (!$status['writable']) {
+			$status['action'] = 'permission';
+		} elseif (!$status['userConfigPathExists']) {
+			$status['action'] = 'wizard';
+		} else {
+			$status['action'] = 'launch';
+			if ($action) {
+				echo '<script type="text/javascript"> window.location.href="' . $this->getServerPath() . 'api/v2/organizr/error' . '";</script>';
+				die(header($this->getServerPath() . 'api/v2/organizr/error'));
+				exit;
+			}
+		}
+		return $status;
+	}
+
+	public function launch()
 	{
 		$status = array();
 		$dependenciesActive = array();
 		$dependenciesInactive = array();
-		$extensions = array("PDO_SQLITE", "PDO", "SQLITE3", "zip", "cURL", "openssl", "simplexml", "json", "session", "filter");
-		$functions = array("hash", "fopen", "fsockopen", "fwrite", "fclose", "readfile");
+		$extensions = array('PDO_SQLITE', 'PDO', 'SQLITE3', 'zip', 'cURL', 'openssl', 'simplexml', 'json', 'session', 'filter');
+		$functions = array('hash', 'fopen', 'fsockopen', 'fwrite', 'fclose', 'readfile');
 		foreach ($extensions as $check) {
 			if (extension_loaded($check)) {
 				array_push($dependenciesActive, $check);
@@ -1118,12 +1190,12 @@ class Organizr
 			}
 		}
 		if (!file_exists($this->userConfigPath)) {
-			$status['status'] = "wizard";//wizard - ok for test
+			$status['status'] = 'wizard';//wizard - ok for test
 		}
 		if (count($dependenciesInactive) > 0 || !is_writable(dirname(__DIR__, 2)) || !(version_compare(PHP_VERSION, $this->minimumPHP) >= 0)) {
-			$status['status'] = "dependencies";
+			$status['status'] = 'dependencies';
 		}
-		$status['status'] = ($status['status']) ?? "ok";
+		$status['status'] = ($status['status']) ?? 'ok';
 		$status['writable'] = is_writable(dirname(__DIR__, 2)) ? 'yes' : 'no';
 		$status['minVersion'] = (version_compare(PHP_VERSION, $this->minimumPHP) >= 0) ? 'yes' : 'no';
 		$status['dependenciesActive'] = $dependenciesActive;
@@ -1532,8 +1604,8 @@ class Organizr
 				);
 			}
 		}
-		$dirname = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
-		$path = 'plugins/images/userTabs/';
+		$dirname = $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
+		$path = 'data/userTabs/';
 		$images = scandir($dirname);
 		foreach ($images as $image) {
 			if (!in_array($image, $ignore)) {
@@ -1586,11 +1658,12 @@ class Organizr
 			$this->setAPIResponse('error', 'No image supplied', 422);
 			return false;
 		}
-		$approvedPath = 'plugins/images/userTabs/';
+		$approvedPath = 'data/userTabs/';
 		$removeImage = $approvedPath . pathinfo($image, PATHINFO_BASENAME);
 		if ($this->approvedFileExtension($removeImage, 'image')) {
 			if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . $removeImage)) {
-				$this->writeLog('success', 'Image Manager Function -  Deleted Image [' . pathinfo($image, PATHINFO_BASENAME) . ']', $this->user['username']);
+				$this->setLoggerChannel('Image Manager');
+				$this->logger->info('Image Manager Function -  Deleted Image [' . pathinfo($image, PATHINFO_BASENAME) . ']');
 				$this->setAPIResponse(null, pathinfo($image, PATHINFO_BASENAME) . ' has been deleted', null);
 				return (unlink(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . $removeImage));
 			} else {
@@ -1610,7 +1683,7 @@ class Organizr
 			ini_set('upload_max_filesize', '10M');
 			ini_set('post_max_size', '10M');
 			$tempFile = $_FILES['file']['tmp_name'];
-			$targetPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
+			$targetPath = $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
 			$targetFile = $targetPath . $_FILES['file']['name'];
 			$this->setAPIResponse(null, pathinfo($_FILES['file']['name'], PATHINFO_BASENAME) . ' has been uploaded', null);
 			return move_uploaded_file($tempFile, $targetFile);
@@ -1768,9 +1841,9 @@ class Organizr
 									<li lang="en"><i class="fa fa-caret-right text-info"></i> Choose your image to use</li>
 									<li lang="en"><i class="fa fa-caret-right text-info"></i> Edit settings to your liking</li>
 									<li lang="en"><i class="fa fa-caret-right text-info"></i> At bottom of page on [Favicon Generator Options] under [Path] choose [I cannot or I do not want to place favicon files at the root of my web site.]</li>
-									<li lang="en"><i class="fa fa-caret-right text-info"></i> Enter this path <code>plugins/images/faviconCustom</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Enter this path <code>data/favicon</code></li>
 									<li lang="en"><i class="fa fa-caret-right text-info"></i> Click [Generate your Favicons and HTML code]</li>
-									<li lang="en"><i class="fa fa-caret-right text-info"></i> Download and unzip file and place in <code>plugins/images/faviconCustom</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Download and unzip file and place in <code>data/favicon</code></li>
 									<li lang="en"><i class="fa fa-caret-right text-info"></i> Copy code and paste inside left box</li>
 								</ul>
 							</div>
@@ -1888,6 +1961,7 @@ class Organizr
 				$this->settingsOption('code-editor', 'blacklistedMessage', ['mode' => 'html']),
 			],
 			'Logs' => [
+				$this->settingsOption('folder', 'logLocation', ['label' => 'Log Save Path', 'help' => 'Folder path to save Organizr Logs - Please test before saving', 'value' => $this->logLocation()]),
 				$this->settingsOption('select', 'logLevel', ['label' => 'Log Level', 'options' => $this->logLevels()]),
 				$this->settingsOption('switch', 'includeDatabaseQueriesInDebug', ['label' => 'Include Database Queries', 'help' => 'Include Database queries in debug logs']),
 				$this->settingsOption('number', 'maxLogFiles', ['label' => 'Maximum Log Files', 'help' => 'Number of log files to preserve', 'attr' => 'min="1"']),
@@ -2050,6 +2124,9 @@ class Organizr
 				$this->settingsOption('url', 'komgaURL'),
 				$this->settingsOption('auth', 'ssoKomgaAuth'),
 				$this->settingsOption('enable', 'ssoKomga'),
+				$this->settingsOption('blank'),
+				$this->settingsOption('username', 'komgaFallbackUser', ['label' => 'Komga Fallback Email', 'help' => 'DO NOT SET THIS TO YOUR ADMIN ACCOUNT. We recommend you create a local account as a "catch all" for when Organizr is unable to perform SSO.  Organizr will request a User Token based off of this user credentials']),
+				$this->settingsOption('password', 'komgaFallbackPassword', ['label' => 'Komga Fallback Password']),
 			],
 		];
 	}
@@ -2226,12 +2303,24 @@ class Organizr
 					}
 				}
 			}
+			switch ($k) {
+				case 'logLocation':
+				case 'dbLocation':
+					if (!empty($v)) {
+						$v = $this->cleanDirectory($v);
+					}
+					break;
+				default:
+					break;
+			}
 			if (strtolower($k) !== 'formkey') {
 				$newItem[$k] = $v;
 				$this->config[$k] = $v;
 			}
 		}
 		$this->setAPIResponse('success', 'Config items updated', 200);
+		$this->setLoggerChannel('Config');
+		$this->logger->info('Config items updated', array_keys($array));
 		return (bool)$this->updateConfig($newItem);
 	}
 
@@ -2753,15 +2842,16 @@ class Organizr
 			return false;
 		}
 		// Check if Auth Proxy is enabled
-		if ($this->config['authProxyEnabled'] && $this->config['authProxyHeaderName'] !== '' && $this->config['authProxyWhitelist'] !== '') {
-			if (isset($this->getallheaders()[$this->config['authProxyHeaderName']])) {
-				$usernameHeader = $this->getallheaders()[$this->config['authProxyHeaderName']] ?? $username;
+		if ($this->config['authProxyEnabled'] && ($this->config['authProxyHeaderName'] !== '' || $this->config['authProxyHeaderNameEmail'] !== '') && $this->config['authProxyWhitelist'] !== '') {
+			if (isset($this->getallheaders()[$this->config['authProxyHeaderName']]) || isset($this->getallheaders()[$this->config['authProxyHeaderNameEmail']])) {
+				$usernameHeader = $this->getallheaders()[$this->config['authProxyHeaderName']] ?? null;
 				$emailHeader = $this->getallheaders()[$this->config['authProxyHeaderNameEmail']] ?? null;
-				$this->setLoggerChannel('Authentication', $usernameHeader);
+				$headerForLogin = $usernameHeader ?: ($emailHeader ?: null);
+				$this->setLoggerChannel('Authentication', $headerForLogin);
 				$this->logger->debug('Starting Auth Proxy verification');
 				$whitelistRange = $this->analyzeIP($this->config['authProxyWhitelist']);
 				$authProxy = $this->authProxyRangeCheck($whitelistRange['from'], $whitelistRange['to']);
-				$username = ($authProxy) ? $usernameHeader : $username;
+				$username = ($authProxy) ? $headerForLogin : $username;
 				$password = ($password == null) ? $this->random_ascii_string(10) : $password;
 				$addEmailToAuthProxy = ($authProxy && $emailHeader) ? ['email' => $emailHeader] : true;
 				if ($authProxy) {
@@ -2958,7 +3048,8 @@ class Organizr
 		if ($isUser) {
 			$this->updateUserPassword($newPassword, $isUser['id']);
 			$this->setAPIResponse('success', 'User password has been reset', 200);
-			$this->writeLog('success', 'User Management Function - User: ' . $isUser['username'] . '\'s password was reset', $isUser['username']);
+			$this->setLoggerChannel('User Management');
+			$this->logger->info('User Management Function - User: ' . $isUser['username'] . '\'s password was reset');
 			if ($this->config['PHPMAILER-enabled']) {
 				$PhpMailer = new PhpMailer();
 				$emailTemplate = array(
@@ -3008,10 +3099,11 @@ class Organizr
 			$this->setAPIResponse('error', 'Registration Password was not supplied', 422);
 			return false;
 		}
+		$this->setLoggerChannel('User Registration');
 		if ($registrationPassword == $this->decrypt($this->config['registrationPassword'])) {
-			$this->writeLog('success', 'Registration Function - Registration Password Verified', $username);
+			$this->logger->debug('Registration Password Verified');
 			if ($this->createUser($username, $password, $email)) {
-				$this->writeLog('success', 'Registration Function - A User has registered', $username);
+				$this->logger->info('A User has registered');
 				if ($this->createToken($username, $email, $this->config['rememberMeDays'])) {
 					$this->setLoggerChannel('Authentication', $username);
 					$this->logger->info('User has logged in');
@@ -3021,7 +3113,7 @@ class Organizr
 				return false;
 			}
 		} else {
-			$this->writeLog('warning', 'Registration Function - Wrong Password', $username);
+			$this->logger->warning('Wrong Password');
 			$this->setAPIResponse('error', 'Registration Password was incorrect', 401);
 			return false;
 		}
@@ -3038,7 +3130,7 @@ class Organizr
 			$password = $this->random_ascii_string(10);
 		}
 		if ($this->createUser($username, $password, $email)) {
-			$this->writeLog('success', 'Registration Function - A User has registered', $username);
+			$this->logger->info('A User has registered');
 			if ($this->config['PHPMAILER-enabled'] && $email !== '') {
 				$PhpMailer = new PhpMailer();
 				$emailTemplate = array(
@@ -3059,14 +3151,13 @@ class Organizr
 				$PhpMailer->_phpMailerPluginSendEmail($sendEmail);
 			}
 			if ($this->createToken($username, $email, $this->config['rememberMeDays'])) {
-				$this->setLoggerChannel('Authentication', $username);
 				$this->logger->info('User has logged in');
 				return true;
 			} else {
 				return false;
 			}
 		} else {
-			$this->writeLog('error', 'Registration Function - An error occurred', $username);
+			$this->logger->warning('Registration error occurred');
 			return false;
 		}
 	}
@@ -3195,6 +3286,8 @@ class Organizr
 		$this->applyTabVariables($queries['tabs']);
 		$all['tabs'] = $queries['tabs'];
 		foreach ($queries['tabs'] as $k => $v) {
+			$v['url_local'] = $v['type'] !== 0 ? $this->checkTabURL($v['url_local']) : $v['url_local'];
+			$v['url'] = $v['type'] !== 0 ? $this->checkTabURL($v['url']) : $v['url'];
 			$v['access_url'] = (!empty($v['url_local']) && ($v['url_local'] !== null) && ($v['url_local'] !== 'null') && $this->isLocal() && $v['type'] !== 0) ? $v['url_local'] : $v['url'];
 		}
 		$count = array_map(function ($element) {
@@ -3202,7 +3295,7 @@ class Organizr
 		}, $queries['tabs']);
 		$count = (array_count_values($count));
 		foreach ($queries['categories'] as $k => $v) {
-			$v['count'] = isset($count[$v['category_id']]) ? $count[$v['category_id']] : 0;
+			$v['count'] = $count[$v['category_id']] ?? 0;
 		}
 		$all['categories'] = $queries['categories'];
 		switch ($type) {
@@ -3215,6 +3308,11 @@ class Organizr
 		}
 	}
 
+	public function checkTabURL($url = null)
+	{
+		return $url !== '' && $url !== null & $url !== 'null' ? $this->qualifyURL($url) : '';
+	}
+
 	public function refreshList()
 	{
 		$searchTerm = "Refresh";
@@ -3484,7 +3582,8 @@ class Organizr
 			if ($this->checkFormKey($formKey)) {
 				return true;
 			} else {
-				$this->writeLog('error', 'API ERROR: Unable to authenticate Form Key: ' . $formKey, $this->user['username']);
+				$this->setLoggerChannel('Authentication');
+				$this->logger->warning('Unable to authenticate Form Key: ' . $formKey);
 				return false;
 			}
 		} else {
@@ -3635,6 +3734,13 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
+				case 'homepageOrderDonate':
+					$class = 'bg-primary';
+					$image = 'plugins/images/tabs/donate.png';
+					if (!$this->config['homepageDonateEnabled']) {
+						$class .= ' faded';
+					}
+					break;
 				case 'homepageOrdercalendar':
 					$class = 'bg-primary';
 					$image = 'plugins/images/tabs/calendar.png';
@@ -3733,6 +3839,13 @@ class Organizr
 						$class .= ' faded';
 					}
 					break;
+				case 'homepageOrderBookmarks':
+					$class = 'bg-bookmarks';
+					$image = 'plugins/images/bookmark.png';
+					if (!$this->config['homepageBookmarksEnabled']) {
+						$class .= ' faded';
+					}
+					break;
 				default:
 					$class = 'blue-bg';
 					$image = '';
@@ -4176,7 +4289,8 @@ class Organizr
 		];
 		$tabInfo = $this->getTabById($id);
 		if ($tabInfo) {
-			$this->writeLog('success', 'Tab Delete Function -  Deleted Tab [' . $tabInfo['name'] . ']', $this->user['username']);
+			$this->setLoggerChannel('Tab Management');
+			$this->logger->debug('Deleted Tab [' . $tabInfo['name'] . ']');
 			$this->setAPIResponse('success', 'Tab deleted', 204);
 			return $this->processQueries($response);
 		} else {
@@ -4225,7 +4339,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Tab added');
-		$this->writeLog('success', 'Tab Editor Function -  Added Tab for [' . $array['name'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Tab Management');
+		$this->logger->debug('Added Tab for [' . $array['name'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -4269,7 +4384,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Tab info updated');
-		$this->writeLog('success', 'Tab Editor Function -  Edited Tab Info for [' . $tabInfo['name'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Tab Management');
+		$this->logger->debug('Edited Tab Info for [' . $tabInfo['name'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -4338,7 +4454,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Category added');
-		$this->writeLog('success', 'Category Editor Function -  Added Category for [' . $array['category'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Category Management');
+		$this->logger->debug('Added Category for [' . $array['category'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -4382,7 +4499,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Category info updated');
-		$this->writeLog('success', 'Category Editor Function -  Edited Category Info for [' . $categoryInfo['category'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Category Management');
+		$this->logger->debug('Edited Category [' . $categoryInfo['category'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -4432,7 +4550,8 @@ class Organizr
 		];
 		$categoryInfo = $this->getCategoryById($id);
 		if ($categoryInfo) {
-			$this->writeLog('success', 'Category Delete Function -  Deleted Category [' . $categoryInfo['category'] . ']', $this->user['username']);
+			$this->setLoggerChannel('Category Management');
+			$this->logger->debug('Deleted Category [' . $categoryInfo['category'] . ']');
 			$this->setAPIResponse('success', 'Category deleted', 204);
 			return $this->processQueries($response);
 		} else {
@@ -4514,7 +4633,8 @@ class Organizr
 				'path' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $this->root . $v['path'])
 			);
 			if (!$this->rrmdir($file['to'])) {
-				$this->writeLog('error', 'Theme Function -  Remove File Failed  for: ' . $v['githubPath'], $this->user['username']);
+				$this->setLoggerChannel('Theme Management');
+				$this->logger->warning('Remove File Failed  for: ' . $v['githubPath']);
 				return false;
 			}
 		}
@@ -4572,7 +4692,8 @@ class Organizr
 				'path' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $this->root . $v['path'])
 			);
 			if (!$this->downloadFileToPath($file['from'], $file['to'], $file['path'])) {
-				$this->writeLog('error', 'Theme Function -  Downloaded File Failed  for: ' . $v['githubPath'], $this->user['username']);
+				$this->setLoggerChannel('Theme Management');
+				$this->logger->warning('Downloaded File Failed  for: ' . $v['githubPath']);
 				$this->setAPIResponse('error', 'Theme download failed', 500);
 				return false;
 			}
@@ -4611,11 +4732,12 @@ class Organizr
 			if ($v['type'] !== 'dir') {
 				$filesList[] = array(
 					'fileName' => $v['name'],
-					'path' => DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR . str_replace($v['name'], '', $v['path']),
+					'path' => $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR . str_replace($v['name'], '', $v['path']),
 					'githubPath' => $v['download_url']
 				);
 			}
 		}
+		$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $folder);
 		return $filesList;
 	}
 
@@ -4757,9 +4879,10 @@ class Organizr
 		foreach ($downloadList as $k => $v) {
 			$file = array(
 				'from' => $v['githubPath'],
-				'to' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $this->root . $v['path'] . $v['fileName']),
-				'path' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $this->root . $v['path'])
+				'to' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $v['path'] . $v['fileName']),
+				'path' => str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $v['path'])
 			);
+			$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins');
 			if (!$this->downloadFileToPath($file['from'], $file['to'], $file['path'])) {
 				$this->setLoggerChannel('Plugin Marketplace');
 				$this->logger->warning('Downloaded File Failed  for: ' . $v['githubPath']);
@@ -4790,7 +4913,7 @@ class Organizr
 			$plugin = array_keys($array)[$key];
 		}
 		$array = $array[$plugin];
-		$pluginDir = $this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $array['project_folder'] . DIRECTORY_SEPARATOR;
+		$pluginDir = $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . $array['project_folder'] . DIRECTORY_SEPARATOR;
 		$dirExists = file_exists($pluginDir);
 		if ($dirExists) {
 			if (!$this->rrmdir($pluginDir)) {
@@ -5109,10 +5232,11 @@ class Organizr
 		set_time_limit(0);
 		$zip = new ZipArchive;
 		$extractPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . "upgrade/";
+		$this->setLoggerChannel('File Management');
 		if ($zip->open($extractPath . $zipFile) != "true") {
-			$this->writeLog("error", "organizr could not unzip upgrade.zip");
+			$this->logger->warning('organizr could not unzip upgrade.zip');
 		} else {
-			$this->writeLog("success", "organizr unzipped upgrade.zip");
+			$this->logger->debug('organizr unzipped upgrade.zip');
 		}
 		/* Extract Zip File */
 		$zip->extractTo($extractPath);
@@ -5125,9 +5249,10 @@ class Organizr
 		ini_set('max_execution_time', 0);
 		set_time_limit(0);
 		$folderPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . "upgrade" . DIRECTORY_SEPARATOR;
+		$this->setLoggerChannel('File Management');
 		if (!file_exists($folderPath)) {
 			if (@!mkdir($folderPath)) {
-				$this->writeLog('error', 'Update Function -  Folder Creation failed', $this->user['username']);
+				$this->logger->warning('Folder Creation failed');
 				return false;
 			}
 		}
@@ -5149,21 +5274,21 @@ class Organizr
 				}
 			}
 		} else {
-			$this->writeLog("error", "organizr could not download $url");
+			$this->logger->warning('Organizr could not download ' . $url);
 			return false;
 		}
 		if ($file) {
 			fclose($file);
-			$this->writeLog("success", "organizr finished downloading the github zip file");
+			$this->logger->debug('Organizr finished downloading the github zip file');
 		} else {
-			$this->writeLog("error", "organizr could not download the github zip file");
+			$this->logger->warning('Organizr could not download the github zip file');
 			return false;
 		}
 		if ($newf) {
 			fclose($newf);
-			$this->writeLog("success", "organizr created upgrade zip file from github zip file");
+			$this->logger->debug('Organizr created upgrade zip file from github zip file');
 		} else {
-			$this->writeLog("error", "organizr could not create upgrade zip file from github zip file");
+			$this->logger->warning('Organizr could not create upgrade zip file from github zip file');
 			return false;
 		}
 		return true;
@@ -5189,9 +5314,8 @@ class Organizr
 		}
 		ini_set('max_execution_time', 0);
 		set_time_limit(0);
-		if (@!mkdir($path, 0777, true)) {
-			$this->writeLog("error", "organizr could not create folder or folder already exists", 'SYSTEM');
-		}
+
+		$this->makeDir($path);
 		$file = fopen($from, 'rb', false, $context);
 		if ($file) {
 			$newf = fopen($to, 'wb', false, $context);
@@ -5201,19 +5325,19 @@ class Organizr
 				}
 			}
 		} else {
-			$this->writeLog("error", "organizr could not download file", 'SYSTEM');
+			$this->logger->warning('Organizr could not download file');
 		}
 		if ($file) {
 			fclose($file);
-			$this->writeLog("success", "organizr finished downloading the file", 'SYSTEM');
+			$this->logger->debug('Organizr finished downloading the file');
 		} else {
-			$this->writeLog("error", "organizr could not download the file", 'SYSTEM');
+			$this->logger->warning('Organizr could not download file');
 		}
 		if ($newf) {
 			fclose($newf);
-			$this->writeLog("success", "organizr saved/moved the file", 'SYSTEM');
+			$this->logger->debug('Organizr saved and/or moved the file');
 		} else {
-			$this->writeLog("error", "organizr could not saved/moved the file", 'SYSTEM');
+			$this->logger->warning('Organizr could not save and/or move the file');
 		}
 		return true;
 	}
@@ -5271,14 +5395,17 @@ class Organizr
 	public function importUsers($array)
 	{
 		$imported = 0;
-		foreach ($array as $user) {
-			$password = $this->random_ascii_string(30);
-			if ($user['username'] !== '' && $user['email'] !== '' && $password !== '') {
-				$newUser = $this->createUser($user['username'], $password, $user['email']);
-				if (!$newUser) {
-					$this->writeLog('error', 'Import Function - Error', $user['username']);
-				} else {
-					$imported++;
+		if ($array) {
+			foreach ($array as $user) {
+				$password = $this->random_ascii_string(30);
+				if ($user['username'] !== '' && $user['email'] !== '' && $password !== '') {
+					$newUser = $this->createUser($user['username'], $password, $user['email']);
+					if (!$newUser) {
+						$this->setLoggerChannel('User Management');
+						$this->logger->warning('An error occurred during user import');
+					} else {
+						$imported++;
+					}
 				}
 			}
 		}
@@ -5359,7 +5486,8 @@ class Organizr
 			}
 			return false;
 		} catch (Requests_Exception $e) {
-			$this->writeLog('success', 'Plex Import User Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setLoggerChannel('User Management');
+			$this->logger->error($e);
 		}
 		return false;
 	}
@@ -5399,7 +5527,8 @@ class Organizr
 			}
 			return false;
 		} catch (Requests_Exception $e) {
-			$this->writeLog('success', 'Jellyfin Import User Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setLoggerChannel('User Management');
+			$this->logger->error($e);
 		}
 		return false;
 	}
@@ -5439,7 +5568,8 @@ class Organizr
 			}
 			return false;
 		} catch (Requests_Exception $e) {
-			$this->writeLog('success', 'Emby Import User Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setLoggerChannel('User Management');
+			$this->logger->error($e);
 		}
 		return false;
 	}
@@ -5536,7 +5666,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'User info updated');
-		$this->writeLog('success', 'User Editor Function -  Updated User Info for [' . $user['username'] . ']', $this->user['username']);
+		$this->setLoggerChannel('User Management');
+		$this->logger->info('Updated User Info for [' . $user['username'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -5557,7 +5688,8 @@ class Organizr
 			return false;
 		}
 		if ($userInfo) {
-			$this->writeLog('success', 'User Delete Function -  Deleted User [' . $userInfo['username'] . ']', $this->user['username']);
+			$this->setLoggerChannel('User Management');
+			$this->logger->info('Deleted User [' . $userInfo['username'] . ']');
 			$this->setAPIResponse('success', 'User deleted', 204);
 			return $this->processQueries($response);
 		} else {
@@ -5579,11 +5711,12 @@ class Organizr
 			$this->setAPIResponse('error', 'Password was not supplied', 409);
 			return false;
 		}
+		$this->setLoggerChannel('User Management');
 		if ($this->createUser($username, $password, $email)) {
-			$this->writeLog('success', 'Create User Function - Account created for [' . $username . ']', $this->user['username']);
+			$this->logger->info('Account created for [' . $username . ']');
 			return true;
 		} else {
-			$this->writeLog('error', 'Create User Function - An error occurred', $this->user['username']);
+			$this->logger->warning('An error occurred');
 			return false;
 		}
 	}
@@ -5687,7 +5820,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Group info updated');
-		$this->writeLog('success', 'Group Editor Function -  Edited Group Info for [' . $groupInfo['group'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Group Management');
+		$this->logger->info('Edited Group Info for [' . $groupInfo['group'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -5712,7 +5846,8 @@ class Organizr
 			return false;
 		}
 		if ($groupInfo) {
-			$this->writeLog('success', 'Group Delete Function -  Deleted Group [' . $groupInfo['group'] . ']', $this->user['username']);
+			$this->setLoggerChannel('Group Management');
+			$this->logger->info('Deleted Group [' . $groupInfo['group'] . ']');
 			$this->setAPIResponse('success', 'Group deleted', 204);
 			return $this->processQueries($response);
 		} else {
@@ -5758,7 +5893,8 @@ class Organizr
 			),
 		];
 		$this->setAPIResponse(null, 'Group added');
-		$this->writeLog('success', 'Group Editor Function -  Added Group for [' . $array['group'] . ']', $this->user['username']);
+		$this->setLoggerChannel('Group Management');
+		$this->logger->info('Added Group for [' . $array['group'] . ']');
 		return $this->processQueries($response);
 	}
 
@@ -5791,7 +5927,8 @@ class Organizr
 							return $libraryList;
 						}
 					} catch (Requests_Exception $e) {
-						$this->writeLog('error', 'Plex Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+						$this->setLoggerChannel('User Management');
+						$this->logger->error($e);
 					}
 				}
 				break;
@@ -5854,12 +5991,12 @@ class Organizr
 
 	public function hasCustomCert()
 	{
-		return file_exists(dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem');
+		return file_exists($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem');
 	}
 
 	public function getCustomCert()
 	{
-		return ($this->hasCustomCert()) ? dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem' : false;
+		return ($this->hasCustomCert()) ? $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem' : false;
 	}
 
 	public function uploadCert()
@@ -5869,9 +6006,10 @@ class Organizr
 			ini_set('upload_max_filesize', '10M');
 			ini_set('post_max_size', '10M');
 			$tempFile = $_FILES['file']['tmp_name'];
-			$targetPath = dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR;
+			$targetPath = $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR;
 			$targetFile = $targetPath . 'custom.pem';
 			$this->setAPIResponse(null, pathinfo($_FILES['file']['name'], PATHINFO_BASENAME) . ' has been uploaded', null);
+			$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert');
 			return move_uploaded_file($tempFile, $targetFile);
 		} else {
 			$this->setAPIResponse('error', pathinfo($_FILES['file']['name'], PATHINFO_BASENAME) . ' is not approved to be uploaded', 403);
@@ -5947,7 +6085,8 @@ class Organizr
 			$this->setAPIResponse($status, $msg, $code);
 			return (!empty($success) && empty($errors));
 		} catch (Requests_Exception $e) {
-			$this->writeLog('error', 'Plex.TV Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setLoggerChannel('User Management');
+			$this->logger->error($e);
 			$this->setAPIResponse('error', 'An Error Occurred', 409);
 			return false;
 		}
@@ -5982,7 +6121,8 @@ class Organizr
 				)
 			),
 		];
-		$this->writeLog('success', 'User Lockout Function - User: ' . $user['username'] . ' account locked', $this->user['username']);
+		$this->setLoggerChannel('User Management');
+		$this->logger->info('User: ' . $user['username'] . ' account locked');
 		$this->setAPIResponse('success', 'User account locked', 200);
 		return $this->processQueries($response);
 	}
@@ -6019,7 +6159,8 @@ class Organizr
 				)
 			),
 		];
-		$this->writeLog('success', 'User Lockout Function - User: ' . $user['username'] . ' account unlocked', $this->user['username']);
+		$this->setLoggerChannel('User Management');
+		$this->logger->info('User: ' . $user['username'] . ' account unlocked');
 		$this->setAPIResponse('success', 'User account unlocked', 200);
 		return $this->processQueries($response);
 	}
@@ -6333,7 +6474,7 @@ class Organizr
 			$url = $this->cleanPath($url);
 			$options = ($this->localURL($appURL)) ? array('verify' => false, 'timeout' => 120) : array('timeout' => 120);
 			$headers = [];
-			$apiData = $this->json_validator($this->apiData($requestObject)) ? json_encode($this->apiData($requestObject)) : $this->apiData($requestObject);
+			$apiData = $this->apiData($requestObject, false);
 			if ($header) {
 				if ($requestObject->hasHeader($header)) {
 					$headerKey = $requestObject->getHeaderLine($header);
@@ -6351,7 +6492,7 @@ class Organizr
 				'headers' => $headers,
 				'url' => $url,
 				'options' => $options,
-				'data' => $apiData
+				'data' => $apiData,
 			];
 			$this->setLoggerChannel('Socks');
 			$this->logger->debug('Sending Socks request', $debugInformation);
@@ -6429,7 +6570,8 @@ class Organizr
 				return $items;
 			}
 		} catch (Requests_Exception $e) {
-			$this->writeLog('success', 'Plex Get Servers Function - Error: ' . $e->getMessage(), 'SYSTEM');
+			$this->setLoggerChannel('Plex Connection');
+			$this->logger->error($e);
 			$this->setAPIResponse('error', $e->getMessage(), 500);
 		}
 	}
@@ -6529,6 +6671,23 @@ class Organizr
 		}
 	}
 
+	public function testFolder($folder = null)
+	{
+		$folder = $folder['folder'] ?? null;
+		if (!$folder) {
+			$this->setResponse(409, 'Folder was not supplied');
+			return false;
+		}
+		$testFolder = $this->makeDir($folder);
+		if ($testFolder) {
+			$this->setResponse(200, 'Folder approved for logs');
+			return true;
+		} else {
+			$this->setResponse(409, 'Folder path is not valid or permissions insufficient');
+			return false;
+		}
+	}
+
 	protected function processQueries(array $request, $migration = false)
 	{
 		$results = array();

+ 3 - 1
api/composer.json

@@ -19,6 +19,8 @@
     "paquettg/php-html-parser": "^3.1",
     "nekonomokochan/php-json-logger": "^1.3",
     "bcremer/line-reader": "^1.1",
-    "peppeocchi/php-cron-scheduler": "^4.0"
+    "peppeocchi/php-cron-scheduler": "^4.0",
+    "simshaun/recurr": "^5.0",
+    "stripe/stripe-php": "^7.116"
   }
 }

+ 199 - 11
api/composer.lock

@@ -4,34 +4,34 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "6b969731ec087add680dbdf2c9829d92",
+    "content-hash": "3f2f3b854484a2014e64d2327a1b8c29",
     "packages": [
         {
             "name": "adldap2/adldap2",
-            "version": "v10.3.3",
+            "version": "v10.4.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Adldap2/Adldap2.git",
-                "reference": "c2a8f72455d3438377d955fc0f4b9ed836b47463"
+                "reference": "81aeb283f56e216ae925a9cc4241de56b1fd4453"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/c2a8f72455d3438377d955fc0f4b9ed836b47463",
-                "reference": "c2a8f72455d3438377d955fc0f4b9ed836b47463",
+                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/81aeb283f56e216ae925a9cc4241de56b1fd4453",
+                "reference": "81aeb283f56e216ae925a9cc4241de56b1fd4453",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "ext-ldap": "*",
-                "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0",
+                "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0|~9.0",
                 "php": ">=7.0",
-                "psr/log": "~1.0",
-                "psr/simple-cache": "~1.0",
+                "psr/log": "~1.0|~2.0|~3.0",
+                "psr/simple-cache": "~1.0|~2.0",
                 "tightenco/collect": "~5.0|~6.0|~7.0|~8.0"
             },
             "require-dev": {
                 "mockery/mockery": "~1.0",
-                "phpunit/phpunit": "~6.0|~7.0|~8.0"
+                "symfony/phpunit-bridge": "~5.2|~6.0"
             },
             "suggest": {
                 "ext-fileinfo": "fileinfo is required when retrieving user encoded thumbnails"
@@ -69,7 +69,7 @@
                 "issues": "https://github.com/Adldap2/Adldap2/issues",
                 "source": "https://github.com/Adldap2/Adldap2"
             },
-            "time": "2021-08-09T15:22:35+00:00"
+            "time": "2022-02-09T13:54:20+00:00"
         },
         {
             "name": "bcremer/line-reader",
@@ -389,6 +389,75 @@
             ],
             "time": "2020-05-25T17:24:27+00:00"
         },
+        {
+            "name": "doctrine/collections",
+            "version": "1.6.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/collections.git",
+                "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/collections/zipball/1958a744696c6bb3bb0d28db2611dc11610e78af",
+                "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9.0",
+                "phpstan/phpstan": "^0.12",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.1.5",
+                "vimeo/psalm": "^4.2.1"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                },
+                {
+                    "name": "Johannes Schmitt",
+                    "email": "schmittjoh@gmail.com"
+                }
+            ],
+            "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.",
+            "homepage": "https://www.doctrine-project.org/projects/collections.html",
+            "keywords": [
+                "array",
+                "collections",
+                "iterators",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/collections/issues",
+                "source": "https://github.com/doctrine/collections/tree/1.6.8"
+            },
+            "time": "2021-08-10T18:51:53+00:00"
+        },
         {
             "name": "doctrine/lexer",
             "version": "1.2.1",
@@ -2637,6 +2706,65 @@
             },
             "time": "2021-04-27T11:05:25+00:00"
         },
+        {
+            "name": "simshaun/recurr",
+            "version": "v5.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/simshaun/recurr.git",
+                "reference": "b5aa5b07a595023b67a558b810390dfa7160e3f5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/simshaun/recurr/zipball/b5aa5b07a595023b67a558b810390dfa7160e3f5",
+                "reference": "b5aa5b07a595023b67a558b810390dfa7160e3f5",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/collections": "~1.6",
+                "php": "^7.2||^8.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.16",
+                "symfony/yaml": "^5.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Recurr\\": "src/Recurr/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Shaun Simmons",
+                    "email": "shaun@shaun.pub",
+                    "homepage": "https://shaun.pub"
+                }
+            ],
+            "description": "PHP library for working with recurrence rules",
+            "homepage": "https://github.com/simshaun/recurr",
+            "keywords": [
+                "dates",
+                "events",
+                "recurrence",
+                "recurring",
+                "rrule"
+            ],
+            "support": {
+                "issues": "https://github.com/simshaun/recurr/issues",
+                "source": "https://github.com/simshaun/recurr/tree/v5.0.0"
+            },
+            "time": "2021-09-09T03:42:57+00:00"
+        },
         {
             "name": "slim/psr7",
             "version": "1.3.0",
@@ -2832,6 +2960,66 @@
             ],
             "time": "2020-12-01T19:41:22+00:00"
         },
+        {
+            "name": "stripe/stripe-php",
+            "version": "v7.116.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/stripe/stripe-php.git",
+                "reference": "7a39f594f213ed3f443a95adf769d1ecbc8393e7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/stripe/stripe-php/zipball/7a39f594f213ed3f443a95adf769d1ecbc8393e7",
+                "reference": "7a39f594f213ed3f443a95adf769d1ecbc8393e7",
+                "shasum": ""
+            },
+            "require": {
+                "ext-curl": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "php": ">=5.6.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "3.5.0",
+                "phpstan/phpstan": "^1.2",
+                "phpunit/phpunit": "^5.7 || ^9.0",
+                "squizlabs/php_codesniffer": "^3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Stripe\\": "lib/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Stripe and contributors",
+                    "homepage": "https://github.com/stripe/stripe-php/contributors"
+                }
+            ],
+            "description": "Stripe PHP Library",
+            "homepage": "https://stripe.com/",
+            "keywords": [
+                "api",
+                "payment processing",
+                "stripe"
+            ],
+            "support": {
+                "issues": "https://github.com/stripe/stripe-php/issues",
+                "source": "https://github.com/stripe/stripe-php/tree/v7.116.0"
+            },
+            "time": "2022-03-02T15:51:15+00:00"
+        },
         {
             "name": "symfony/deprecation-contracts",
             "version": "v2.1.3",
@@ -3676,5 +3864,5 @@
     "prefer-lowest": false,
     "platform": [],
     "platform-dev": [],
-    "plugin-api-version": "2.0.0"
+    "plugin-api-version": "2.2.0"
 }

+ 27 - 1
api/config/default.php

@@ -81,8 +81,13 @@ return [
 	'ssoKomga' => false,
 	'ssoKomgaAuth' => '4',
 	'komgaURL' => '',
+	'komgaFallbackUser' => '',
+	'komgaFallbackPassword' => '',
 	'sonarrURL' => '',
 	'sonarrUnmonitored' => false,
+	'sonarrIcon' => true,
+	'sonarrCalendarLink' => '',
+	'sonarrFrameTarget' => '',
 	'sonarrToken' => '',
 	'sonarrSocksEnabled' => false,
 	'sonarrSocksAuth' => '999',
@@ -94,8 +99,14 @@ return [
 	'lidarrSocksAuth' => '999',
 	'lidarrDisableCertCheck' => false,
 	'lidarrUseCustomCertificate' => false,
+	'lidarrIcon' => true,
+	'lidarrCalendarLink' => '',
+	'lidarrFrameTarget' => '',
 	'radarrURL' => '',
 	'radarrUnmonitored' => false,
+	'radarrIcon' => true,
+	'radarrCalendarLink' => '',
+	'radarrFrameTarget' => '',
 	'radarrPhysicalRelease' => true,
 	'radarrDigitalRelease' => false,
 	'radarrCinemaRelease' => false,
@@ -147,6 +158,7 @@ return [
 	'delugePassword' => '',
 	'delugeHideSeeding' => false,
 	'delugeHideCompleted' => false,
+	'delugeHideStatus' => true,
 	'delugeCombine' => false,
 	'delugeRefresh' => '60000',
 	'delugeDisableCertCheck' => false,
@@ -296,6 +308,15 @@ return [
 	'homepageHealthChecksShowTags' => false,
 	'healthChecksDisableCertCheck' => false,
 	'healthChecksUseCustomCertificate' => false,
+	'homepageDonateEnabled' => false,
+	'homepageDonateAuth' => '999',
+	'homepageDonatePublicToken' => '',
+	'homepageDonateSecretToken' => '',
+	'homepageDonateProductID' => '',
+	'homepageDonateCustomizeHeading' => 'Donate Please',
+	'homepageDonateCustomizeDescription' => 'Hi!  Help me out with a donation...',
+	'homepageDonateMinimum' => '100',
+	'homepageDonateShowUserHistory' => false,
 	'homepageOrdercustomhtml01' => '1',
 	'homepageOrdercustomhtml02' => '2',
 	'homepageOrdertransmission' => '3',
@@ -335,6 +356,8 @@ return [
 	'homepageOrdercustomhtml08' => '37',
 	'homepageOrderuTorrent' => '38',
 	'homepageOrderoverseerr' => '39',
+	'homepageOrderBookmarks' => '40',
+	'homepageOrderDonate' => '41',
 	'homepageShowStreamNames' => false,
 	'homepageShowStreamNamesAuth' => '1',
 	'homepageUseCustomStreamNames' => false,
@@ -363,6 +386,9 @@ return [
 	'homepageJellyfinRecent' => false,
 	'homepageJellyfinRecentAuth' => '1',
 	'homepageJellyfinLink' => 'http://hostname:port/jellyfin/web/index.html#!/details?id={id}&serverId={serverId}',
+	'homepageBookmarksAuth' => '1',
+	'homepageBookmarksEnabled' => false,
+	'homepageBookmarksRefresh' => '3600000',
 	'calendarDefault' => 'month',
 	'calendarFirstDay' => '1',
 	'calendarStart' => '14',
@@ -622,4 +648,4 @@ return [
 	'checkForPluginUpdate' => true,
 	'autoUpdateCronEnabled' => false,
 	'autoUpdateCronSchedule' => '@weekly'
-];
+];

+ 16 - 2
api/functions.php

@@ -18,9 +18,12 @@ foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . "*
 	require_once $filename;
 }
 // Include all custom pages files
-foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . "*.php") as $filename) {
-	require_once $filename;
+if (file_exists(dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'pages')) {
+	foreach (glob(dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
+		require_once $filename;
+	}
 }
+
 // Include all plugin files
 $folder = __DIR__ . DIRECTORY_SEPARATOR . 'plugins';
 $directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
@@ -29,4 +32,15 @@ foreach ($iteratorIterator as $info) {
 	if ($info->getFilename() == 'plugin.php' || strpos($info->getFilename(), 'page.php') !== false || $info->getFilename() == 'cron.php') {
 		require_once $info->getPathname();
 	}
+}
+// Include all custom plugin files
+if (file_exists(dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins')) {
+	$folder = dirname(__DIR__, 1) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins';
+	$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
+	$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
+	foreach ($iteratorIterator as $info) {
+		if ($info->getFilename() == 'plugin.php' || strpos($info->getFilename(), 'page.php') !== false || $info->getFilename() == 'cron.php') {
+			require_once $info->getPathname();
+		}
+	}
 }

+ 1 - 1
api/functions/demo-functions.php

@@ -13,7 +13,7 @@ trait DemoFunctions
 		if (file_exists($path)) {
 			$data = file_get_contents($path);
 			$path = (strpos($file, '/') !== false) ? explode('/', $file)[0] . '/' : '';
-			$data = $this->userDefinedIdReplacementLink($data, ['plugins/images/cache/' => 'api/demo_data/' . $path . 'images/']);
+			$data = $this->userDefinedIdReplacementLink($data, ['data/cache/' => 'api/demo_data/' . $path . 'images/']);
 			$data = json_decode($data, true);
 			$this->setResponse(200, 'Demo data for file: ' . $file, $data['response']['data']);
 			return $data['response']['data'];

+ 34 - 25
api/functions/log-functions.php

@@ -2,71 +2,77 @@
 
 trait LogFunctions
 {
+	public function logLocation()
+	{
+		return isset($this->config['logLocation']) && $this->config['logLocation'] !== '' ? $this->config['logLocation'] : $this->config['dbLocation'] . 'logs' . DIRECTORY_SEPARATOR;
+	}
+
 	public function debug($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->debug($msg, $context);
 		}
 	}
-	
+
 	public function info($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->info($msg, $context);
 		}
 	}
-	
+
 	public function notice($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->notice($msg, $context);
 		}
 	}
-	
+
 	public function warning($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->warning($msg, $context);
 		}
 	}
-	
+
 	public function error($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->error($msg, $context);
 		}
 	}
-	
+
 	public function critical($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->critical($msg, $context);
 		}
 	}
-	
+
 	public function alert($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->alert($msg, $context);
 		}
 	}
-	
+
 	public function emergency($msg, $context = [])
 	{
 		if ($this->logger) {
 			$this->logger->emergency($msg, $context);
 		}
 	}
-	
+
 	public function setOrganizrLog()
 	{
 		if ($this->hasDB()) {
-			$logPath = $this->config['dbLocation'] . 'logs' . DIRECTORY_SEPARATOR;
+			$this->makeDir($this->logLocation());
+			$logPath = $this->logLocation();
 			return $logPath . 'organizr.log';
 		}
 		return false;
 	}
-	
+
 	public function readLog($file, $pageSize = 10, $offset = 0, $filter = 'NONE', $trace_id = null)
 	{
 		$combinedLogs = false;
@@ -126,7 +132,7 @@ trait LogFunctions
 		}
 		return false;
 	}
-	
+
 	public function formatLogResults($lines, $pageSize, $offset)
 	{
 		if (is_array($lines)) {
@@ -150,12 +156,12 @@ trait LogFunctions
 			return json_decode($lines, true);
 		}
 	}
-	
+
 	public function getLatestLogFile()
 	{
 		if ($this->log) {
 			if (isset($this->log)) {
-				$folder = $this->config['dbLocation'] . 'logs' . DIRECTORY_SEPARATOR;
+				$folder = $this->logLocation();
 				$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
 				$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
 				$files = [];
@@ -176,12 +182,12 @@ trait LogFunctions
 		}
 		return false;
 	}
-	
+
 	public function getLogFiles()
 	{
 		if ($this->log) {
 			if (isset($this->log)) {
-				$folder = $this->config['dbLocation'] . 'logs' . DIRECTORY_SEPARATOR;
+				$folder = $this->logLocation();
 				$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
 				$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
 				$files = [];
@@ -200,7 +206,7 @@ trait LogFunctions
 		}
 		return false;
 	}
-	
+
 	public function setLoggerChannel($channel = 'Organizr', $username = null)
 	{
 		if ($this->hasDB()) {
@@ -221,11 +227,13 @@ trait LogFunctions
 			}
 			if ($setLogger) {
 				$channel = $channel ?: 'Organizr';
-				$this->setupLogger($channel, $username);
+				return $this->setupLogger($channel, $username);
+			} else {
+				return $this->logger;
 			}
 		}
 	}
-	
+
 	public function setupLogger($channel = 'Organizr', $username = null)
 	{
 		if (!$username) {
@@ -266,9 +274,11 @@ trait LogFunctions
 		$loggerBuilder->setLogLevel($logLevel);
 		try {
 			$this->logger = $loggerBuilder->build();
+			return $this->logger;
 		} catch (Exception $e) {
 			// nothing so far
 			$this->logger = null;
+			return $this->logger;
 		}
 		/* setup:
 		set the log channel before you send log (You can set an optional Username (2nd Variable) | If user is logged already logged in, it will use their username):
@@ -280,9 +290,8 @@ trait LogFunctions
 		exception:
 		$this->logger->critical($exception, $context);
 		*/
-		
 	}
-	
+
 	public function tempLogIfNeeded()
 	{
 		if (!$this->log) {
@@ -291,7 +300,7 @@ trait LogFunctions
 			return $this->log;
 		}
 	}
-	
+
 	public function getLog($pageSize = 10, $offset = 0, $filter = 'NONE', $number = 0, $trace_id = null)
 	{
 		if ($this->log) {
@@ -319,7 +328,7 @@ trait LogFunctions
 			return false;
 		}
 	}
-	
+
 	public function purgeLog($number)
 	{
 		$this->setLoggerChannel('Logger');
@@ -366,7 +375,7 @@ trait LogFunctions
 			return false;
 		}
 	}
-	
+
 	public function logArray($context)
 	{
 		if (!is_array($context)) {
@@ -380,7 +389,7 @@ trait LogFunctions
 			return $context;
 		}
 	}
-	
+
 	function buildLogDropdown()
 	{
 		$logs = $this->getLogFiles();
@@ -400,7 +409,7 @@ trait LogFunctions
 		}
 		return false;
 	}
-	
+
 	function buildFilterDropdown()
 	{
 		$dropdownItems = '<li><a href="javascript:toggleLogFilter(\'DEBUG\')"><span lang="en">Debug</span></a></li>';

+ 47 - 42
api/functions/normal-functions.php

@@ -27,12 +27,12 @@ trait NormalFunctions
 		//return $timeExtra[0] . 's ' . (number_format(('0.' . substr($timeExtra[1], 0, 4)), 4, '.', '') * 1000) . 'ms';
 		//return (number_format(('0.' . substr($timeExtra[1], 0, 4)), 4, '.', '') * 1000) . 'ms';
 	}
-	
+
 	public function getExtension($string)
 	{
 		return preg_replace("#(.+)?\.(\w+)(\?.+)?#", "$2", $string);
 	}
-	
+
 	public function get_browser_name()
 	{
 		$user_agent = $_SERVER['HTTP_USER_AGENT'];
@@ -51,13 +51,13 @@ trait NormalFunctions
 		}
 		return 'Other';
 	}
-	
+
 	public function array_filter_key(array $array, $callback)
 	{
 		$matchedKeys = array_filter(array_keys($array), $callback);
 		return array_intersect_key($array, array_flip($matchedKeys));
 	}
-	
+
 	public function getOS()
 	{
 		if (PHP_SHLIB_SUFFIX == "dll") {
@@ -66,14 +66,14 @@ trait NormalFunctions
 			return "*nix";
 		}
 	}
-	
+
 	// Get Gravatar Email Image
 	public function gravatar($email = '')
 	{
 		$email = md5(strtolower(trim($email)));
 		return "https://www.gravatar.com/avatar/$email?s=100&d=mm";
 	}
-	
+
 	// Clean Directory string
 	public function cleanDirectory($path)
 	{
@@ -86,14 +86,14 @@ trait NormalFunctions
 		}
 		return $path;
 	}
-	
+
 	// Print output all purrty
 	public function prettyPrint($v)
 	{
 		$trace = debug_backtrace()[0];
 		echo '<pre style="white-space: pre; text-overflow: ellipsis; overflow: hidden; background-color: #f2f2f2; border: 2px solid black; border-radius: 5px; padding: 5px; margin: 5px;">' . $trace['file'] . ':' . $trace['line'] . ' ' . gettype($v) . "\n\n" . print_r($v, 1) . '</pre><br/>';
 	}
-	
+
 	public function gen_uuid()
 	{
 		return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
@@ -112,19 +112,19 @@ trait NormalFunctions
 			mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
 		);
 	}
-	
+
 	public function dbExtension($string)
 	{
 		return (substr($string, -3) == '.db') ? $string : $string . '.db';
 	}
-	
+
 	public function cleanPath($path)
 	{
 		$path = preg_replace('/([^:])(\/{2,})/', '$1/', $path);
 		$path = rtrim($path, '/');
 		return $path;
 	}
-	
+
 	public function searchArray($array, $field, $value)
 	{
 		foreach ($array as $key => $item) {
@@ -133,7 +133,7 @@ trait NormalFunctions
 		}
 		return false;
 	}
-	
+
 	public function localURL($url, $force = false)
 	{
 		if ($force) {
@@ -146,7 +146,7 @@ trait NormalFunctions
 		}
 		return false;
 	}
-	
+
 	public function arrayIP($string)
 	{
 		if (strpos($string, ',') !== false) {
@@ -159,7 +159,7 @@ trait NormalFunctions
 		}
 		return $result;
 	}
-	
+
 	public function timeExecution($previous = null)
 	{
 		if (!$previous) {
@@ -168,7 +168,7 @@ trait NormalFunctions
 			return (microtime(true) - $_SERVER["REQUEST_TIME_FLOAT"]) - $previous;
 		}
 	}
-	
+
 	public function getallheaders()
 	{
 		if (!function_exists('getallheaders')) {
@@ -186,7 +186,7 @@ trait NormalFunctions
 			return getallheaders();
 		}
 	}
-	
+
 	public function random_ascii_string($length)
 	{
 		$characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
@@ -197,7 +197,7 @@ trait NormalFunctions
 		}
 		return $randomString;
 	}
-	
+
 	// Generate Random string
 	public function randString($length = 10, $chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ')
 	{
@@ -207,7 +207,7 @@ trait NormalFunctions
 		}
 		return $tmp;
 	}
-	
+
 	public function isEncrypted($password)
 	{
 		switch (strlen($password)) {
@@ -223,7 +223,7 @@ trait NormalFunctions
 				return false;
 		}
 	}
-	
+
 	public function fillString($string, $length)
 	{
 		$filler = '0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*';
@@ -237,7 +237,7 @@ trait NormalFunctions
 			return $string;
 		}
 	}
-	
+
 	public function userIP()
 	{
 		if (isset($_SERVER['HTTP_CLIENT_IP'])) {
@@ -263,7 +263,7 @@ trait NormalFunctions
 			return $ipaddress;
 		}
 	}
-	
+
 	public function serverIP()
 	{
 		if (array_key_exists('SERVER_ADDR', $_SERVER)) {
@@ -271,7 +271,7 @@ trait NormalFunctions
 		}
 		return '127.0.0.1';
 	}
-	
+
 	public function parseDomain($value, $force = false)
 	{
 		$badDomains = array('ddns.net', 'ddnsking.com', '3utilities.com', 'bounceme.net', 'freedynamicdns.net', 'freedynamicdns.org', 'gotdns.ch', 'hopto.org', 'myddns.me', 'myds.me', 'myftp.biz', 'myftp.org', 'myvnc.com', 'noip.com', 'onthewifi.com', 'redirectme.net', 'serveblog.net', 'servecounterstrike.com', 'serveftp.com', 'servegame.com', 'servehalflife.com', 'servehttp.com', 'serveirc.com', 'serveminecraft.net', 'servemp3.com', 'servepics.com', 'servequake.com', 'sytes.net', 'viewdns.net', 'webhop.me', 'zapto.org');
@@ -309,7 +309,7 @@ trait NormalFunctions
 		}
 		return ($force) ? $value : $Domain;
 	}
-	
+
 	// Cookie Custom Function
 	public function coookie($type, $name, $value = '', $days = -1, $http = true, $path = '/')
 	{
@@ -360,7 +360,7 @@ trait NormalFunctions
 				. (!$HTTPOnly ? '' : '; HttpOnly'), false);
 		}
 	}
-	
+
 	public function coookieSeconds($type, $name, $value = '', $ms = null, $http = true, $path = '/')
 	{
 		if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == "https") {
@@ -409,13 +409,13 @@ trait NormalFunctions
 				. (!$HTTPOnly ? '' : '; HttpOnly'), false);
 		}
 	}
-	
+
 	// Qualify URL
 	public function qualifyURL($url, $return = false)
 	{
 		//local address?
 		if (substr($url, 0, 1) == "/") {
-			if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') {
+			if ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off') || (isset($_SERVER['HTTP_X_FORWARDED_SSL']) && $_SERVER['HTTP_X_FORWARDED_SSL'] != 'off') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] != 'http')) {
 				$protocol = "https://";
 			} else {
 				$protocol = "http://";
@@ -448,7 +448,7 @@ trait NormalFunctions
 		);
 		return ($return) ? $array : $scheme . '://' . $host . $port . $path . $query;
 	}
-	
+
 	public function getServer($over = false)
 	{
 		if ($over) {
@@ -458,7 +458,7 @@ trait NormalFunctions
 		}
 		return isset($_SERVER["HTTP_HOST"]) ? $_SERVER["HTTP_HOST"] : $_SERVER["SERVER_NAME"];
 	}
-	
+
 	public function getServerPath($over = false)
 	{
 		if ($over) {
@@ -497,7 +497,7 @@ trait NormalFunctions
 			return $url;
 		}
 	}
-	
+
 	public function convertIPToRange($ip)
 	{
 		if (strpos($ip, '/') !== false) {
@@ -518,7 +518,7 @@ trait NormalFunctions
 			'to' => $last_ip_long
 		];
 	}
-	
+
 	public function localIPRanges()
 	{
 		$mainArray = array(
@@ -571,7 +571,7 @@ trait NormalFunctions
 		*/
 		return $mainArray;
 	}
-	
+
 	public function isLocal($checkIP = null)
 	{
 		$isLocal = false;
@@ -586,7 +586,7 @@ trait NormalFunctions
 		}
 		return $isLocal;
 	}
-	
+
 	public function isLocalOrServer()
 	{
 		$isLocalOrServer = false;
@@ -600,7 +600,7 @@ trait NormalFunctions
 		}
 		return $isLocalOrServer;
 	}
-	
+
 	public function human_filesize($bytes, $dec = 2)
 	{
 		$bytes = number_format($bytes, 0, '.', '');
@@ -608,7 +608,7 @@ trait NormalFunctions
 		$factor = floor((strlen($bytes) - 1) / 3);
 		return sprintf("%.{$dec}f %s", $bytes / (1024 ** $factor), $size[$factor]);
 	}
-	
+
 	public function apiResponseFormatter($response)
 	{
 		if (is_array($response)) {
@@ -622,7 +622,7 @@ trait NormalFunctions
 		}
 		return ['api_response' => 'No data'];
 	}
-	
+
 	public function json_validator($data = null)
 	{
 		if (!empty($data)) {
@@ -631,12 +631,12 @@ trait NormalFunctions
 		}
 		return false;
 	}
-	
+
 	public function replace_first($search_str, $replacement_str, $src_str)
 	{
 		return (false !== ($pos = strpos($src_str, $search_str))) ? substr_replace($src_str, $replacement_str, $pos, strlen($search_str)) : $src_str;
 	}
-	
+
 	/**
 	 *  Check if an array is a multidimensional array.
 	 *
@@ -648,7 +648,7 @@ trait NormalFunctions
 		if (count(array_filter($x, 'is_array')) > 0) return true;
 		return false;
 	}
-	
+
 	/**
 	 *  Convert an object to an array.
 	 *
@@ -660,13 +660,13 @@ trait NormalFunctions
 		if (!is_object($object) && !is_array($object)) return $object;
 		return array_map(array($this, 'object_to_array'), (array)$object);
 	}
-	
+
 	/**
 	 *  Check if a value exists in the array/object.
 	 *
-	 * @param mixed   $needle   The value that you are searching for
-	 * @param mixed   $haystack The array/object to search
-	 * @param boolean $strict   Whether to use strict search or not
+	 * @param mixed $needle The value that you are searching for
+	 * @param mixed $haystack The array/object to search
+	 * @param boolean $strict Whether to use strict search or not
 	 * @return  boolean             Whether the value was found or not
 	 */
 	public function search_for_value($needle, $haystack, $strict = true)
@@ -698,6 +698,11 @@ trait NormalFunctions
 		}
 		return false;
 	}
+
+	public function makeDir($dirPath, $mode = 0777)
+	{
+		return is_dir($dirPath) || @mkdir($dirPath, $mode, true);
+	}
 }
 
 // Leave for deluge class

+ 98 - 27
api/functions/option-functions.php

@@ -14,7 +14,7 @@ trait OptionsFunction
 		}
 		return $settings;
 	}
-	
+
 	public function settingsOption($type, $name = null, $extras = null)
 	{
 		$type = strtolower(str_replace('-', '', $type));
@@ -100,6 +100,14 @@ trait OptionsFunction
 					'placeholder' => '* * * * *'
 				];
 				break;
+			case 'folder':
+				$settingMerge = [
+					'type' => 'folder',
+					'label' => 'Save Path',
+					'help' => 'Folder path',
+					'placeholder' => '/path/to/folder'
+				];
+				break;
 			case 'cronfile':
 				$path = $this->root . DIRECTORY_SEPARATOR . 'cron.php';
 				$server = $this->serverIP();
@@ -279,6 +287,12 @@ trait OptionsFunction
 					'label' => 'Hide Completed',
 				];
 				break;
+			case 'hidestatus':
+				$settingMerge = [
+					'type' => 'switch',
+					'label' => 'Hide Status',
+				];
+				break;
 			case 'limit':
 				$settingMerge = [
 					'type' => 'number',
@@ -376,7 +390,7 @@ trait OptionsFunction
 						' . $name . '.session.setMode(new mode());
 						' . $name . '.setTheme("ace/theme/idle_fingers");
 						' . $name . '.setShowPrintMargin(false);
-						' . $name . '.session.on("change", function(delta) { $(".' . $name . 'Textarea").val(' . $name . '.getValue()) });
+						' . $name . '.session.on("change", function(delta) { $(".' . $name . 'Textarea").val(' . $name . '.getValue()); $(".' . $name . 'Textarea").trigger("change") });
 					</script>
 					'
 				];
@@ -439,6 +453,22 @@ trait OptionsFunction
 					'attr' => 'data-original="' . $this->config[$name] . '"'
 				];
 				break;
+			case 'calendarlinkurl':
+				$settingMerge = [
+					'type' => 'select',
+					'label' => 'Target URL',
+					'help' => 'Set the primary URL used when clicking on calendar icon.',
+					'options' => $this->makeOptionsFromValues($this->config[str_replace('CalendarLink','',$name).'URL'], true, 'Use Default'),
+				];
+				break;
+			case 'calendarframetarget':
+				$settingMerge = [
+					'type' => 'select',
+					'label' => 'Target Tab',
+					'help' => 'Set the tab used when clicking on calendar icon. If not set, link will open in new window.',
+					'options' => $this->getIframeTabs($this->config[str_replace('FrameTarget','CalendarLink',$name)])
+				];
+				break;
 			default:
 				$settingMerge = [
 					'type' => strtolower($type),
@@ -455,9 +485,52 @@ trait OptionsFunction
 		return $setting;
 	}
 	
-	public function makeOptionsFromValues($values = null)
+	public function getIframeTabs($url = "")
+	{	
+		if (!empty($url)){
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						"SELECT * FROM tabs WHERE `enabled`='1' AND `type`='1' AND `group_id`>=? AND (`url` = '" . $url . "' OR `url_local` = '" . $url . "') ORDER BY `order` ASC",
+						$this->getUserLevel(),
+					)
+				)
+			];
+		} else {
+			$response = [
+				array(
+					'function' => 'fetchAll',
+					'query' => array(
+						"SELECT * FROM tabs WHERE `enabled`='1' AND `type`='1' AND `group_id`>=? ORDER BY `order` ASC",
+						$this->getUserLevel()
+					)
+				)
+			];
+		}
+		$formattedValues[] = [
+			'name' => 'Open in New Window',
+			'value' => ''
+		];
+		foreach($this->processQueries($response) as $result) {
+			$formattedValues[] = [
+				'name' => $result['name'],
+				'value' => $result['name']
+			];
+		}
+		return $formattedValues;
+	}
+
+	public function makeOptionsFromValues($values = null, $appendBlank = null, $blankLabel = null)
 	{
-		$formattedValues = [];
+		if ($appendBlank === true){
+			$formattedValues[] = [
+				'name' => (!empty($blankLabel)) ? $blankLabel : 'Select option...',
+				'value' => ''
+			];
+		} else {
+			$formattedValues = [];
+		}
 		if (strpos($values, ',') !== false) {
 			$explode = explode(',', $values);
 			foreach ($explode as $item) {
@@ -476,7 +549,7 @@ trait OptionsFunction
 		}
 		return $formattedValues;
 	}
-	
+
 	public function logLevels()
 	{
 		return [
@@ -514,7 +587,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function sandboxOptions()
 	{
 		return [
@@ -568,7 +641,7 @@ trait OptionsFunction
 			],
 		];
 	}
-	
+
 	public function calendarLocaleOptions()
 	{
 		return [
@@ -765,9 +838,8 @@ trait OptionsFunction
 				'name' => 'Chinese (Taiwan)'
 			]
 		];
-		
 	}
-	
+
 	public function daysOptions()
 	{
 		return array(
@@ -801,7 +873,7 @@ trait OptionsFunction
 			)
 		);
 	}
-	
+
 	public function mediaServerOptions()
 	{
 		return array(
@@ -819,7 +891,7 @@ trait OptionsFunction
 			)
 		);
 	}
-	
+
 	public function requestTvOptions($includeUserOption = false)
 	{
 		$options = [
@@ -845,7 +917,7 @@ trait OptionsFunction
 		}
 		return $options;
 	}
-	
+
 	public function requestServiceOptions()
 	{
 		return [
@@ -859,7 +931,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function limitOptions()
 	{
 		return array(
@@ -901,7 +973,7 @@ trait OptionsFunction
 			),
 		);
 	}
-	
+
 	public function notificationTypesOptions()
 	{
 		return array(
@@ -923,7 +995,7 @@ trait OptionsFunction
 			),
 		);
 	}
-	
+
 	public function notificationPositionsOptions()
 	{
 		return array(
@@ -957,7 +1029,7 @@ trait OptionsFunction
 			),
 		);
 	}
-	
+
 	public function timeOptions()
 	{
 		return array(
@@ -1006,9 +1078,8 @@ trait OptionsFunction
 				'value' => '3600000'
 			),
 		);
-		
 	}
-	
+
 	public function netdataOptions()
 	{
 		return [
@@ -1054,7 +1125,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function netdataChartOptions()
 	{
 		return [
@@ -1068,7 +1139,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function netdataColourOptions()
 	{
 		return [
@@ -1098,7 +1169,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function netdataSizeOptions()
 	{
 		return [
@@ -1116,7 +1187,7 @@ trait OptionsFunction
 			]
 		];
 	}
-	
+
 	public function timeFormatOptions()
 	{
 		return array(
@@ -1150,7 +1221,7 @@ trait OptionsFunction
 			)
 		);
 	}
-	
+
 	public function rTorrentSortOptions()
 	{
 		return array(
@@ -1204,7 +1275,7 @@ trait OptionsFunction
 			),
 		);
 	}
-	
+
 	public function qBittorrentApiOptions()
 	{
 		return array(
@@ -1218,7 +1289,7 @@ trait OptionsFunction
 			),
 		);
 	}
-	
+
 	public function qBittorrentSortOptions()
 	{
 		return array(
@@ -1284,7 +1355,7 @@ trait OptionsFunction
 			)
 		);
 	}
-	
+
 	public function calendarDefaultOptions()
 	{
 		return array(
@@ -1306,4 +1377,4 @@ trait OptionsFunction
 			)
 		);
 	}
-}
+}

+ 59 - 51
api/functions/organizr-functions.php

@@ -6,7 +6,7 @@ trait OrganizrFunctions
 	{
 		return 'https://organizr.gitbook.io/organizr/' . $path;
 	}
-	
+
 	public function loadResources($files = [], $rootPath = '')
 	{
 		$scripts = '';
@@ -21,17 +21,17 @@ trait OrganizrFunctions
 		}
 		return $scripts;
 	}
-	
+
 	public function loadJavaResource($file = '', $rootPath = '')
 	{
 		return ($file !== '') ? '<script src="' . $rootPath . $file . '?v=' . trim($this->fileHash) . '"></script>' . "\n" : '';
 	}
-	
+
 	public function loadStyleResource($file = '', $rootPath = '')
 	{
 		return ($file !== '') ? '<link href="' . $rootPath . $file . '?v=' . trim($this->fileHash) . '" rel="stylesheet">' . "\n" : '';
 	}
-	
+
 	public function loadDefaultJavascriptFiles()
 	{
 		$javaFiles = [
@@ -54,12 +54,12 @@ trait OrganizrFunctions
 		}
 		return $scripts;
 	}
-	
+
 	public function loadJavascriptFile($file)
 	{
 		return '<script>loadJavascript("' . $file . '?v=' . trim($this->fileHash) . '");' . "</script>\n";
 	}
-	
+
 	public function embyJoinAPI($array)
 	{
 		$username = ($array['username']) ?? null;
@@ -79,7 +79,7 @@ trait OrganizrFunctions
 		}
 		return $this->embyJoin($username, $email, $password);
 	}
-	
+
 	public function embyJoin($username, $email, $password)
 	{
 		try {
@@ -162,7 +162,7 @@ trait OrganizrFunctions
 			return false;
 		}
 	}
-	
+
 	/*loads users from emby and returns a correctly formated policy for a new user.
 	*/
 	public function getEmbyTemplateUserJson()
@@ -198,7 +198,7 @@ trait OrganizrFunctions
 		unset($policy['DisablePremiumFeatures']);
 		return (json_encode($policy));
 	}
-	
+
 	public function checkHostPrefix($s)
 	{
 		if (empty($s)) {
@@ -206,7 +206,7 @@ trait OrganizrFunctions
 		}
 		return (substr($s, -1, 1) == '\\') ? $s : $s . '\\';
 	}
-	
+
 	public function approvedFileExtension($filename, $type = 'image')
 	{
 		$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
@@ -229,9 +229,8 @@ trait OrganizrFunctions
 					return false;
 			}
 		}
-		
 	}
-	
+
 	public function getImages()
 	{
 		$allIconsPrep = array();
@@ -248,8 +247,8 @@ trait OrganizrFunctions
 				);
 			}
 		}
-		$dirname = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
-		$path = 'plugins/images/userTabs/';
+		$dirname = $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs' . DIRECTORY_SEPARATOR;
+		$path = 'data/userTabs/';
 		$images = scandir($dirname);
 		foreach ($images as $image) {
 			if (!in_array($image, $ignore)) {
@@ -265,7 +264,7 @@ trait OrganizrFunctions
 		}
 		return $allIcons;
 	}
-	
+
 	public function imageSelect($form)
 	{
 		$i = 1;
@@ -277,7 +276,7 @@ trait OrganizrFunctions
 		}
 		return $return . '</select>';
 	}
-	
+
 	public function getThemes()
 	{
 		$themes = array();
@@ -289,7 +288,7 @@ trait OrganizrFunctions
 		}
 		return $themes;
 	}
-	
+
 	public function getSounds()
 	{
 		$sounds = array();
@@ -307,7 +306,7 @@ trait OrganizrFunctions
 		}
 		return $sounds;
 	}
-	
+
 	public function getBranches()
 	{
 		return array(
@@ -321,7 +320,7 @@ trait OrganizrFunctions
 			)
 		);
 	}
-	
+
 	public function getSettingsTabs()
 	{
 		return array(
@@ -351,7 +350,7 @@ trait OrganizrFunctions
 			)
 		);
 	}
-	
+
 	public function getAuthTypes()
 	{
 		return array(
@@ -369,7 +368,7 @@ trait OrganizrFunctions
 			)
 		);
 	}
-	
+
 	public function getLDAPOptions()
 	{
 		return array(
@@ -387,7 +386,7 @@ trait OrganizrFunctions
 			),
 		);
 	}
-	
+
 	public function getAuthBackends()
 	{
 		$backendOptions = array();
@@ -426,7 +425,7 @@ trait OrganizrFunctions
 		ksort($backendOptions);
 		return $backendOptions;
 	}
-	
+
 	public function importUserButtons()
 	{
 		$emptyButtons = '
@@ -447,11 +446,11 @@ trait OrganizrFunctions
 		}
 		return ($buttons !== '') ? $buttons : $emptyButtons;
 	}
-	
+
 	public function getHomepageMediaImage()
 	{
 		$refresh = false;
-		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 		if (!file_exists($cacheDirectory)) {
 			mkdir($cacheDirectory, 0777, true);
 		}
@@ -522,10 +521,10 @@ trait OrganizrFunctions
 			die($this->showHTML('Invalid Request', 'No image returned'));
 		}
 	}
-	
+
 	public function cacheImage($url, $name, $extension = 'jpg')
 	{
-		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 		if (!file_exists($cacheDirectory)) {
 			mkdir($cacheDirectory, 0777, true);
 		}
@@ -535,7 +534,7 @@ trait OrganizrFunctions
 			@copy($url, $cacheFile);
 		}
 	}
-	
+
 	public function checkFrame($array, $url)
 	{
 		if (array_key_exists("x-frame-options", $array)) {
@@ -574,7 +573,7 @@ trait OrganizrFunctions
 			return true;
 		}
 	}
-	
+
 	public function frameTest($url)
 	{
 		if (!$url || $url == '') {
@@ -591,7 +590,7 @@ trait OrganizrFunctions
 			return false;
 		}
 	}
-	
+
 	public function groupSelect()
 	{
 		$groups = $this->getAllGroups();
@@ -604,24 +603,24 @@ trait OrganizrFunctions
 		}
 		return $select;
 	}
-	
+
 	public function showLogin()
 	{
 		if ($this->config['hideRegistration'] == false) {
 			return '<p><span lang="en">Don\'t have an account?</span><a href="#" class="text-primary m-l-5 to-register"><b lang="en">Sign Up</b></a></p>';
 		}
 	}
-	
+
 	public function checkoAuth()
 	{
 		return $this->config['plexoAuth'] && $this->config['authBackend'] == 'plex' && $this->config['authType'] !== 'internal';
 	}
-	
+
 	public function checkoAuthOnly()
 	{
 		return $this->config['plexoAuth'] && $this->config['authBackend'] == 'plex' && $this->config['authType'] == 'external';
 	}
-	
+
 	public function showoAuth()
 	{
 		$buttons = '';
@@ -648,7 +647,7 @@ trait OrganizrFunctions
         </div>
 	' : '';
 	}
-	
+
 	public function logoOrText()
 	{
 		$showLogo = $this->config['minimalLoginScreen'] ? '' : 'visible-xs';
@@ -659,13 +658,13 @@ trait OrganizrFunctions
 		}
 		return '<a href="javascript:void(0)" class="text-center db ' . $showLogo . '" id="login-logo">' . $html . '</a>';
 	}
-	
+
 	public function settingsDocker()
 	{
 		$type = ($this->docker) ? 'Official Docker' : 'Native';
 		return '<li><div class="bg-info"><i class="mdi mdi-flag mdi-24px text-white"></i></div><span class="text-muted hidden-xs m-t-10" lang="en">Install Type</span> ' . $type . '</li>';
 	}
-	
+
 	public function settingsPathChecks()
 	{
 		$paths = $this->pathsWritable($this->paths);
@@ -677,7 +676,7 @@ trait OrganizrFunctions
 		}
 		return $result . $items;
 	}
-	
+
 	public function pathsWritable($paths)
 	{
 		$results = array();
@@ -689,7 +688,7 @@ trait OrganizrFunctions
 		}
 		return $results;
 	}
-	
+
 	public function clearTautulliTokens()
 	{
 		foreach (array_keys($_COOKIE) as $k => $v) {
@@ -698,7 +697,7 @@ trait OrganizrFunctions
 			}
 		}
 	}
-	
+
 	public function clearJellyfinTokens()
 	{
 		foreach (array_keys($_COOKIE) as $k => $v) {
@@ -708,7 +707,7 @@ trait OrganizrFunctions
 		}
 		$this->coookie('delete', 'jellyfin_credentials');
 	}
-	
+
 	public function clearKomgaToken()
 	{
 		if (isset($_COOKIE['komga_token'])) {
@@ -727,7 +726,7 @@ trait OrganizrFunctions
 			$this->coookie('delete', 'komga_token');
 		}
 	}
-	
+
 	public function analyzeIP($ip)
 	{
 		if (strpos($ip, '/') !== false) {
@@ -743,7 +742,7 @@ trait OrganizrFunctions
 		}
 		return (isset($start_ip_long) && isset($last_ip_long)) ? array('from' => $start_ip_long, 'to' => $last_ip_long) : false;
 	}
-	
+
 	public function authProxyRangeCheck($from, $to)
 	{
 		$approved = false;
@@ -753,14 +752,15 @@ trait OrganizrFunctions
 		if ($userIP <= $high && $low <= $userIP) {
 			$approved = true;
 		}
+		$this->logger->debug('authProxy range check', ['server_ip' => $userIP, 'range_from' => $from, 'range_to' => $to, 'approved' => $approved]);
 		return $approved;
 	}
-	
+
 	public function userDefinedIdReplacementLink($link, $variables)
 	{
 		return strtr($link, $variables);
 	}
-	
+
 	public function requestOptions($url, $timeout = null, $override = false, $customCertificate = false, $extras = null)
 	{
 		$options = [];
@@ -785,9 +785,11 @@ trait OrganizrFunctions
 		}
 		return $options;
 	}
-	
-	public function showHTML(string $title = 'Organizr Alert', string $notice = '')
+
+	public function showHTML(string $title = 'Organizr Alert', string $notice = '', bool $autoClose = false)
 	{
+		$close = $autoClose ? 'onLoad="setTimeout(\'closemyself()\',3000);"' : '';
+		$closeMessage = $autoClose ? '<p><sup>(This window will close automatically)</sup></p>' : '';
 		return
 			'<!DOCTYPE html>
 			<html lang="en">
@@ -798,20 +800,26 @@ trait OrganizrFunctions
 				<meta name="viewport" content="width=device-width, initial-scale=1.0">
 				<title>' . $title . '</title>
 			</head>
-
-			<body>
+			<script language=javascript>
+					function closemyself() {
+						window.opener=self;
+						window.close();
+					}
+			</script>
+			<body ' . $close . '>
 				<main>
 					<section>
 						<aside>
 							<h3>' . $title . '</h3>
 							<p>' . $notice . '</p>
+							' . $closeMessage . '
 						</aside>
 					</section>
 				</main>
 			</body>
 			</html>';
 	}
-	
+
 	public function buildSettingsMenus($menuItems, $menuName)
 	{
 		$selectMenuItems = '';
@@ -834,7 +842,7 @@ trait OrganizrFunctions
 		$unorderedListMenu = '<ul class="nav customtab2 nav-tabs nav-non-mobile hidden-xs" data-dropdown="' . $menuNameLower . '-menu" role="tablist">' . $unorderedListMenuItems . '</ul>';
 		return $selectMenu . $unorderedListMenu;
 	}
-	
+
 	public function isJSON($string)
 	{
 		return is_string($string) && is_array(json_decode($string, true)) && (json_last_error() == JSON_ERROR_NONE);

+ 26 - 14
api/functions/sso-functions.php

@@ -21,7 +21,7 @@ trait SSOFunctions
 		}
 		return $cookies;
 	}
-	
+
 	public function getSSOUserFor($app, $userobj)
 	{
 		$map = array(
@@ -34,11 +34,11 @@ trait SSOFunctions
 		);
 		return (gettype($userobj) == 'string') ? $userobj : $userobj[$map[$app]];
 	}
-	
+
 	public function ssoCheck($userobj, $password, $token = null)
 	{
 		$this->setCurrentUser(false);
-		$this->setLoggerChannel('Authentication', $userobj['username']);
+		$this->setLoggerChannel('Authentication', $this->user['username']);
 		$this->logger->debug('Starting SSO check function');
 		if ($this->config['ssoPlex'] && $token) {
 			$this->logger->debug('Setting Plex SSO cookie');
@@ -103,7 +103,8 @@ trait SSOFunctions
 		}
 		if ($this->config['ssoKomga'] && $this->qualifyRequest($this->config['ssoKomgaAuth'])) {
 			$this->logger->debug('Starting Komga SSO check function');
-			$komga = $this->getKomgaToken($this->getSSOUserFor('komga', $userobj), $password);
+			$fallback = ($this->config['komgaFallbackUser'] !== '' && $this->config['komgaFallbackPassword'] !== '');
+			$komga = $this->getKomgaToken($this->getSSOUserFor('komga', $userobj), $password, $fallback);
 			if ($komga) {
 				$this->logger->debug('Setting Komga SSO cookie');
 				$this->coookie('set', 'komga_token', $komga, $this->config['rememberMeDays'], false);
@@ -113,9 +114,10 @@ trait SSOFunctions
 		}
 		return true;
 	}
-	
-	public function getKomgaToken($email, $password)
+
+	public function getKomgaToken($email, $password, $fallback = false)
 	{
+		$token = null;
 		try {
 			$credentials = array('auth' => new Requests_Auth_Digest(array($email, $password)));
 			$url = $this->qualifyURL($this->config['komgaURL']);
@@ -124,19 +126,29 @@ trait SSOFunctions
 			if ($response->success) {
 				if ($response->headers['x-auth-token']) {
 					$this->writeLog('success', 'Komga Token Function - Grabbed token.', $email);
-					return $response->headers['x-auth-token'];
+					$token = $response->headers['x-auth-token'];
 				} else {
 					$this->writeLog('error', 'Komga Token Function - Komga did not return Token', $email);
 				}
 			} else {
-				$this->writeLog('error', 'Komga Token Function - Komga did not return Token', $email);
+				if ($fallback) {
+					$this->writeLog('error', 'Komga Token Function - Komga did not return Token - Will retry using fallback credentials', $email);
+				} else {
+					$this->writeLog('error', 'Komga Token Function - Komga did not return Token', $email);
+				}
 			}
 		} catch (Requests_Exception $e) {
 			$this->writeLog('error', 'Komga Token Function - Error: ' . $e->getMessage(), $email);
 		}
-		return false;
+		if ($token) {
+			return $token;
+		} elseif ($fallback) {
+			return $this->getKomgaToken($this->config['komgaFallbackUser'], $this->decrypt($this->config['komgaFallbackPassword']), false);
+		} else {
+			return false;
+		}
 	}
-	
+
 	public function getJellyfinToken($username, $password)
 	{
 		$token = null;
@@ -171,7 +183,7 @@ trait SSOFunctions
 		}
 		return false;
 	}
-	
+
 	public function getOmbiToken($username, $password, $oAuthToken = null, $fallback = false)
 	{
 		$token = null;
@@ -212,7 +224,7 @@ trait SSOFunctions
 			return false;
 		}
 	}
-	
+
 	public function getTautulliToken($username, $password, $plexToken = null)
 	{
 		$token = null;
@@ -252,7 +264,7 @@ trait SSOFunctions
 		}
 		return ($token) ? $token : false;
 	}
-	
+
 	public function getOverseerrToken($email, $password, $oAuthToken = null, $fallback = false)
 	{
 		$token = null;
@@ -292,7 +304,7 @@ trait SSOFunctions
 			return false;
 		}
 	}
-	
+
 	public function getPetioToken($username, $password, $oAuthToken = null, $fallback = false)
 	{
 		$token = null;

+ 119 - 10
api/functions/upgrade-functions.php

@@ -72,6 +72,14 @@ trait UpgradeFunctions
 				$this->upgradeToVersion($versionCheck);
 			}
 			// End Upgrade check start for version above
+			// Upgrade check start for version below
+			$versionCheck = '2.1.1500';
+			if ($compare->lessThan($oldVer, $versionCheck)) {
+				$updateDB = false;
+				$oldVer = $versionCheck;
+				$this->upgradeToVersion($versionCheck);
+			}
+			// End Upgrade check start for version above
 			if ($updateDB == true) {
 				//return 'Upgraded Needed - Current Version '.$oldVer.' - New Version: '.$versionCheck;
 				// Upgrade database to latest version
@@ -89,7 +97,7 @@ trait UpgradeFunctions
 			return true;
 		}
 	}
-	
+
 	public function addColumnToDatabase($table = '', $columnName = '', $definition = 'TEXT')
 	{
 		if ($table == '' || $columnName == '' || $definition == '') {
@@ -130,7 +138,7 @@ trait UpgradeFunctions
 		}
 		return false;
 	}
-	
+
 	public function updateDB($oldVerNum = false)
 	{
 		$tempLock = $this->config['dbLocation'] . 'DBLOCK.txt';
@@ -204,7 +212,6 @@ trait UpgradeFunctions
 				}
 				@unlink($tempLock);
 				return false;
-				
 			} else {
 				$this->writeLog('error', 'Update Function -  Could not create migration DB', 'Database');
 			}
@@ -213,7 +220,7 @@ trait UpgradeFunctions
 		}
 		return false;
 	}
-	
+
 	public function upgradeToVersion($version = '2.1.0')
 	{
 		switch ($version) {
@@ -226,12 +233,114 @@ trait UpgradeFunctions
 				$this->removeOldCustomHTML();
 			case '2.1.860':
 				$this->upgradeInstalledPluginsConfigItem();
+			case '2.1.1500':
+				$this->upgradeDataToFolder();
 			default:
 				$this->setAPIResponse('success', 'Ran update function for version: ' . $version, 200);
 				return true;
 		}
 	}
-	
+
+	public function removeOldCacheFolder()
+	{
+		$folder = $this->root . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$this->setLoggerChannel('Migration');
+		$this->logger->info('Running Old Cache folder migration');
+		if (file_exists($folder)) {
+			$this->rrmdir($folder);
+			$this->logger->info('Old Cache folder found');
+			$this->logger->info('Removed Old Cache folder');
+		}
+		return true;
+	}
+
+	public function upgradeDataToFolder()
+	{
+		if ($this->hasDB()) {
+			// Make main data folder
+			$rootFolderMade = $this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data');
+			// Make config folder child
+			$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR);
+
+			if ($rootFolderMade) {
+				// Migrate over userTabs folder
+				$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs');
+				if ($this->rcopy($this->root . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'userTabs', $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'userTabs')) {
+					// Convert tabs over
+					$query = [
+						[
+							'function' => 'fetchAll',
+							'query' => [
+								'SELECT * FROM tabs WHERE image like "%userTabs%"'
+							]
+						],
+					];
+					$tabs = $this->processQueries($query);
+					if (count($tabs) > 0) {
+						foreach ($tabs as $tab) {
+							$newImage = str_replace('plugins/images/userTabs', 'data/userTabs', $tab['image']);
+							$updateQuery = [
+								[
+									'function' => 'query',
+									'query' => [
+										'UPDATE tabs SET',
+										['image' => $newImage],
+										'WHERE id = ?',
+										$tab['id']
+									]
+								],
+							];
+							$this->processQueries($updateQuery);
+						}
+					}
+					$this->setLoggerChannel('Migration');
+					$this->logger->info('The folder "userTabs" was migrated to new data folder');
+				}
+				// Migrate over custom cert
+				if (file_exists($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem')) {
+					// Make cert folder child
+					$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR);
+					if ($this->rcopy($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem', $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cert' . DIRECTORY_SEPARATOR . 'custom.pem')) {
+						$this->setLoggerChannel('Migration');
+						$this->logger->info('Moved over custom cert file');
+					}
+				}
+				// Migrate over favIcon
+				$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'favicon');
+				if ($this->rcopy($this->root . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'faviconCustom', $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'favicon')) {
+					if ($this->config['favIcon'] !== '') {
+						$this->config['favIcon'] = str_replace('plugins/images/faviconCustom', 'data/favicon', $this->config['favIcon']);
+						$this->updateConfig(array('favIcon' => $this->config['favIcon']));
+					}
+					$this->setLoggerChannel('Migration');
+					$this->logger->info('Favicon was migrated over');
+				}
+				// Migrate over custom pages
+				$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'pages');
+				if (file_exists($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . 'custom')) {
+					if ($this->rcopy($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . 'custom', $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'pages')) {
+						$this->rrmdir($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'pages' . DIRECTORY_SEPARATOR . 'custom');
+						$this->setLoggerChannel('Migration');
+						$this->logger->info('Custom pages was migrated over');
+					}
+				}
+				// Migrate over custom routes
+				$this->makeDir($this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'routes');
+				if (file_exists($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'v2' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'custom')) {
+					if ($this->rcopy($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'v2' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'custom', $this->root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'routes')) {
+						$this->rrmdir($this->root . DIRECTORY_SEPARATOR . 'api' . DIRECTORY_SEPARATOR . 'v2' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'custom');
+						$this->setLoggerChannel('Migration');
+						$this->logger->info('Custom routes was migrated over');
+					}
+				}
+				// Migrate over cache folder
+				$this->removeOldCacheFolder();
+			}
+			return true;
+		}
+		return false;
+	}
+
 	public function upgradeSettingsTabURL()
 	{
 		$response = [
@@ -247,7 +356,7 @@ trait UpgradeFunctions
 		];
 		return $this->processQueries($response);
 	}
-	
+
 	public function upgradeHomepageTabURL()
 	{
 		$response = [
@@ -263,7 +372,7 @@ trait UpgradeFunctions
 		];
 		return $this->processQueries($response);
 	}
-	
+
 	public function upgradeInstalledPluginsConfigItem()
 	{
 		$oldConfigItem = $this->config['installedPlugins'];
@@ -296,7 +405,7 @@ trait UpgradeFunctions
 		}
 		return true;
 	}
-	
+
 	public function removeOldPluginDirectoriesAndFiles()
 	{
 		$folders = [
@@ -326,7 +435,7 @@ trait UpgradeFunctions
 		}
 		return true;
 	}
-	
+
 	public function checkForConfigKeyAddToArray($keys)
 	{
 		$updateItems = [];
@@ -340,7 +449,7 @@ trait UpgradeFunctions
 		}
 		return $updateItems;
 	}
-	
+
 	public function removeOldCustomHTML()
 	{
 		$backup = $this->backupOrganizr();

+ 67 - 0
api/homepage/bookmarks.php

@@ -0,0 +1,67 @@
+<?php
+
+trait BookmarksHomepageItem
+{
+	public function bookmarksSettingsArray($infoOnly = false)
+	{
+		$homepageInformation = [
+			'name' => 'Bookmarks',
+			'enabled' => true,
+			'image' => 'plugins/images/bookmark.png',
+			'category' => 'Links',
+			'settingsArray' => __FUNCTION__
+		];
+		if ($infoOnly) {
+			return $homepageInformation;
+		}
+		$homepageSettings = [
+			'debug' => true,
+			'settings' => [
+				'Enable' => [
+					$this->settingsOption('enable', 'homepageBookmarksEnabled'),
+					$this->settingsOption('auth', 'homepageBookmarksAuth'),
+				],
+				'Options' => [
+					$this->settingsOption('title', 'homepageBookmarksHeader'),
+					$this->settingsOption('toggle-title', 'homepageBookmarksHeaderToggle'),
+					$this->settingsOption('enable', 'homepageBookmarksEnabled', ['label' => 'Enable Bookmarks', 'help' => 'Toggles the view module for Bookmarks']),
+					$this->settingsOption('refresh', 'homepageBookmarksRefresh'),
+				],
+			]
+		];
+		return array_merge($homepageInformation, $homepageSettings);
+	}
+	
+	public function bookmarksHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageBookmarksEnabled'
+				],
+				'auth' => [
+					'homepageBookmarksAuth'
+				]
+			]
+		];
+		return $this->homepageCheckKeyPermissions($key, $permissions);
+	}
+	
+	public function homepageOrderBookmarks()
+	{
+		if ($this->homepageItemPermissions($this->bookmarksHomepagePermissions('main'))) {
+
+			return '
+				<div id="' . __FUNCTION__ . '">
+					<div class="white-box homepage-loading-box"><h2 class="text-center" lang="en">Loading Bookmarks...</h2></div>
+					<script>
+						// Bookmarks And Air
+						homepageBookmarks("' . $this->config['homepageBookmarksRefresh'] . '");
+						// End Bookmarks And Air
+					</script>
+				</div>
+				';
+		}
+	}
+
+}

+ 8 - 9
api/homepage/couchpotato.php

@@ -2,7 +2,7 @@
 
 trait CouchPotatoHomepageItem
 {
-	
+
 	public function couchPotatoSettingsArray($infoOnly = false)
 	{
 		$homepageInformation = [
@@ -42,7 +42,7 @@ trait CouchPotatoHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function couchPotatoHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -61,7 +61,7 @@ trait CouchPotatoHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function getCouchPotatoCalendar()
 	{
 		if (!$this->homepageItemPermissions($this->couchPotatoHomepagePermissions('calendar'), true)) {
@@ -84,7 +84,7 @@ trait CouchPotatoHomepageItem
 		$this->setAPIResponse('success', null, 200, $calendarItems);
 		return $calendarItems;
 	}
-	
+
 	public function formatCouchCalendar($array, $number)
 	{
 		$api = json_decode($array, true);
@@ -130,13 +130,13 @@ trait CouchPotatoHomepageItem
 			} elseif (!empty($child['info']['images']['backdrop'])) {
 				$banner = $child['info']['images']['backdrop_original'][0];
 			} else {
-				$banner = "/plugins/images/cache/no-np.png";
+				$banner = "/plugins/images/homepage/no-np.png";
 			}
-			if ($banner !== "/plugins/images/cache/no-np.png") {
-				$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			if ($banner !== "/plugins/images/homepage/no-np.png") {
+				$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 				$imageURL = $banner;
 				$cacheFile = $cacheDirectory . $movieID . '.jpg';
-				$banner = 'plugins/images/cache/' . $movieID . '.jpg';
+				$banner = 'data/cache/' . $movieID . '.jpg';
 				if (!file_exists($cacheFile)) {
 					$this->cacheImage($imageURL, $movieID);
 					unset($imageURL);
@@ -171,7 +171,6 @@ trait CouchPotatoHomepageItem
 				"bgColor" => str_replace('text', 'bg', $downloaded),
 				"details" => $details
 			));
-			
 		}
 		if ($i != 0) {
 			return $gotCalendar;

+ 6 - 2
api/homepage/deluge.php

@@ -52,8 +52,9 @@ trait DelugeHomepageItem
 				'Misc Options' => [
 					$this->settingsOption('hide-seeding', 'delugeHideSeeding'),
 					$this->settingsOption('hide-completed', 'delugeHideCompleted'),
-					$this->settingsOption('refresh', 'delugeRefresh'),
+					$this->settingsOption('hide-status', 'delugeHideStatus'),
 					$this->settingsOption('combine', 'delugeCombine'),
+					$this->settingsOption('refresh', 'delugeRefresh'),
 				],
 				'Test Connection' => [
 					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing. Note that using a blank password might not work correctly.']),
@@ -128,7 +129,7 @@ trait DelugeHomepageItem
 		try {
 			$options = $this->requestOptions($this->config['delugeURL'], $this->config['delugeRefresh'], $this->config['delugeDisableCertCheck'], $this->config['delugeUseCustomCertificate'], ['organizr_cert' => $this->getCert(), 'custom_cert' => $this->getCustomCert()]);
 			$deluge = new deluge($this->config['delugeURL'], $this->decrypt($this->config['delugePassword']),$options);
-			$torrents = $deluge->getTorrents(null, 'comment, download_payload_rate, eta, hash, is_finished, is_seed, message, name, paused, progress, queue, state, total_size, upload_payload_rate');
+			$torrents = $deluge->getTorrents(null, 'comment, download_payload_rate, eta, hash, is_finished, is_seed, message, name, paused, progress, queue, state, total_size, upload_payload_rate, tracker_status');
 			foreach ($torrents as $key => $value) {
 				$tempStatus = $this->delugeStatus($value->queue, $value->state, $value->progress);
 				if ($tempStatus == 'Seeding' && $this->config['delugeHideSeeding']) {
@@ -136,6 +137,9 @@ trait DelugeHomepageItem
 				} elseif ($tempStatus == 'Finished' && $this->config['delugeHideCompleted']) {
 					//do nothing
 				} else {
+					if ($this->config['delugeHideStatus']){
+						$value->tracker_status = "";
+					}
 					$api['content']['queueItems'][] = $value;
 				}
 			}

+ 228 - 0
api/homepage/donate.php

@@ -0,0 +1,228 @@
+<?php
+
+trait DonateHomepageItem
+{
+	public function donateSettingsArray($infoOnly = false)
+	{
+		$homepageInformation = [
+			'name' => 'Donate',
+			'enabled' => strpos('personal', $this->config['license']) !== false,
+			'image' => 'plugins/images/tabs/donate.png',
+			'category' => 'Requests',
+			'settingsArray' => __FUNCTION__
+		];
+		if ($infoOnly) {
+			return $homepageInformation;
+		}
+		$homepageSettings = [
+			'debug' => true,
+
+			'settings' => [
+				'About' => [
+					$this->settingsOption('about', 'Donations', ['about' => 'This item allows you to use Stripe to accept donations on the homepage']),
+				],
+				'Setup' => [
+					$this->settingsOption('html', null, ['label' => 'Instructions', 'override' => 12, 'html' => '
+					<div class="panel panel-default">
+						<div class="panel-heading">
+							<a href="https://dashboard.stripe.com//" target="_blank"><span class="label label-info m-l-5">Visit Stripe Site</span></a>
+						</div>
+						<div class="panel-wrapper collapse in">
+							<div class="panel-body">
+								<ul class="list-icons">
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Create or Login if you already have an account</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Goto products and click [Add Product]</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Name the product anything you like</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Make sure the product has standard pricing</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Also make sure that the product is set to <code>One Time</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Set the pricing to the minimum price i.e. 1 USD</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Click <code>Save Product</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Click The Product you just created</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Copy <code>ID</code> value</li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Click <code>Developers</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Click <code>API Keys</code></li>
+									<li lang="en"><i class="fa fa-caret-right text-info"></i> Copy both <code>Publishable key</code> and <code>Secret key</code></li>
+								</ul>
+							</div>
+						</div>
+					</div>
+					']
+					),
+				],
+				'Enable' => [
+					$this->settingsOption('enable', 'homepageDonateEnabled'),
+					$this->settingsOption('auth', 'homepageDonateAuth'),
+				],
+				'Connection' => [
+					$this->settingsOption('input', 'homepageDonatePublicToken', ['label' => 'Public Token']),
+					$this->settingsOption('token', 'homepageDonateSecretToken', ['label' => 'Secret Token']),
+					$this->settingsOption('input', 'homepageDonateProductID', ['label' => 'Product ID']),
+
+				],
+				'Customize' => [
+					$this->settingsOption('input', 'homepageDonateCustomizeHeading', ['label' => 'Heading']),
+					$this->settingsOption('code-editor', 'homepageDonateCustomizeDescription', ['label' => 'Description', 'mode' => 'html']),
+					$this->settingsOption('select', 'homepageDonateMinimum',
+						['label' => 'Minimum',
+							'options' => [
+								['name' => '1 USD', 'value' => '100'],
+								['name' => '2 USD', 'value' => '200'],
+								['name' => '3 USD', 'value' => '300'],
+								['name' => '4 USD', 'value' => '400'],
+								['name' => '5 USD', 'value' => '500'],
+								['name' => '10 USD', 'value' => '1000'],
+								['name' => '20 USD', 'value' => '2000'],
+								['name' => '25 USD', 'value' => '2500'],
+								['name' => '50 USD', 'value' => '5000'],
+								['name' => '75 USD', 'value' => '7500'],
+								['name' => '100 USD', 'value' => '10000'],
+							]
+						]),
+					$this->settingsOption('switch', 'homepageDonateShowUserHistory', ['label' => 'Show User Donate History']),
+				]
+			]
+		];
+		return array_merge($homepageInformation, $homepageSettings);
+	}
+
+
+	public function donateHomepagePermissions($key = null)
+	{
+		$permissions = [
+			'main' => [
+				'enabled' => [
+					'homepageDonateEnabled'
+				],
+				'auth' => [
+					'homepageDonateAuth'
+				],
+				'not_empty' => [
+					'homepageDonateMinimum',
+					'homepageDonatePublicToken',
+					'homepageDonateSecretToken',
+					'homepageDonateProductID',
+				]
+			],
+			'history' => [
+				'enabled' => [
+					'homepageDonateEnabled',
+					'homepageDonateShowUserHistory'
+				],
+				'auth' => [
+					'homepageDonateAuth'
+				],
+				'not_empty' => [
+					'homepageDonateMinimum',
+					'homepageDonatePublicToken',
+					'homepageDonateSecretToken',
+					'homepageDonateProductID',
+				]
+			]
+		];
+		return $this->homepageCheckKeyPermissions($key, $permissions);
+	}
+
+	public function homepageDonateUserHistory()
+	{
+		$items = [];
+		if ($this->homepageItemPermissions($this->donateHomepagePermissions('history'))) {
+			try {
+				$stripe = new \Stripe\StripeClient(
+					trim($this->config['homepageDonateSecretToken'])
+				);
+				$history = $stripe->charges->all(['limit' => 100]);
+				if (count($history) > 0) {
+					if ($this->user['email']) {
+						foreach ($history as $charge) {
+							if (($this->qualifyRequest(0) || (strtolower($charge['billing_details']['email']) == strtolower($this->user['email']))) && $charge['amount_captured'] > 0) {
+								$items[] = [
+									'date' => date('Y-m-d\TH:i:s\Z', $charge['created']),
+									'email' => $charge['billing_details']['email'],
+									'amount' => $charge['amount_captured'] / 100
+								];
+							}
+						}
+					}
+				}
+			} catch (\Stripe\Exception\ApiErrorException $e) {
+				die($this->showHTML('Error', $e->getMessage()));
+			}
+		}
+		$this->setResponse(200, null, $items);
+		return $items;
+	}
+
+	public function homepageDonateCreateSession($amount = null)
+	{
+		$amount = $amount ? $amount * 100 : $this->config['homepageDonateMinimum'];
+		if ($this->config['homepageDonatePublicToken'] == '' || $this->config['homepageDonateSecretToken'] == '' || $this->config['homepageDonateProductID'] == '') {
+			$this->setResponse(409, 'Donation Tokens are not setup');
+			return false;
+		}
+		try {
+			$stripe = new \Stripe\StripeClient(
+				trim($this->config['homepageDonateSecretToken'])
+			);
+			$sessionInfo = [
+				'payment_method_types' => ['card'],
+				'line_items' => [[
+					'price_data' => [
+						'product' => $this->config['homepageDonateProductID'],
+						'unit_amount' => $amount,
+						'currency' => 'usd',
+					],
+					'quantity' => 1,
+				]],
+				'mode' => 'payment',
+				'success_url' => $this->getServerPath() . 'api/v2/homepage/donate/success',
+				'cancel_url' => $this->getServerPath() . 'api/v2/homepage/donate/cancel',
+			];
+			if ($this->user['email'] && stripos($this->user['email'], 'placeholder') == false) {
+				$sessionInfo = array_merge($sessionInfo, ['customer_email' => $this->user['email']]);
+			}
+			$session = $stripe->checkout->sessions->create($sessionInfo);
+			header('HTTP/1.1 303 See Other');
+			header('Location: ' . $session->url);
+		} catch (\Stripe\Exception\ApiErrorException $e) {
+			$this->setResponse(500, $e->getMessage());
+			return false;
+		}
+	}
+
+	public function homepageOrderDonate()
+	{
+		if ($this->homepageItemPermissions($this->donateHomepagePermissions('main'))) {
+			$minimum = $this->config['homepageDonateMinimum'] / 100;
+			$history = $this->config['homepageDonateShowUserHistory'] ? '<div class="pull-right"><a href="javascript:void(0)" class="toggle-donation-history" data-status="hidden"><i class="fa fa-clock-o"></i></a> </div>' : '';
+			return '
+			<script>
+				$(document).on("keyup", "#custom-donation-amount", function () {
+					$("#homepage-donation-form").attr("action", "api/v2/homepage/donate?amount=" + $(this).val());
+				});
+			</script>
+				<div id="' . __FUNCTION__ . '">
+					<div class="panel panel-primary" style="position: static; zoom: 1;">
+						<div class="panel-heading"> ' . $this->config['homepageDonateCustomizeHeading'] . $history . '</div>
+						<div class="panel-wrapper collapse in" aria-expanded="true">
+							<div class="panel-body">
+								<p>' . $this->config['homepageDonateCustomizeDescription'] . '</p>
+								<script src="https://polyfill.io/v3/polyfill.min.js?version=3.52.1&features=fetch"></script>
+								<script src="https://js.stripe.com/v3/"></script>
+								<form id="homepage-donation-form" action="api/v2/homepage/donate?amount=' . $minimum . '" method="POST" target="_blank">
+									<div class="input-group m-b-30">
+										<span class="input-group-addon">$</span>
+										<input type="number" class="form-control" name="amount" id="custom-donation-amount" placeholder="' . $minimum . '" min="' . $minimum . '"/>
+										<span class="input-group-btn"> 
+											<button class="btn btn-info" type="submit" id="checkout-button" lang="en">Donate</button> 
+										</span>
+									</div>
+								</form>
+								<div class="donation-history hidden"></div>
+							</div>
+						</div>
+					</div>
+				</div>
+				';
+		}
+	}
+}

+ 14 - 15
api/homepage/emby.php

@@ -54,7 +54,7 @@ trait EmbyHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionEmby()
 	{
 		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('test'), true)) {
@@ -77,7 +77,7 @@ trait EmbyHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function embyHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -136,7 +136,7 @@ trait EmbyHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderembynowplaying()
 	{
 		if ($this->homepageItemPermissions($this->embyHomepagePermissions('streams'))) {
@@ -152,7 +152,7 @@ trait EmbyHomepageItem
 				';
 		}
 	}
-	
+
 	public function homepageOrderembyrecent()
 	{
 		if ($this->homepageItemPermissions($this->embyHomepagePermissions('recent'))) {
@@ -168,7 +168,7 @@ trait EmbyHomepageItem
 				';
 		}
 	}
-	
+
 	public function getEmbyHomepageStreams()
 	{
 		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('streams'), true)) {
@@ -200,7 +200,7 @@ trait EmbyHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getEmbyHomepageRecent()
 	{
 		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('recent'), true)) {
@@ -212,8 +212,7 @@ trait EmbyHomepageItem
 		$showPlayed = false;
 		$userId = 0;
 		try {
-			
-			
+
 			if (isset($this->user['username'])) {
 				$username = strtolower($this->user['username']);
 			}
@@ -259,7 +258,7 @@ trait EmbyHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getEmbyHomepageMetadata($array)
 	{
 		if (!$this->homepageItemPermissions($this->embyHomepagePermissions('metadata'), true)) {
@@ -319,7 +318,7 @@ trait EmbyHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function resolveEmbyItem($itemDetails)
 	{
 		$item = isset($itemDetails['NowPlayingItem']['Id']) ? $itemDetails['NowPlayingItem'] : $itemDetails;
@@ -331,8 +330,8 @@ trait EmbyHomepageItem
 		$actorHeight = 450;
 		$actorWidth = 300;
 		// Cache Directories
-		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
-		$cacheDirectoryWeb = 'plugins/images/cache/';
+		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$cacheDirectoryWeb = 'data/cache/';
 		// Types
 		//$embyItem['array-item'] = $item;
 		//$embyItem['array-itemdetails'] = $itemDetails;
@@ -508,11 +507,11 @@ trait EmbyHomepageItem
 			$embyItem['imageURL'] = 'api/v2/homepage/image?source=emby&type=' . $embyItem['imageType'] . '&img=' . $embyItem['thumb'] . '&height=' . $height . '&width=' . $width . '&key=' . $embyItem['key'] . '';
 		}
 		if (!$embyItem['nowPlayingThumb']) {
-			$embyItem['nowPlayingOriginalImage'] = $embyItem['nowPlayingImageURL'] = "plugins/images/cache/no-np.png";
+			$embyItem['nowPlayingOriginalImage'] = $embyItem['nowPlayingImageURL'] = "plugins/images/homepage/no-np.png";
 			$embyItem['nowPlayingKey'] = "no-np";
 		}
 		if (!$embyItem['thumb']) {
-			$embyItem['originalImage'] = $embyItem['imageURL'] = "plugins/images/cache/no-list.png";
+			$embyItem['originalImage'] = $embyItem['imageURL'] = "plugins/images/homepage/no-list.png";
 			$embyItem['key'] = "no-list";
 		}
 		if (isset($useImage)) {
@@ -520,5 +519,5 @@ trait EmbyHomepageItem
 		}
 		return $embyItem;
 	}
-	
+
 }

+ 94 - 67
api/homepage/ical.php

@@ -12,7 +12,7 @@ trait ICalHomepageItem
 		}
 		return $success;
 	}
-	
+
 	public function calendarStandardizeTimezone($timezone)
 	{
 		switch ($timezone) {
@@ -30,11 +30,26 @@ trait ICalHomepageItem
 			case('Eastern Standard Time'):
 				$timezone = 'America/New_York';
 				break;
+			case('MST'):
+			case('Mountain Time'):
+			case('Mountain Standard Time'):
+				$timezone = 'America/Denver';
+				break;
 			case('PST'):
 			case('Pacific Time'):
 			case('Pacific Standard Time'):
 				$timezone = 'America/Los_Angeles';
 				break;
+			case('AKST'):
+			case('Alaska Time'):
+			case('Alaska Standard Time'):
+				$timezone = 'America/Anchorage';
+				break;
+			case('HST'):
+			case('Hawaii Time'):
+			case('Hawaii Standard Time'):
+				$timezone = 'Pacific/Honolulu';
+				break;
 			case('China Time'):
 			case('China Standard Time'):
 				$timezone = 'Asia/Beijing';
@@ -44,15 +59,58 @@ trait ICalHomepageItem
 			case('India Standard Time'):
 				$timezone = 'Asia/New_Delhi';
 				break;
-			case('JST');
+			case('JST'):
 			case('Japan Time'):
 			case('Japan Standard Time'):
 				$timezone = 'Asia/Tokyo';
 				break;
+			case('WET'):
+			case('WEST'):
+			case('Western European Time'):
+			case('Western European Standard Time'):
+			case('Western European Summer Time'):
+			case('W. Europe Time'):
+			case('W. Europe Standard Time'):
+			case('W. Europe Summer Time'):
+				$timezone = 'Europe/Lisbon';
+				break;
 		}
 		return $timezone;
 	}
-	
+
+	public function getCalendarExtraDates($start, $rule, $timezone)
+	{
+		$extraDates = [];
+		try {
+			if (stripos($rule, 'FREQ') !== false) {
+				$until = $this->getCalenderRepeatUntil($rule);
+				$start = new DateTime ($start);
+				$startDate = new DateTime ($this->currentTime);
+				$startDate->setTime($start->format('H'), $start->format('i'));
+				$startDate->modify('-' . $this->config['calendarStart'] . ' days');
+				$endDate = new DateTime ($this->currentTime);
+				$endDate->modify('+' . $this->config['calendarEnd'] . ' days');
+				$start = (stripos($rule, 'BYDAY') !== false || stripos($rule, 'BYMONTHDAY') !== false || stripos($rule, 'DAILY') !== false) ? $startDate : $start;
+				$until = $until ? new DateTime($until) : $endDate;
+				$dates = new \Recurr\Rule(trim($rule));
+				$dates->setStartDate($start)->setUntil($until);
+				$transformer = new \Recurr\Transformer\ArrayTransformer();
+				$transformerConfig = new \Recurr\Transformer\ArrayTransformerConfig();
+				$transformerConfig->enableLastDayOfMonthFix();
+				$transformer->setConfig($transformerConfig);
+				foreach (@$transformer->transform($dates) as $key => $date) {
+					if ($date->getStart() >= $startDate) {
+						$extraDates[$key]['start'] = $date->getStart();
+						$extraDates[$key]['end'] = $date->getEnd();
+					}
+				}
+			}
+		} catch (\Recurr\Exception\InvalidRRule | \Recurr\Exception\InvalidWeekday | Exception $e) {
+			return $extraDates;
+		}
+		return $extraDates;
+	}
+
 	public function getCalenderRepeat($value)
 	{
 		//FREQ=DAILY
@@ -69,7 +127,7 @@ trait ICalHomepageItem
 			return $first[1];
 		}
 	}
-	
+
 	public function getCalenderRepeatUntil($value)
 	{
 		$first = explode('UNTIL=', $value);
@@ -84,7 +142,7 @@ trait ICalHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getCalenderRepeatCount($value)
 	{
 		$first = explode('COUNT=', $value);
@@ -94,7 +152,7 @@ trait ICalHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function file_get_contents_curl($url)
 	{
 		$ch = curl_init();
@@ -109,7 +167,7 @@ trait ICalHomepageItem
 		curl_close($ch);
 		return $data;
 	}
-	
+
 	public function getIcsEventsAsArray($file)
 	{
 		$icalString = $this->file_get_contents_curl($file);
@@ -129,8 +187,8 @@ trait ICalHomepageItem
 		}
 		return $icsDates;
 	}
-	
-	/* funcion is to avaid the elements wich is not having the proper start, end  and summary informations */
+
+	/* function is to avoid the elements which is not having the proper start, end and summary information */
 	public function getICSDates($key, $subKey, $subValue, $icsDates)
 	{
 		if ($key != 0 && $subKey == 0) {
@@ -143,7 +201,7 @@ trait ICalHomepageItem
 		}
 		return $icsDates;
 	}
-	
+
 	public function getICalendar()
 	{
 		if (!$this->config['homepageCalendarEnabled']) {
@@ -163,6 +221,7 @@ trait ICalHomepageItem
 		$calendarURLList = explode(',', $this->config['calendariCal']);
 		$icalEvents = array();
 		foreach ($calendarURLList as $key => $value) {
+			$dates = [];
 			$icsEvents = $this->getIcsEventsAsArray($value);
 			if (isset($icsEvents) && !empty($icsEvents)) {
 				$timeZone = isset($icsEvents [1] ['X-WR-TIMEZONE']) ? trim($icsEvents[1]['X-WR-TIMEZONE']) : date_default_timezone_get();
@@ -177,67 +236,37 @@ trait ICalHomepageItem
 					});
 					if (!empty($startKeys) && !empty($endKeys) && isset($icsEvent['SUMMARY'])) {
 						/* Getting start date and time */
-						$repeat = isset($icsEvent ['RRULE']) ? $icsEvent ['RRULE'] : false;
+						$repeat = $icsEvent ['RRULE'] ?? false;
 						if (!$originalTimeZone) {
 							$tzKey = array_keys($startKeys);
 							if (strpos($tzKey[0], 'TZID=') !== false) {
 								$originalTimeZone = explode('TZID=', (string)$tzKey[0]);
 								$originalTimeZone = (count($originalTimeZone) >= 2) ? str_replace('"', '', $originalTimeZone[1]) : false;
+								$originalTimeZone = stripos($originalTimeZone, ';') !== false ? explode(';', $originalTimeZone)[0] : $originalTimeZone;
 							}
 						}
 						$start = reset($startKeys);
 						$end = reset($endKeys);
-						$totalDays = $this->config['calendarStart'] + $this->config['calendarEnd'];
+						$oldestDay = new DateTime ($this->currentTime);
+						$oldestDay->modify('-' . $this->config['calendarStart'] . ' days');
+						$newestDay = new DateTime ($this->currentTime);
+						$newestDay->modify('+' . $this->config['calendarEnd'] . ' days');
 						if ($repeat) {
-							$repeatOverride = $this->getCalenderRepeatCount(trim($icsEvent["RRULE"]));
-							switch (trim(strtolower($this->getCalenderRepeat($repeat)))) {
-								case 'daily':
-									$repeat = ($repeatOverride) ? $repeatOverride : $totalDays;
-									$term = 'days';
-									break;
-								case 'weekly':
-									$repeat = ($repeatOverride) ? $repeatOverride : round($totalDays / 7);
-									$term = 'weeks';
-									break;
-								case 'monthly':
-									$repeat = ($repeatOverride) ? $repeatOverride : round($totalDays / 30);
-									$term = 'months';
-									break;
-								case 'yearly':
-									$repeat = ($repeatOverride) ? $repeatOverride : round($totalDays / 365);
-									$term = 'years';
-									break;
-								default:
-									$repeat = ($repeatOverride) ? $repeatOverride : $totalDays;
-									$term = 'days';
-									break;
-							}
+							$dates = $this->getCalendarExtraDates($start, $icsEvent['RRULE'], $originalTimeZone);
 						} else {
-							$repeat = 1;
-							$term = 'day';
+							$dates[] = [
+								'start' => new DateTime ($start),
+								'end' => new DateTime ($end)
+							];
+							if ($oldestDay > new DateTime ($end)) {
+								continue;
+							}
 						}
-						$calendarTimes = 0;
-						while ($calendarTimes < $repeat) {
-							$currentDate = new DateTime ($this->currentTime);
-							$oldestDay = new DateTime ($this->currentTime);
-							$oldestDay->modify('-' . $this->config['calendarStart'] . ' days');
-							$newestDay = new DateTime ($this->currentTime);
-							$newestDay->modify('+' . $this->config['calendarEnd'] . ' days');
+						foreach ($dates as $eventDate) {
 							/* Converting to datetime and apply the timezone to get proper date time */
-							$startDt = new DateTime ($start);
+							$startDt = $eventDate['start'];
 							/* Getting end date with time */
-							$endDt = new DateTime ($end);
-							if ($calendarTimes !== 0) {
-								$dateDiff = date_diff($startDt, $currentDate);
-								$startDt->modify($dateDiff->format('%R') . (round(($dateDiff->days) / 7)) . ' weeks');
-								$startDt->modify('+' . $calendarTimes . ' ' . $term);
-								$endDt->modify($dateDiff->format('%R') . (round(($dateDiff->days) / 7)) . ' weeks');
-								$endDt->modify('+' . $calendarTimes . ' ' . $term);
-							} elseif ($calendarTimes == 0 && $repeat !== 1) {
-								$dateDiff = date_diff($startDt, $currentDate);
-								$startDt->modify($dateDiff->format('%R') . (round(($dateDiff->days) / 7)) . ' weeks');
-								$endDt->modify($dateDiff->format('%R') . (round(($dateDiff->days) / 7)) . ' weeks');
-							}
+							$endDt = $eventDate['end'];
 							$calendarStartDiff = date_diff($startDt, $newestDay);
 							$calendarEndDiff = date_diff($startDt, $oldestDay);
 							if ($originalTimeZone && $originalTimeZone !== 'UTC' && (strpos($start, 'Z') == false)) {
@@ -249,13 +278,16 @@ trait ICalHomepageItem
 								$dateTimeOriginalOffset = $dateTimeOriginal->getOffset() / 3600;
 								$dateTimeUTCOffset = $dateTimeUTC->getOffset() / 3600;
 								$diff = $dateTimeUTCOffset - $dateTimeOriginalOffset;
-								$startDt->modify('+ ' . $diff . ' hour');
-								$endDt->modify('+ ' . $diff . ' hour');
+								if ((int)$diff >= 0) {
+									$startDt->modify('+ ' . $diff . ' hour');
+									$endDt->modify('+ ' . $diff . ' hour');
+								}
 							}
 							$startDt->setTimeZone(new DateTimezone ($timeZone));
 							$endDt->setTimeZone(new DateTimezone ($timeZone));
 							$startDate = $startDt->format(DateTime::ATOM);
 							$endDate = $endDt->format(DateTime::ATOM);
+							$dates = isset($icsEvent['RRULE']) ? $dates : null;
 							if (new DateTime() < $endDt) {
 								$extraClass = 'text-info';
 							} else {
@@ -266,12 +298,8 @@ trait ICalHomepageItem
 							if (!$this->calendarDaysCheck($calendarStartDiff->format('%R') . $calendarStartDiff->days, $calendarEndDiff->format('%R') . $calendarEndDiff->days)) {
 								break;
 							}
-							if (isset($icsEvent["RRULE"]) && $this->getCalenderRepeatUntil(trim($icsEvent["RRULE"]))) {
-								$untilDate = new DateTime ($this->getCalenderRepeatUntil(trim($icsEvent["RRULE"])));
-								$untilDiff = date_diff($currentDate, $untilDate);
-								if ($untilDiff->days > 0) {
-									break;
-								}
+							if ($startDt->format('H') == 0 && $startDt->format('i') == 0) {
+								$startDate = $startDt->format('Y-m-d');
 							}
 							$icalEvents[] = array(
 								'title' => $eventName,
@@ -282,7 +310,6 @@ trait ICalHomepageItem
 								'end' => $endDate,
 								'bgColor' => str_replace('text', 'bg', $extraClass),
 							);
-							$calendarTimes = $calendarTimes + 1;
 						}
 					}
 				}
@@ -292,4 +319,4 @@ trait ICalHomepageItem
 		$this->setAPIResponse('success', null, 200, $calendarSources);
 		return $calendarSources;
 	}
-}
+}

+ 14 - 14
api/homepage/jellyfin.php

@@ -2,7 +2,7 @@
 
 trait JellyfinHomepageItem
 {
-	
+
 	public function jellyfinSettingsArray($infoOnly = false)
 	{
 		$homepageInformation = [
@@ -54,7 +54,7 @@ trait JellyfinHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionJellyfin()
 	{
 		if (empty($this->config['jellyfinURL'])) {
@@ -88,7 +88,7 @@ trait JellyfinHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function jellyfinHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -135,7 +135,7 @@ trait JellyfinHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderjellyfinnowplaying()
 	{
 		if ($this->homepageItemPermissions($this->jellyfinHomepagePermissions('streams'))) {
@@ -151,7 +151,7 @@ trait JellyfinHomepageItem
 				';
 		}
 	}
-	
+
 	public function homepageOrderjellyfinrecent()
 	{
 		if ($this->homepageItemPermissions($this->jellyfinHomepagePermissions('recent'))) {
@@ -167,7 +167,7 @@ trait JellyfinHomepageItem
 				';
 		}
 	}
-	
+
 	public function getJellyfinHomepageStreams()
 	{
 		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('streams'), true)) {
@@ -199,7 +199,7 @@ trait JellyfinHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getJellyfinHomepageRecent()
 	{
 		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('recent'), true)) {
@@ -256,7 +256,7 @@ trait JellyfinHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getJellyfinHomepageMetadata($array)
 	{
 		if (!$this->homepageItemPermissions($this->jellyfinHomepagePermissions('metadata'), true)) {
@@ -316,7 +316,7 @@ trait JellyfinHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function resolveJellyfinItem($itemDetails)
 	{
 		$item = isset($itemDetails['NowPlayingItem']['Id']) ? $itemDetails['NowPlayingItem'] : $itemDetails;
@@ -328,8 +328,8 @@ trait JellyfinHomepageItem
 		$actorHeight = 450;
 		$actorWidth = 300;
 		// Cache Directories
-		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
-		$cacheDirectoryWeb = 'plugins/images/cache/';
+		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$cacheDirectoryWeb = 'data/cache/';
 		// Types
 		switch (@$item['Type']) {
 			case 'Series':
@@ -503,11 +503,11 @@ trait JellyfinHomepageItem
 			$jellyfinItem['imageURL'] = 'api/v2/homepage/image?source=jellyfin&type=' . $jellyfinItem['imageType'] . '&img=' . $jellyfinItem['thumb'] . '&height=' . $height . '&width=' . $width . '&key=' . $jellyfinItem['key'] . '';
 		}
 		if (!$jellyfinItem['nowPlayingThumb']) {
-			$jellyfinItem['nowPlayingOriginalImage'] = $jellyfinItem['nowPlayingImageURL'] = "plugins/images/cache/no-np.png";
+			$jellyfinItem['nowPlayingOriginalImage'] = $jellyfinItem['nowPlayingImageURL'] = "plugins/images/homepage/no-np.png";
 			$jellyfinItem['nowPlayingKey'] = "no-np";
 		}
 		if (!$jellyfinItem['thumb']) {
-			$jellyfinItem['originalImage'] = $jellyfinItem['imageURL'] = "plugins/images/cache/no-list.png";
+			$jellyfinItem['originalImage'] = $jellyfinItem['imageURL'] = "plugins/images/homepage/no-list.png";
 			$jellyfinItem['key'] = "no-list";
 		}
 		if (isset($useImage)) {
@@ -515,5 +515,5 @@ trait JellyfinHomepageItem
 		}
 		return $jellyfinItem;
 	}
-	
+
 }

+ 29 - 11
api/homepage/lidarr.php

@@ -33,7 +33,7 @@ trait LidarrHomepageItem
 					$this->settingsOption('enable', 'lidarrSocksEnabled'),
 					$this->settingsOption('auth', 'lidarrSocksAuth'),
 				],
-				'Misc Options' => [
+				'Calendar' => [
 					$this->settingsOption('calendar-start', 'calendarStart'),
 					$this->settingsOption('calendar-end', 'calendarEnd'),
 					$this->settingsOption('calendar-starting-day', 'calendarFirstDay'),
@@ -41,7 +41,13 @@ trait LidarrHomepageItem
 					$this->settingsOption('calendar-time-format', 'calendarTimeFormat'),
 					$this->settingsOption('calendar-locale', 'calendarLocale'),
 					$this->settingsOption('calendar-limit', 'calendarLimit'),
-					$this->settingsOption('refresh', 'calendarRefresh'),
+					$this->settingsOption('refresh', 'calendarRefresh'),					
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('enable', 'lidarrIcon', ['label' => 'Show Lidarr Icon']),
+					$this->settingsOption('calendar-link-url', 'lidarrCalendarLink'),
+					$this->settingsOption('blank'),
+					$this->settingsOption('calendar-frame-target', 'lidarrFrameTarget')
 				],
 				'Test Connection' => [
 					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
@@ -51,7 +57,7 @@ trait LidarrHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionLidarr()
 	{
 		if (empty($this->config['lidarrURL'])) {
@@ -83,7 +89,6 @@ trait LidarrHomepageItem
 					$errors .= $ip . ': Response was not JSON';
 					$failed = true;
 				}
-				
 			} catch (Exception $e) {
 				$failed = true;
 				$ip = $value['url'];
@@ -99,7 +104,7 @@ trait LidarrHomepageItem
 			return true;
 		}
 	}
-	
+
 	public function lidarrHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -132,7 +137,7 @@ trait LidarrHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function getLidarrQueue()
 	{
 		if (!$this->homepageItemPermissions($this->lidarrHomepagePermissions('queue'), true)) {
@@ -164,7 +169,7 @@ trait LidarrHomepageItem
 		$this->setAPIResponse('success', null, 200, $api);
 		return $api;;
 	}
-	
+
 	public function getLidarrCalendar($startDate = null, $endDate = null)
 	{
 		$startDate = ($startDate) ?? $_GET['start'] ?? date('Y-m-d', strtotime('-' . $this->config['calendarStart'] . ' days'));
@@ -198,7 +203,7 @@ trait LidarrHomepageItem
 		$this->setAPIResponse('success', null, 200, $calendarItems);
 		return $calendarItems;
 	}
-	
+
 	public function formatLidarrCalendar($array, $number)
 	{
 		$array = json_decode($array, true);
@@ -231,12 +236,21 @@ trait LidarrHomepageItem
 			} else {
 				$downloaded = "text-danger";
 			}
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			foreach ($child['artist']['images'] as $image) {
 				if ($image['coverType'] == "fanart") {
 					$fanart = str_replace('http://', 'https://', $image['url']);
 				}
 			}
+			$href = $this->config['lidarrCalendarLink'] ?? '';
+			if (empty($href) && !empty($this->config['lidarrURL'])) {
+				$href_arr = explode(',', $this->config['lidarrURL']);
+				$href = reset($href_arr);
+			}
+			if (!empty($href)) {
+				$href = $href . '/artist/' . $child['artist']['foreignArtistId'];
+				$href = str_replace("//artist/", "/artist/", $href);
+			}
 			$details = array(
 				"seasonCount" => '',
 				"status" => '',
@@ -252,6 +266,10 @@ trait LidarrHomepageItem
 				"videoCodec" => "unknown",
 				"size" => "unknown",
 				"genres" => $child['genres'],
+				"href" => strtolower($href),
+				"icon" => "/plugins/images/tabs/lidarr.png",
+				"frame" => $this->config['lidarrFrameTarget'],
+				"showLink" => $this->config['lidarrIcon']
 			);
 			array_push($gotCalendar, array(
 				"id" => "Lidarr-" . $number . "-" . $i,
@@ -271,5 +289,5 @@ trait LidarrHomepageItem
 		}
 		return false;
 	}
-	
-}
+
+}

+ 5 - 5
api/homepage/monitorr.php

@@ -37,7 +37,7 @@ trait MonitorrHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function monitorrHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -55,7 +55,7 @@ trait MonitorrHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderMonitorr()
 	{
 		if ($this->homepageItemPermissions($this->monitorrHomepagePermissions('main'))) {
@@ -71,7 +71,7 @@ trait MonitorrHomepageItem
 				';
 		}
 	}
-	
+
 	public function getMonitorrHomepageData()
 	{
 		if (!$this->homepageItemPermissions($this->monitorrHomepagePermissions('main'), true)) {
@@ -124,13 +124,13 @@ trait MonitorrHomepageItem
 					$ext = explode('.', $image);
 					$ext = $ext[key(array_slice($ext, -1, 1, true))];
 					$imageUrl = $url . '/assets' . $image;
-					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 					$img = Requests::get($imageUrl, ['Token' => $this->config['organizrAPI']], $options);
 					if ($img->success) {
 						$base64 = 'data:image/' . $ext . ';base64,' . base64_encode($img->body);
 						$statuses[$service]['image'] = $base64;
 					} else {
-						$statuses[$service]['image'] = 'plugins/images/cache/no-list.png';
+						$statuses[$service]['image'] = 'plugins/images/homepage/no-list.png';
 					}
 					$linkMatch = [];
 					$linkPattern = '/<a class="servicetile" href="(.*)" target="_blank" style="display: block"><div id="serviceimg"><div><img id="' . strtolower($service) . '-service-img/';

+ 11 - 12
api/homepage/ombi.php

@@ -2,7 +2,7 @@
 
 trait OmbiHomepageItem
 {
-	
+
 	public function ombiSettingsArray($infoOnly = false)
 	{
 		$homepageInformation = [
@@ -53,7 +53,7 @@ trait OmbiHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionOmbi()
 	{
 		if (empty($this->config['ombiURL'])) {
@@ -85,7 +85,7 @@ trait OmbiHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function ombiHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -104,7 +104,7 @@ trait OmbiHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderombi()
 	{
 		if ($this->homepageItemPermissions($this->ombiHomepagePermissions('main'))) {
@@ -120,8 +120,8 @@ trait OmbiHomepageItem
 				';
 		}
 	}
-	
-	
+
+
 	public function getOmbiRequests($type = "both", $limit = 50, $offset = 0)
 	{
 		if (!$this->homepageItemPermissions($this->ombiHomepagePermissions('main'), true)) {
@@ -165,7 +165,7 @@ trait OmbiHomepageItem
 								'id' => $value['theMovieDbId'],
 								'title' => $value['title'],
 								'overview' => $value['overview'],
-								'poster' => (isset($value['posterPath']) && $value['posterPath'] !== '') ? 'https://image.tmdb.org/t/p/w300/' . $value['posterPath'] : 'plugins/images/cache/no-list.png',
+								'poster' => (isset($value['posterPath']) && $value['posterPath'] !== '') ? 'https://image.tmdb.org/t/p/w300/' . $value['posterPath'] : 'plugins/images/homepage/no-list.png',
 								'background' => (isset($value['background']) && $value['background'] !== '') ? 'https://image.tmdb.org/t/p/w1280/' . $value['background'] : '',
 								'approved' => $value['approved'],
 								'available' => $value['available'],
@@ -194,7 +194,7 @@ trait OmbiHomepageItem
 									'id' => $value['tvDbId'],
 									'title' => $value['title'],
 									'overview' => $value['overview'],
-									'poster' => (isset($value['posterPath']) && $value['posterPath'] !== '') ? (str_starts_with($value['posterPath'], '/') ? 'https://image.tmdb.org/t/p/w300/' . $value['posterPath'] : $value['posterPath']) : 'plugins/images/cache/no-list.png',
+									'poster' => (isset($value['posterPath']) && $value['posterPath'] !== '') ? (str_starts_with($value['posterPath'], '/') ? 'https://image.tmdb.org/t/p/w300/' . $value['posterPath'] : $value['posterPath']) : 'plugins/images/homepage/no-list.png',
 									'background' => (isset($value['background']) && $value['background'] !== '') ? 'https://image.tmdb.org/t/p/w1280/' . $value['background'] : '',
 									'approved' => $value['childRequests'][0]['approved'],
 									'available' => $value['childRequests'][0]['available'],
@@ -230,7 +230,7 @@ trait OmbiHomepageItem
 		$this->setAPIResponse('success', null, 200, $api);
 		return $api;
 	}
-	
+
 	public function addOmbiRequest($id, $type)
 	{
 		$id = ($id) ?? null;
@@ -347,14 +347,13 @@ trait OmbiHomepageItem
 				$this->setAPIResponse('error', 'Ombi Error Occurred', 500);
 				return false;
 			}
-			
 		} catch (Requests_Exception $e) {
 			$this->writeLog('error', 'OMBI Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
 			$this->setAPIResponse('error', $e->getMessage(), 500);
 			return false;
 		}
 	}
-	
+
 	public function actionOmbiRequest($id, $type, $action)
 	{
 		$id = ($id) ?? null;
@@ -432,7 +431,7 @@ trait OmbiHomepageItem
 			return false;
 		};
 	}
-	
+
 	public function ombiTVDefault($type)
 	{
 		return $type == $this->config['ombiTvDefault'];

+ 12 - 13
api/homepage/overseerr.php

@@ -2,7 +2,7 @@
 
 trait OverseerrHomepageItem
 {
-	
+
 	public function overseerrSettingsArray($infoOnly = false)
 	{
 		$homepageInformation = [
@@ -55,7 +55,7 @@ trait OverseerrHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionOverseerr()
 	{
 		if (!$this->homepageItemPermissions($this->overseerrHomepagePermissions('test'), true)) {
@@ -76,14 +76,13 @@ trait OverseerrHomepageItem
 				$this->setResponse(401, 'API Connection failed');
 				return false;
 			}
-			
 		} catch (Requests_Exception $e) {
 			$this->writeLog('error', 'Overseerr Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
 			$this->setResponse(500, $e->getMessage());
 			return false;
 		}
 	}
-	
+
 	public function overseerrHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -121,7 +120,7 @@ trait OverseerrHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderoverseerr()
 	{
 		if ($this->homepageItemPermissions($this->overseerrHomepagePermissions('main'))) {
@@ -137,8 +136,8 @@ trait OverseerrHomepageItem
 				';
 		}
 	}
-	
-	
+
+
 	public function getOverseerrRequests($limit = 50, $offset = 0)
 	{
 		if (!$this->homepageItemPermissions($this->overseerrHomepagePermissions('main'), true)) {
@@ -176,7 +175,7 @@ trait OverseerrHomepageItem
 								'id' => $value['media']['tmdbId'],
 								'title' => ($value['type'] == 'movie') ? $requestsItemData['title'] : $requestsItemData['name'],
 								'overview' => $requestsItemData['overview'],
-								'poster' => (isset($requestsItemData['posterPath']) && $requestsItemData['posterPath'] !== '') ? 'https://image.tmdb.org/t/p/w300/' . $requestsItemData['posterPath'] : 'plugins/images/cache/no-list.png',
+								'poster' => (isset($requestsItemData['posterPath']) && $requestsItemData['posterPath'] !== '') ? 'https://image.tmdb.org/t/p/w300/' . $requestsItemData['posterPath'] : 'plugins/images/homepage/no-list.png',
 								'background' => (isset($requestsItemData['backdropPath']) && $requestsItemData['backdropPath'] !== '') ? 'https://image.tmdb.org/t/p/w1280/' . $requestsItemData['backdropPath'] : '',
 								'approved' => $value['status'] == 2,
 								'available' => $value['media']['status'] == 5,
@@ -211,7 +210,7 @@ trait OverseerrHomepageItem
 		$this->setResponse(200, null, $api);
 		return $api;
 	}
-	
+
 	public function getDefaultService($services)
 	{
 		if (empty($services)) {
@@ -234,7 +233,7 @@ trait OverseerrHomepageItem
 			return ($default) ? $services[$default] : $services[0];
 		}
 	}
-	
+
 	public function addOverseerrRequest($id, $type, $seasons = null)
 	{
 		$id = ($id) ?? null;
@@ -401,7 +400,7 @@ trait OverseerrHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function actionOverseerrRequest($id, $type, $action)
 	{
 		$id = ($id) ?? null;
@@ -492,7 +491,7 @@ trait OverseerrHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getOverseerrMetadata($id, $type)
 	{
 		if (!$id) {
@@ -529,7 +528,7 @@ trait OverseerrHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function overseerrTVDefault($type)
 	{
 		return $type == $this->config['overseerrTvDefault'];

+ 34 - 27
api/homepage/plex.php

@@ -16,10 +16,12 @@ trait PlexHomepageItem
 		}
 		$libraryList = [['name' => 'Refresh page to update List', 'value' => '', 'disabled' => true]];
 		if ($this->config['plexID'] !== '' && $this->config['plexToken'] !== '') {
-			$libraryList = [];
-			$loop = $this->plexLibraryList('key')['libraries'];
-			foreach ($loop as $key => $value) {
-				$libraryList[] = ['name' => $key, 'value' => $value];
+			$loop = $this->plexLibraryList('key');
+			if ($loop) {
+				$loop = $loop['libraries'];
+				foreach ($loop as $key => $value) {
+					$libraryList[] = ['name' => $key, 'value' => $value];
+				}
 			}
 		}
 		$homepageSettings = [
@@ -80,7 +82,7 @@ trait PlexHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionPlex()
 	{
 		if (!empty($this->config['plexURL']) && !empty($this->config['plexToken'])) {
@@ -105,7 +107,7 @@ trait PlexHomepageItem
 			return 'URL and/or Token not setup';
 		}
 	}
-	
+
 	public function plexHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -185,7 +187,7 @@ trait PlexHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderplexnowplaying()
 	{
 		if ($this->homepageItemPermissions($this->plexHomepagePermissions('streams'))) {
@@ -201,7 +203,7 @@ trait PlexHomepageItem
 				';
 		}
 	}
-	
+
 	public function homepageOrderplexrecent()
 	{
 		if ($this->homepageItemPermissions($this->plexHomepagePermissions('recent'))) {
@@ -217,7 +219,7 @@ trait PlexHomepageItem
 				';
 		}
 	}
-	
+
 	public function homepageOrderplexplaylist()
 	{
 		if ($this->homepageItemPermissions($this->plexHomepagePermissions('playlists'))) {
@@ -233,7 +235,7 @@ trait PlexHomepageItem
 				';
 		}
 	}
-	
+
 	public function getPlexHomepageStreams()
 	{
 		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('streams'), true)) {
@@ -266,13 +268,16 @@ trait PlexHomepageItem
 				$api['group'] = '1';
 				$this->setAPIResponse('success', null, 200, $api);
 				return $api;
+			} else {
+				$this->setAPIResponse('error', null, 401, []);
+				return [];
 			}
 		} catch (Exception $e) {
 			$this->setAPIResponse('error', null, 422, [$e->getMessage()]);
 			return false;
 		}
 	}
-	
+
 	public function getPlexHomepageRecent()
 	{
 		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('recent'), true)) {
@@ -320,7 +325,7 @@ trait PlexHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getPlexHomepagePlaylists()
 	{
 		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('playlists'), true)) {
@@ -366,7 +371,7 @@ trait PlexHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getPlexHomepageMetadata($array)
 	{
 		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('metadata'), true)) {
@@ -408,7 +413,7 @@ trait PlexHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function getPlexHomepageSearch($query)
 	{
 		if (!$this->homepageItemPermissions($this->plexHomepagePermissions('search'), true)) {
@@ -448,7 +453,7 @@ trait PlexHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function resolvePlexItem($item)
 	{
 		// Static Height & Width
@@ -457,8 +462,8 @@ trait PlexHomepageItem
 		$nowPlayingHeight = $this->getCacheImageSize('nph');
 		$nowPlayingWidth = $this->getCacheImageSize('npw');
 		// Cache Directories
-		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
-		$cacheDirectoryWeb = 'plugins/images/cache/';
+		$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+		$cacheDirectoryWeb = 'data/cache/';
 		// Types
 		switch ($item['type']) {
 			case 'show':
@@ -488,21 +493,22 @@ trait PlexHomepageItem
 				$plexItem['metadataKey'] = (string)$item['parentRatingKey'];
 				break;
 			case 'episode':
+				$useImage = (isset($item['live']) ? 'plugins/images/homepage/livetv.png' : null);
 				$plexItem['type'] = 'tv';
 				$plexItem['title'] = (string)$item['grandparentTitle'];
 				$plexItem['secondaryTitle'] = (string)$item['parentTitle'] . ' - Episode ' . (string)$item['index'];
 				$plexItem['summary'] = (string)$item['title'];
-				$plexItem['ratingKey'] = (string)$item['parentRatingKey'];
+				$plexItem['ratingKey'] = (string)($item['parentRatingKey'] ?? $item['ratingKey']);
 				$plexItem['thumb'] = ($item['parentThumb'] ? (string)$item['parentThumb'] : (string)$item['grandparentThumb']);
 				$plexItem['key'] = (string)$item['ratingKey'] . "-list";
 				$plexItem['nowPlayingThumb'] = (string)$item['grandparentArt'];
 				$plexItem['nowPlayingKey'] = (string)$item['grandparentRatingKey'] . "-np";
 				$plexItem['nowPlayingTitle'] = (string)$item['grandparentTitle'] . ' - ' . (string)$item['title'];
 				$plexItem['nowPlayingBottom'] = 'S' . (string)$item['parentIndex'] . ' · E' . (string)$item['index'];
-				$plexItem['metadataKey'] = (string)$item['grandparentRatingKey'];
+				$plexItem['metadataKey'] = (string)($item['grandparentRatingKey'] ?? $item['parentRatingKey'] ?? $item['ratingKey']);
 				break;
 			case 'clip':
-				$useImage = (isset($item['live']) ? "plugins/images/cache/livetv.png" : null);
+				$useImage = (isset($item['live']) ? "plugins/images/homepage/livetv.png" : null);
 				$plexItem['type'] = 'clip';
 				$plexItem['title'] = (isset($item['live']) ? 'Live TV' : (string)$item['title']);
 				$plexItem['secondaryTitle'] = '';
@@ -531,6 +537,7 @@ trait PlexHomepageItem
 				$plexItem['metadataKey'] = isset($item['grandparentRatingKey']) ? (string)$item['grandparentRatingKey'] : (string)$item['parentRatingKey'];
 				break;
 			default:
+				$useImage = (isset($item['live']) ? 'plugins/images/homepage/livetv.png' : null);
 				$plexItem['type'] = 'movie';
 				$plexItem['title'] = (string)$item['title'];
 				$plexItem['secondaryTitle'] = (string)$item['year'];
@@ -629,11 +636,11 @@ trait PlexHomepageItem
 			$plexItem['imageURL'] = 'api/v2/homepage/image?source=plex&img=' . $plexItem['thumb'] . '&height=' . $height . '&width=' . $width . '&key=' . $plexItem['key'] . '';
 		}
 		if (!$plexItem['nowPlayingThumb']) {
-			$plexItem['nowPlayingOriginalImage'] = $plexItem['nowPlayingImageURL'] = "plugins/images/cache/no-np.png";
+			$plexItem['nowPlayingOriginalImage'] = $plexItem['nowPlayingImageURL'] = "plugins/images/homepage/no-np.png";
 			$plexItem['nowPlayingKey'] = "no-np";
 		}
 		if (!$plexItem['thumb'] || $plexItem['addedAt'] >= (time() - 300)) {
-			$plexItem['originalImage'] = $plexItem['imageURL'] = "plugins/images/cache/no-list.png";
+			$plexItem['originalImage'] = $plexItem['imageURL'] = "plugins/images/homepage/no-list.png";
 			$plexItem['key'] = "no-list";
 		}
 		if (isset($useImage)) {
@@ -641,7 +648,7 @@ trait PlexHomepageItem
 		}
 		return $plexItem;
 	}
-	
+
 	public function getTautulliFriendlyNames($bypass = null)
 	{
 		$names = [];
@@ -666,7 +673,7 @@ trait PlexHomepageItem
 		$this->setAPIResponse('success', null, 200, $names);
 		return $names;
 	}
-	
+
 	public function setTautulliFriendlyNames()
 	{
 		if ($this->config['tautulliURL'] && $this->config['tautulliApikey'] && $this->config['homepageUseCustomStreamNames']) {
@@ -680,7 +687,7 @@ trait PlexHomepageItem
 			}
 		}
 	}
-	
+
 	private function formatPlexUserName($item)
 	{
 		$name = ($this->config['homepageShowStreamNames'] && $this->qualifyRequest($this->config['homepageShowStreamNamesAuth'])) ? (string)$item->User['title'] : "";
@@ -696,10 +703,10 @@ trait PlexHomepageItem
 		}
 		return $name;
 	}
-	
+
 	public function plexLibraryList($value = 'id')
 	{
-		
+
 		if (!empty($this->config['plexToken']) && !empty($this->config['plexID'])) {
 			$url = 'https://plex.tv/api/servers/' . $this->config['plexID'];
 			try {

+ 27 - 8
api/homepage/radarr.php

@@ -52,6 +52,12 @@ trait RadarrHomepageItem
 					$this->settingsOption('switch', 'radarrPhysicalRelease', ['label' => 'Show Physical Releases']),
 					$this->settingsOption('switch', 'radarrDigitalRelease', ['label' => 'Show Digital Releases']),
 					$this->settingsOption('switch', 'radarrCinemaRelease', ['label' => 'Show Cinema Releases']),
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('enable', 'radarrIcon', ['label' => 'Show Radarr Icon']),
+					$this->settingsOption('calendar-link-url', 'radarrCalendarLink'),
+					$this->settingsOption('blank'),
+					$this->settingsOption('calendar-frame-target', 'radarrFrameTarget')
 				],
 				'Test Connection' => [
 					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
@@ -283,7 +289,7 @@ trait RadarrHomepageItem
 				} else {
 					$downloaded = "text-danger";
 				}
-				$banner = "/plugins/images/cache/no-np.png";
+				$banner = "/plugins/images/homepage/no-np.png";
 				foreach ($child['images'] as $image) {
 					if ($image['coverType'] == "banner" || $image['coverType'] == "fanart") {
 						if (strpos($image['url'], '://') === false) {
@@ -300,11 +306,11 @@ trait RadarrHomepageItem
 						}
 					}
 				}
-				if ($banner !== "/plugins/images/cache/no-np.png" || (strpos($banner, 'apikey') !== false)) {
-					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+				if ($banner !== "/plugins/images/homepage/no-np.png" || (strpos($banner, 'apikey') !== false)) {
+					$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 					$imageURL = $banner;
 					$cacheFile = $cacheDirectory . $movieID . '.jpg';
-					$banner = 'plugins/images/cache/' . $movieID . '.jpg';
+					$banner = 'data/cache/' . $movieID . '.jpg';
 					if (!file_exists($cacheFile)) {
 						$this->cacheImage($imageURL, $movieID);
 						unset($imageURL);
@@ -322,6 +328,15 @@ trait RadarrHomepageItem
 					}
 				}
 				$alternativeTitles = empty($alternativeTitles) ? "" : substr($alternativeTitles, 0, -2);
+				$href = $this->config['radarrCalendarLink'] ?? '';
+				if (empty($href) && !empty($this->config['radarrURL'])) {
+					$href_arr = explode(',', $this->config['radarrURL']);
+					$href = reset($href_arr);
+				}
+				if (!empty($href)) {
+					$href = $href . '/movie/' . $movieID;
+					$href = str_replace("//movie/", "/movie/", $href);
+				}
 				$details = array(
 					"topTitle" => $movieName,
 					"bottomTitle" => $alternativeTitles,
@@ -329,15 +344,19 @@ trait RadarrHomepageItem
 					"overview" => $child['overview'],
 					"runtime" => $child['runtime'],
 					"image" => $banner,
-					"ratings" => $child['ratings']['value'],
+					"ratings" => $child['ratings']['value'] ?? 0,
 					"videoQuality" => $child["hasFile"] ? @$child['movieFile']['quality']['quality']['name'] : "unknown",
 					"audioChannels" => $child["hasFile"] ? @$child['movieFile']['mediaInfo']['audioChannels'] : "unknown",
 					"audioCodec" => $child["hasFile"] ? @$child['movieFile']['mediaInfo']['audioFormat'] : "unknown",
 					"videoCodec" => $child["hasFile"] ? @$child['movieFile']['mediaInfo']['videoCodec'] : "unknown",
 					"size" => $child["hasFile"] ? @$child['movieFile']['size'] : "unknown",
 					"genres" => $child['genres'],
-					"year" => isset($child['year']) ? $child['year'] : '',
-					"studio" => isset($child['studio']) ? $child['studio'] : '',
+					"year" => $child['year'] ?? '',
+					"studio" => $child['studio'] ?? '',
+					"href" => strtolower($href),
+					"icon" => "/plugins/images/tabs/radarr.png",
+					"frame" => $this->config['radarrFrameTarget'],
+					"showLink" => $this->config['radarrIcon']
 				);
 				array_push($gotCalendar, array(
 					"id" => "Radarr-" . $number . "-" . $i,
@@ -358,4 +377,4 @@ trait RadarrHomepageItem
 		}
 		return false;
 	}
-}
+}

+ 8 - 8
api/homepage/rtorrent.php

@@ -4,7 +4,7 @@ trait RTorrentHomepageItem
 {
 	public function rTorrentSettingsArray($infoOnly = false)
 	{
-		
+
 		$homepageInformation = [
 			'name' => 'rTorrent',
 			'enabled' => strpos('personal', $this->config['license']) !== false,
@@ -77,7 +77,7 @@ trait RTorrentHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionRTorrent()
 	{
 		if (empty($this->config['rTorrentURL']) && empty($this->config['rTorrentURLOverride'])) {
@@ -89,7 +89,7 @@ trait RTorrentHomepageItem
 			$extraPath = (strpos($this->config['rTorrentURL'], '.php') !== false) ? '' : '/RPC2';
 			$extraPath = (empty($this->config['rTorrentURLOverride'])) ? $extraPath : '';
 			$url = $digest['scheme'] . '://' . $digest['host'] . $digest['port'] . $digest['path'] . $extraPath;
-			$options = $this->requestOptions($url, null, $this->config['rTorrentDisableCertCheck'], $this->config['rtorrentUseCustomCertificate']);
+			$options = $this->requestOptions($url, null, $this->config['rTorrentDisableCertCheck'], $this->config['rTorrentUseCustomCertificate']);
 			if ($this->config['rTorrentUsername'] !== '' && $this->decrypt($this->config['rTorrentPassword']) !== '') {
 				$credentials = array('auth' => new Requests_Auth_Digest(array($this->config['rTorrentUsername'], $this->decrypt($this->config['rTorrentPassword']))));
 				$options = array_merge($options, $credentials);
@@ -112,7 +112,7 @@ trait RTorrentHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function rTorrentHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -128,7 +128,7 @@ trait RTorrentHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrderrTorrent()
 	{
 		if ($this->homepageItemPermissions($this->rTorrentHomepagePermissions('main'))) {
@@ -147,7 +147,7 @@ trait RTorrentHomepageItem
 				';
 		}
 	}
-	
+
 	public function checkOverrideURL($url, $override)
 	{
 		if (strpos($override, $url) !== false) {
@@ -156,7 +156,7 @@ trait RTorrentHomepageItem
 			return $url . $override;
 		}
 	}
-	
+
 	public function rTorrentStatus($completed, $state, $status)
 	{
 		if ($completed && $state && $status == 'seed') {
@@ -170,7 +170,7 @@ trait RTorrentHomepageItem
 		}
 		return ($state) ? $state : $status;
 	}
-	
+
 	public function getRTorrentHomepageQueue()
 	{
 		if (empty($this->config['rTorrentURL']) && empty($this->config['rTorrentURLOverride'])) {

+ 20 - 21
api/homepage/sickrage.php

@@ -43,7 +43,7 @@ trait SickRageHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionSickRage()
 	{
 		if (empty($this->config['sickrageURL'])) {
@@ -75,7 +75,6 @@ trait SickRageHomepageItem
 					$errors .= $ip . ': Response was not JSON';
 					$failed = true;
 				}
-				
 			} catch (Exception $e) {
 				$failed = true;
 				$ip = $value['url'];
@@ -91,7 +90,7 @@ trait SickRageHomepageItem
 			return true;
 		}
 	}
-	
+
 	public function sickrageHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -110,7 +109,7 @@ trait SickRageHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function getSickRageCalendar($startDate = null, $endDate = null)
 	{
 		if (!$this->homepageItemPermissions($this->sickrageHomepagePermissions('calendar'), true)) {
@@ -137,7 +136,7 @@ trait SickRageHomepageItem
 		$this->setAPIResponse('success', null, 200, $calendarItems);
 		return $calendarItems;
 	}
-	
+
 	public function formatSickrageCalendarWanted($array, $number)
 	{
 		$array = json_decode($array, true);
@@ -165,11 +164,11 @@ trait SickRageHomepageItem
 				$downloaded = "text-danger";
 			}
 			$bottomTitle = 'S' . sprintf("%02d", $child['season']) . 'E' . sprintf("%02d", $child['episode']) . ' - ' . $child['ep_name'];
-			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 			$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			if (file_exists($cacheFile)) {
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				unset($cacheFile);
 			}
 			$details = array(
@@ -222,11 +221,11 @@ trait SickRageHomepageItem
 				$downloaded = "text-danger";
 			}
 			$bottomTitle = 'S' . sprintf("%02d", $child['season']) . 'E' . sprintf("%02d", $child['episode']) . ' - ' . $child['ep_name'];
-			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 			$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			if (file_exists($cacheFile)) {
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				unset($cacheFile);
 			}
 			$details = array(
@@ -279,11 +278,11 @@ trait SickRageHomepageItem
 				$downloaded = "text-danger";
 			}
 			$bottomTitle = 'S' . sprintf("%02d", $child['season']) . 'E' . sprintf("%02d", $child['episode']) . ' - ' . $child['ep_name'];
-			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 			$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			if (file_exists($cacheFile)) {
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				unset($cacheFile);
 			}
 			$details = array(
@@ -336,11 +335,11 @@ trait SickRageHomepageItem
 				$downloaded = "text-danger";
 			}
 			$bottomTitle = 'S' . sprintf("%02d", $child['season']) . 'E' . sprintf("%02d", $child['episode']) . ' - ' . $child['ep_name'];
-			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 			$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			if (file_exists($cacheFile)) {
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				unset($cacheFile);
 			}
 			$details = array(
@@ -376,7 +375,7 @@ trait SickRageHomepageItem
 		}
 		return false;
 	}
-	
+
 	public function formatSickrageCalendarHistory($array, $number)
 	{
 		$array = json_decode($array, true);
@@ -390,11 +389,11 @@ trait SickRageHomepageItem
 			$episodeAirDate = $child['date'];
 			$downloaded = "text-success";
 			$bottomTitle = 'S' . sprintf("%02d", $child['season']) . 'E' . sprintf("%02d", $child['episode']);
-			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 			$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			if (file_exists($cacheFile)) {
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				unset($cacheFile);
 			}
 			$details = array(

+ 25 - 5
api/homepage/sonarr.php

@@ -52,7 +52,14 @@ trait SonarrHomepageItem
 					$this->settingsOption('calendar-locale', 'calendarLocale'),
 					$this->settingsOption('calendar-limit', 'calendarLimit'),
 					$this->settingsOption('refresh', 'calendarRefresh'),
+					$this->settingsOption('blank'),
 					$this->settingsOption('switch', 'sonarrUnmonitored', ['label' => 'Show Unmonitored']),
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('blank', '', ['type' => 'html', 'html' => '<hr />']),
+					$this->settingsOption('enable', 'sonarrIcon', ['label' => 'Show Sonarr Icon']),
+					$this->settingsOption('calendar-link-url', 'sonarrCalendarLink'),
+					$this->settingsOption('blank'),
+					$this->settingsOption('calendar-frame-target', 'sonarrFrameTarget')
 				],
 				'Test Connection' => [
 					$this->settingsOption('blank', null, ['label' => 'Please Save before Testing']),
@@ -271,17 +278,17 @@ trait SonarrHomepageItem
 			} else {
 				$downloaded = "text-danger";
 			}
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			foreach ($child['series']['images'] as $image) {
 				if ($image['coverType'] == "fanart") {
 					$fanart = $image['url'];
 				}
 			}
-			if ($fanart !== "/plugins/images/cache/no-np.png" || (strpos($fanart, '://') === false)) {
-				$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
+			if ($fanart !== "/plugins/images/homepage/no-np.png" || (strpos($fanart, '://') === false)) {
+				$cacheDirectory = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'cache' . DIRECTORY_SEPARATOR;
 				$imageURL = $fanart;
 				$cacheFile = $cacheDirectory . $seriesID . '.jpg';
-				$fanart = 'plugins/images/cache/' . $seriesID . '.jpg';
+				$fanart = 'data/cache/' . $seriesID . '.jpg';
 				if (!file_exists($cacheFile)) {
 					$this->cacheImage($imageURL, $seriesID);
 					unset($imageURL);
@@ -289,6 +296,15 @@ trait SonarrHomepageItem
 				}
 			}
 			$bottomTitle = 'S' . sprintf("%02d", $child['seasonNumber']) . 'E' . sprintf("%02d", $child['episodeNumber']) . ' - ' . $child['title'];
+			$href = $this->config['sonarrCalendarLink'] ?? '';
+			if (empty($href) && !empty($this->config['sonarrURL'])) {
+				$href_arr = explode(',', $this->config['sonarrURL']);
+				$href = reset($href_arr);
+			}
+			if (!empty($href)) {
+				$href = $href . '/series/' . preg_replace('/[^A-Za-z0-9 -]/', '', str_replace('&', 'and', preg_replace('/[[:space:]]+/', '-', $seriesName)));
+				$href = str_replace("//series/", "/series/", $href);
+			}
 			$details = array(
 				"seasonCount" => $child['series']['seasonCount'] ?? isset($child['series']['seasons']) ? count($child['series']['seasons']) : 0,
 				"status" => $child['series']['status'],
@@ -304,6 +320,10 @@ trait SonarrHomepageItem
 				"videoCodec" => $child["hasFile"] && isset($child['episodeFile']['mediaInfo']) ? $child['episodeFile']['mediaInfo']['videoCodec'] : "unknown",
 				"size" => $child["hasFile"] && isset($child['episodeFile']['size']) ? $child['episodeFile']['size'] : "unknown",
 				"genres" => $child['series']['genres'],
+				"href" => strtolower($href),
+				"icon" => "/plugins/images/tabs/sonarr.png",
+				"frame" => $this->config['sonarrFrameTarget'],
+				"showLink" => $this->config['sonarrIcon']
 			);
 			array_push($gotCalendar, array(
 				"id" => "Sonarr-" . $number . "-" . $i,
@@ -322,4 +342,4 @@ trait SonarrHomepageItem
 		}
 		return false;
 	}
-}
+}

+ 29 - 22
api/homepage/tautulli.php

@@ -14,12 +14,16 @@ trait TautulliHomepageItem
 		if ($infoOnly) {
 			return $homepageInformation;
 		}
+
 		$libraryList = [['name' => 'Refresh page to update List', 'value' => '', 'disabled' => true]];
 		if (!empty($this->config['tautulliApikey']) && !empty($this->config['tautulliURL'])) {
 			$libraryList = [];
-			$loop = $this->tautulliLibraryList('key')['libraries'];
-			foreach ($loop as $key => $value) {
-				$libraryList[] = ['name' => $key, 'value' => $value];
+			$loop = $this->tautulliLibraryList();
+			if ($loop) {
+				$loop = $loop['libraries'];
+				foreach ($loop as $key => $value) {
+					$libraryList[] = ['name' => $key, 'value' => $value];
+				}
 			}
 		}
 		$homepageSettings = [
@@ -73,7 +77,7 @@ trait TautulliHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function testConnectionTautulli()
 	{
 		$this->setLoggerChannel('Tautulli Homepage');
@@ -104,7 +108,7 @@ trait TautulliHomepageItem
 			return false;
 		}
 	}
-	
+
 	public function tautulliHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -123,7 +127,7 @@ trait TautulliHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function homepageOrdertautulli()
 	{
 		if ($this->homepageItemPermissions($this->tautulliHomepagePermissions('main'))) {
@@ -139,7 +143,7 @@ trait TautulliHomepageItem
 				';
 		}
 	}
-	
+
 	public function getTautulliHomepageData()
 	{
 		$this->setLoggerChannel('Tautulli Homepage');
@@ -158,12 +162,12 @@ trait TautulliHomepageItem
 			$options = $this->requestOptions($this->config['tautulliURL'], $this->config['homepageTautulliRefresh'], $this->config['tautulliDisableCertCheck'], $this->config['tautulliUseCustomCertificate']);
 			$homestats = Requests::get($homestatsUrl, [], $options);
 			if ($homestats->success) {
-				$homepageTautulliViewingStatsExclude = explode(",",$this->config['homepageTautulliViewingStatsExclude']);
+				$homepageTautulliViewingStatsExclude = explode(",", $this->config['homepageTautulliViewingStatsExclude']);
 				$homestats = json_decode($homestats->body, true);
 				foreach ($homestats['response']['data'] as $s => $stats) {
 					foreach ($stats['rows'] as $i => $v) {
 						if (array_key_exists('section_id', $v)) {
-							if (in_array($v['section_id'],$homepageTautulliViewingStatsExclude)) {
+							if (in_array($v['section_id'], $homepageTautulliViewingStatsExclude)) {
 								unset($homestats['response']['data'][$s]['rows'][$i]);
 							}
 						}
@@ -175,27 +179,31 @@ trait TautulliHomepageItem
 				$categories = ['top_movies', 'top_tv', 'popular_movies', 'popular_tv'];
 				foreach ($categories as $cat) {
 					$key = array_search($cat, array_column($api['homestats']['data'], 'stat_id'));
-					$img = $api['homestats']['data'][$key]['rows'][0];
-					$this->cacheImage($url . '/pms_image_proxy?img=' . $img['art'] . '&rating_key=' . $img['rating_key'] . '&width=' . $nowPlayingWidth . '&height=' . $nowPlayingHeight, $img['rating_key'] . '-np');
-					$this->cacheImage($url . '/pms_image_proxy?img=' . $img['thumb'] . '&rating_key=' . $img['rating_key'] . '&width=' . $width . '&height=' . $height, $img['rating_key'] . '-list');
-					$img['art'] = 'plugins/images/cache/' . $img['rating_key'] . '-np.jpg';
-					$img['thumb'] = 'plugins/images/cache/' . $img['rating_key'] . '-list.jpg';
-					$api['homestats']['data'][$key]['rows'][0] = $img;
+					if (count($api['homestats']['data'][$key]['rows']) > 0) {
+						$img = $api['homestats']['data'][$key]['rows'][0];
+						$this->cacheImage($url . '/pms_image_proxy?img=' . $img['art'] . '&rating_key=' . $img['rating_key'] . '&width=' . $nowPlayingWidth . '&height=' . $nowPlayingHeight, $img['rating_key'] . '-np');
+						$this->cacheImage($url . '/pms_image_proxy?img=' . $img['thumb'] . '&rating_key=' . $img['rating_key'] . '&width=' . $width . '&height=' . $height, $img['rating_key'] . '-list');
+						$img['art'] = 'data/cache/' . $img['rating_key'] . '-np.jpg';
+						$img['thumb'] = 'data/cache/' . $img['rating_key'] . '-list.jpg';
+						$api['homestats']['data'][$key]['rows'][0] = $img;
+					}
 				}
 				// Cache the platform icon
-				$key = array_search('top_platforms', array_column($api['homestats']['data'], 'stat_id'));
-				$platform = $api['homestats']['data'][$key]['rows'][0]['platform_name'];
-				$this->cacheImage($url . '/images/platforms/' . $platform . '.svg', 'tautulli-' . $platform, 'svg');
+				if (count($api['homestats']['data'][$key]['rows']) > 0) {
+					$key = array_search('top_platforms', array_column($api['homestats']['data'], 'stat_id'));
+					$platform = $api['homestats']['data'][$key]['rows'][0]['platform_name'];
+					$this->cacheImage($url . '/images/platforms/' . $platform . '.svg', 'tautulli-' . $platform, 'svg');
+				}
 			}
 			$libstatsUrl = $apiURL . '&cmd=get_libraries_table';
 			$options = $this->requestOptions($this->config['tautulliURL'], $this->config['homepageTautulliRefresh'], $this->config['tautulliDisableCertCheck'], $this->config['tautulliUseCustomCertificate']);
 			$libstats = Requests::get($libstatsUrl, [], $options);
 			if ($libstats->success) {
-				$homepageTautulliLibraryStatsExclude = explode(",",$this->config['homepageTautulliLibraryStatsExclude']);
+				$homepageTautulliLibraryStatsExclude = explode(",", $this->config['homepageTautulliLibraryStatsExclude']);
 				$libstats = json_decode($libstats->body, true);
 				foreach ($libstats['response']['data']['data'] as $i => $v) {
 					if (array_key_exists('section_id', $v)) {
-						if (in_array($v['section_id'],$homepageTautulliLibraryStatsExclude)) {
+						if (in_array($v['section_id'], $homepageTautulliLibraryStatsExclude)) {
 							unset($libstats['response']['data']['data'][$i]);
 						}
 					}
@@ -275,10 +283,9 @@ trait TautulliHomepageItem
 					return $libraryList;
 				}
 			} catch (Requests_Exception $e) {
-				$this->setAPIResponse('error', 'Tautulli Homepage Error - Unable to get list of libraries: '.$e->getMessage(), 500);
 				$this->writeLog('error', 'Tautulli Homepage Error - Unable to get list of libraries: ' . $e->getMessage(), 'SYSTEM');
 				return false;
-			};
+			}
 		}
 		return false;
 	}

+ 6 - 7
api/homepage/trakt.php

@@ -60,7 +60,7 @@ trait TraktHomepageItem
 		];
 		return array_merge($homepageInformation, $homepageSettings);
 	}
-	
+
 	public function traktHomepagePermissions($key = null)
 	{
 		$permissions = [
@@ -79,7 +79,7 @@ trait TraktHomepageItem
 		];
 		return $this->homepageCheckKeyPermissions($key, $permissions);
 	}
-	
+
 	public function getTraktCalendar($startDate = null)
 	{
 		$startDate = date('Y-m-d', strtotime('-' . $this->config['calendarStartTrakt'] . ' days'));
@@ -106,7 +106,6 @@ trait TraktHomepageItem
 					$calendarItems = array_merge($calendarItems, $traktTv);
 				}
 			}
-			
 		} catch (Requests_Exception $e) {
 			$this->writeLog('error', 'Trakt Connect Function - Error: ' . $e->getMessage(), 'SYSTEM');
 			$this->setAPIResponse('error', $e->getMessage(), 500);
@@ -135,7 +134,7 @@ trait TraktHomepageItem
 		$this->traktOAuthRefresh();
 		return $calendarItems;
 	}
-	
+
 	public function formatTraktCalendarTv($array)
 	{
 		$gotCalendar = array();
@@ -177,7 +176,7 @@ trait TraktHomepageItem
 			} else {
 				$downloaded = "text-danger";
 			}
-			$fanart = "/plugins/images/cache/no-np.png";
+			$fanart = "/plugins/images/homepage/no-np.png";
 			$bottomTitle = 'S' . sprintf("%02d", $child['episode']['season']) . 'E' . sprintf("%02d", $child['episode']['number']) . ' - ' . $child['episode']['title'];
 			$details = array(
 				"seasonCount" => $child['episode']['season'],
@@ -212,7 +211,7 @@ trait TraktHomepageItem
 		}
 		return false;
 	}
-	
+
 	public function formatTraktCalendarMovies($array)
 	{
 		$gotCalendar = array();
@@ -240,7 +239,7 @@ trait TraktHomepageItem
 				$notReleased = 'false';
 			}
 			$downloaded = 'text-dark';
-			$banner = '/plugins/images/cache/no-np.png';
+			$banner = '/plugins/images/homepage/no-np.png';
 			$details = array(
 				'topTitle' => $movieName,
 				'bottomTitle' => $child['movie']['tagline'],

+ 0 - 43
api/pages/custom/index.html

@@ -1,43 +0,0 @@
-Place all custom page files here in this directory....
-
-Name file something like "custom_code_presentation.php" and make sure to start page contents like so:
-<pre>
-&lt;?php
-    /*
-     * Make sure to edit "name_here" with your page name - i.e. custom_code_presentation
-     * You will edit on both "$GLOBALS['organizrPages'][] = 'name_here';" and "function get_page_name_here($Organizr)"
-     */
-    $GLOBALS['organizrPages'][] = 'name_here';
-    function get_page_name_here($Organizr)
-    {
-        if (!$Organizr) {
-            $Organizr = new Organizr();
-        }
-        /*
-         * Take this out if you dont care if DB has been created
-         */
-        if ((!$Organizr->hasDB())) {
-            return false;
-        }
-        /*
-         * Take this out if you dont want to be for admin only
-         */
-        if (!$Organizr->qualifyRequest(1, true)) {
-            return false;
-        }
-        return '
-            &#x3C;script&#x3E;
-                // Custom JS here
-            &#x3C;/script&#x3E;
-            &#x3C;div class=&#x22;panel bg-org panel-info&#x22;&#x3E;
-                &#x3C;div class=&#x22;panel-heading&#x22;&#x3E;
-                    &#x3C;span lang=&#x22;en&#x22;&#x3E;Template&#x3C;/span&#x3E;
-                &#x3C;/div&#x3E;
-                &#x3C;div class=&#x22;panel-wrapper collapse in&#x22; aria-expanded=&#x22;true&#x22;&#x3E;
-                    &#x3C;div class=&#x22;panel-body bg-org&#x22;&#x3E;
-                    &#x3C;/div&#x3E;
-                &#x3C;/div&#x3E;
-            &#x3C;/div&#x3E;
-        ';
-    }
-</pre>

+ 1 - 0
api/pages/settings-settings-logs.php

@@ -16,6 +16,7 @@ function get_page_settings_settings_logs($Organizr)
 	return '
 	<div class="btn-group m-b-20 pull-left">' . $logsDropdown . '</div>
 	<button class="btn btn-danger waves-effect waves-light pull-right purgeLog" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Purge Log"><i class="fa fa-trash"></i></span></button>
+	<button onclick="shortcut(\'log-settings\')" class="btn btn-inverse waves-effect waves-light pull-right m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Log Settings"><i class="fa fa-cog"></i></span></button>
 	<button onclick="organizrLogTable.clear().draw().ajax.reload(null, false)" class="btn btn-info waves-effect waves-light pull-right reloadLog m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Reload Log"><i class="fa fa-refresh"></i></span></button>
 	<button onclick="toggleKillOrganizrLiveUpdate(' . $Organizr->config['logLiveUpdateRefresh'] . ');" class="btn btn-primary waves-effect waves-light pull-right organizr-log-live-update m-r-5" type="button" data-toggle="tooltip" data-placement="bottom" title="" data-original-title="Live Update"><i class="fa fa-clock-o"></i></span></button>
 	' . $filterDropdown . '

+ 1 - 1
api/pages/settings-tab-editor-categories.php

@@ -86,7 +86,7 @@ let sortable = new Sortable(el, {
 	</div>
 	<div class="table-responsive">
 		<form id="submit-categories-form" onsubmit="return false;">
-			<table class="table table-hover manage-u-table">
+			<table class="table table-hover manage-u-table m-b-0">
 				<thead>
 					<tr>
 						<th width="70" class="text-center">#</th>

+ 3 - 3
api/pages/settings-tab-editor-tabs.php

@@ -79,7 +79,7 @@ function get_page_settings_tab_editor_tabs($Organizr)
 		</div>
 		<div class="table-responsive">
 			<form id="submit-tabs-form" onsubmit="return false;">
-				<table class="table table-hover manage-u-table">
+				<table class="table table-hover manage-u-table m-b-0">
 					<thead>
 						<tr>
 							<th width="20" class="text-center"></th>
@@ -121,11 +121,11 @@ function get_page_settings_tab_editor_tabs($Organizr)
 			</div>
 			<div class="form-group">
 				<label class="control-label" for="new-tab-form-inputURLNew" lang="en">Tab URL</label>
-				<input type="text" class="form-control" id="new-tab-form-inputURLNew" name="url"  required="">
+				<input type="text" class="form-control" id="new-tab-form-inputURLNew" name="url" placeholder="http(s)://domain.com" required="">
 			</div>
 			<div class="form-group">
 				<label class="control-label" for="new-tab-form-inputURLLocalNew" lang="en">Tab Local URL</label>
-				<input type="text" class="form-control" id="new-tab-form-inputURLLocalNew" name="url_local">
+				<input type="text" class="form-control" id="new-tab-form-inputURLLocalNew" name="url_local" placeholder="http://192.168.0.1">
 			</div>
 			<div class="form-group">
 				<label class="control-label" for="new-tab-form-inputPingURLNew" lang="en">Ping URL</label>

+ 2 - 2
api/pages/wizard.php

@@ -282,8 +282,8 @@ function get_page_wizard($Organizr)
                                     <div class="panel-wrapper collapse in" aria-expanded="true">
                                         <div class="panel-body">
                                             <p lang="en">The Database will contain sensitive information.  Please place in directory outside of root Web Directory.</p>
-                                            <p lang="en">Suggested Directory: <code>' . dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'db</code> <a class="btn default btn-outline clipboard p-a-5" data-clipboard-text="' . dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'db" href="javascript:void(0);"><i class="ti-clipboard"></i></a></p>
-                                            <p lang="en">Current Directory: <code>' . dirname(__DIR__, 2) . '</code> <a class="btn default btn-outline clipboard p-a-5" data-clipboard-text="' . dirname(__DIR__, 2) . '" href="javascript:void(0);"><i class="ti-clipboard"></i></a></p>
+                                            <p lang="en">Suggested Directory: <code>' . dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . $Organizr->random_ascii_string(10) . DIRECTORY_SEPARATOR . '</code> <a class="btn default btn-outline clipboard p-a-5" data-clipboard-text="' . dirname(__DIR__, 3) . DIRECTORY_SEPARATOR . 'db" href="javascript:void(0);"><i class="ti-clipboard"></i></a></p>
+                                            <p lang="en">Current Directory: <code>' . dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . '</code> <a class="btn default btn-outline clipboard p-a-5" data-clipboard-text="' . dirname(__DIR__, 2) . '" href="javascript:void(0);"><i class="ti-clipboard"></i></a></p>
                                             <p lang="en">Parent Directory: <code>' . dirname(__DIR__, 3) . '</code> <a class="btn default btn-outline clipboard p-a-5" data-clipboard-text="' . dirname(__DIR__, 3) . '" href="javascript:void(0);"><i class="ti-clipboard"></i></a></p>
                                         </div>
                                     </div>

+ 17 - 2
api/v2/index.php

@@ -99,8 +99,10 @@ foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . '
 /*
  * Include all custom routes
  */
-foreach (glob(__DIR__ . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'custom' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
-	require_once $filename;
+if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'routes')) {
+	foreach (glob(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . '*.php') as $filename) {
+		require_once $filename;
+	}
 }
 /*
  * Include all Plugin routes
@@ -113,6 +115,19 @@ foreach ($iteratorIterator as $info) {
 		require_once $info->getPathname();
 	}
 }
+/*
+ * Include all custom Plugin routes
+ */
+if (file_exists(dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins')) {
+	$folder = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR . 'plugins';
+	$directoryIterator = new RecursiveDirectoryIterator($folder, FilesystemIterator::SKIP_DOTS);
+	$iteratorIterator = new RecursiveIteratorIterator($directoryIterator);
+	foreach ($iteratorIterator as $info) {
+		if ($info->getFilename() == 'api.php') {
+			require_once $info->getPathname();
+		}
+	}
+}
 /*
  *
  *  This is the last defined api endpoint to catch all undefined endpoints

+ 22 - 24
api/v2/routes/connectionTester.php

@@ -26,7 +26,6 @@ $app->post('/test/ldap', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/ldap/login', function ($request, $response, $args) {
 	/**
@@ -49,7 +48,6 @@ $app->post('/test/ldap/login', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/iframe', function ($request, $response, $args) {
 	/**
@@ -72,7 +70,6 @@ $app->post('/test/iframe', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/path', function ($request, $response, $args) {
 	/**
@@ -92,7 +89,6 @@ $app->post('/test/path', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/plex', function ($request, $response, $args) {
 	/**
@@ -115,7 +111,6 @@ $app->post('/test/plex', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/emby', function ($request, $response, $args) {
 	/**
@@ -138,7 +133,6 @@ $app->post('/test/emby', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/jellyfin', function ($request, $response, $args) {
 	/**
@@ -161,7 +155,6 @@ $app->post('/test/jellyfin', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/sabnzbd', function ($request, $response, $args) {
 	/**
@@ -184,7 +177,6 @@ $app->post('/test/sabnzbd', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/pihole', function ($request, $response, $args) {
 	/**
@@ -207,7 +199,6 @@ $app->post('/test/pihole', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/rtorrent', function ($request, $response, $args) {
 	/**
@@ -230,7 +221,6 @@ $app->post('/test/rtorrent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/sonarr', function ($request, $response, $args) {
 	/**
@@ -253,7 +243,6 @@ $app->post('/test/sonarr', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/radarr', function ($request, $response, $args) {
 	/**
@@ -276,7 +265,6 @@ $app->post('/test/radarr', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/lidarr', function ($request, $response, $args) {
 	/**
@@ -299,7 +287,6 @@ $app->post('/test/lidarr', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/sickrage', function ($request, $response, $args) {
 	/**
@@ -322,7 +309,6 @@ $app->post('/test/sickrage', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/ombi', function ($request, $response, $args) {
 	/**
@@ -345,7 +331,6 @@ $app->post('/test/ombi', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/overseerr', function ($request, $response, $args) {
 	/**
@@ -368,7 +353,6 @@ $app->post('/test/overseerr', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/nzbget', function ($request, $response, $args) {
 	/**
@@ -391,7 +375,6 @@ $app->post('/test/nzbget', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/utorrent', function ($request, $response, $args) {
 	/**
@@ -414,7 +397,6 @@ $app->post('/test/utorrent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/deluge', function ($request, $response, $args) {
 	/**
@@ -437,7 +419,6 @@ $app->post('/test/deluge', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/jdownloader', function ($request, $response, $args) {
 	/**
@@ -460,7 +441,6 @@ $app->post('/test/jdownloader', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/transmission', function ($request, $response, $args) {
 	/**
@@ -483,7 +463,6 @@ $app->post('/test/transmission', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/qbittorrent', function ($request, $response, $args) {
 	/**
@@ -506,7 +485,6 @@ $app->post('/test/qbittorrent', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/unifi', function ($request, $response, $args) {
 	/**
@@ -529,7 +507,6 @@ $app->post('/test/unifi', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/unifi/site', function ($request, $response, $args) {
 	/**
@@ -552,7 +529,6 @@ $app->post('/test/unifi/site', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });
 $app->post('/test/tautulli', function ($request, $response, $args) {
 	/**
@@ -624,4 +600,26 @@ $app->get('/test/cron', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/test/folder', function ($request, $response, $args) {
+	/**
+	 * @OA\Post(
+	 *     security={{ "api_key":{} }},
+	 *     tags={"test connection"},
+	 *     path="/api/v2/test/folder",
+	 *     summary="Test folder path",
+	 *     @OA\Response(response="200",description="Success",@OA\JsonContent(ref="#/components/schemas/success-message")),
+	 *     @OA\Response(response="401",description="Unauthorized",@OA\JsonContent(ref="#/components/schemas/unauthorized-message")),
+	 *     @OA\Response(response="422",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 *     @OA\Response(response="500",description="Error",@OA\JsonContent(ref="#/components/schemas/error-message")),
+	 * )
+	 */
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	if ($Organizr->qualifyRequest(1, true)) {
+		$Organizr->testFolder($Organizr->apiData($request));
+	}
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
 });

+ 0 - 27
api/v2/routes/custom/index.html

@@ -1,27 +0,0 @@
-Place all custom route files here in this directory....
-
-Name file something anything you like...
-<pre>
-&lt;?php
-
-    /*
-     * The first thing you need to edit it the <code>get</code> part - options are get/post/delete/put/options
-     * The second thing you need to edit is the </code>/something</code>
-     * This will be the endpoints name and will be accessible from: http://organizr/api/v2/custom/something
-     */
-
-    $app->get('/custom/something', function ($request, $response, $args) {
-        // Let's define the Organizr Class to the $Organizr variable
-        $Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
-        // Now let's set auth on our function, 1 is for co-admin and upto 999 is for Guest
-        if ($Organizr->qualifyRequest(1, true)) {
-            // Let's assign the api response with our function that holds our data...
-            $GLOBALS['api']['response']['data'] = $Organizr->getAllUsers();
-        }
-        // You do not need to change anything else below this line
-        $response->getBody()->write(jsonE($GLOBALS['api']));
-        return $response
-            ->withHeader('Content-Type', 'application/json;charset=UTF-8')
-            ->withStatus($GLOBALS['responseCode']);
-    });
-</pre>

+ 38 - 0
api/v2/routes/homepage.php

@@ -522,4 +522,42 @@ $app->get('/homepage/trakt/calendar', function ($request, $response, $args) {
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
+});
+$app->get('/homepage/donate/success', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$response->getBody()->write($Organizr->showHTML('Donation Success', 'Thank you for donating!', true));
+	return $response
+		->withHeader('Content-Type', 'text/html;charset=UTF-8')
+		->withStatus(200);
+});
+$app->get('/homepage/donate/cancel', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$response->getBody()->write($Organizr->showHTML('Donation Cancelled', 'Taking you back...', true));
+	return $response
+		->withHeader('Content-Type', 'text/html;charset=UTF-8')
+		->withStatus(200);
+});
+$app->get('/homepage/donate/error', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$response->getBody()->write($Organizr->showHTML('Donation Error', 'An error has occurred!', true));
+	return $response
+		->withHeader('Content-Type', 'text/html;charset=UTF-8')
+		->withStatus(500);
+});
+$app->get('/homepage/donate', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$Organizr->homepageDonateUserHistory();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
+$app->post('/homepage/donate', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$amount = $_GET['amount'] ?? 1000;
+	$Organizr->homepageDonateCreateSession($amount);
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
 });

+ 13 - 7
api/v2/routes/root.php

@@ -25,11 +25,7 @@ $app->get('/status', function ($request, $response, $args) {
 	 */
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	if ($Organizr->checkRoute($request)) {
-		$GLOBALS['api']['response']['data'] = array(
-			'status' => 'ok',
-			'api_version' => '2.0',
-			'organizr_version' => $Organizr->version
-		);
+		$GLOBALS['api']['response']['data'] = $Organizr->status(true);
 	}
 	$response->getBody()->write(jsonE($GLOBALS['api']));
 	return $response
@@ -111,6 +107,17 @@ $app->any('/auth/[{group}[/{type}[/{ips}]]]', function ($request, $response, $ar
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
 });
+$app->any('/organizr-auth/[{group}[/{type}[/{ips}]]]', function ($request, $response, $args) {
+	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
+	$_GET['group'] = $args['group'] ?? 0;
+	$_GET['type'] = $args['type'] ?? 'deny';
+	$_GET['ips'] = $args['ips'] ?? '192.0.0.0';
+	$Organizr->auth();
+	$response->getBody()->write(jsonE($GLOBALS['api']));
+	return $response
+		->withHeader('Content-Type', 'application/json;charset=UTF-8')
+		->withStatus($GLOBALS['responseCode']);
+});
 $app->get('/launch', function ($request, $response, $args) {
 	$Organizr = ($request->getAttribute('Organizr')) ?? new Organizr();
 	$tabInfo = $Organizr->getUserTabsAndCategories();
@@ -124,12 +131,11 @@ $app->get('/launch', function ($request, $response, $args) {
 	$GLOBALS['api']['response']['data']['settings'] = $Organizr->organizrSpecialSettings();
 	$GLOBALS['api']['response']['data']['plugins'] = $Organizr->pluginGlobalList();
 	$GLOBALS['api']['response']['data']['appearance'] = $Organizr->loadAppearance();
-	$GLOBALS['api']['response']['data']['status'] = $Organizr->status();
+	$GLOBALS['api']['response']['data']['status'] = $Organizr->launch();
 	$GLOBALS['api']['response']['data']['sso'] = $Organizr->ssoCookies();
 	$GLOBALS['api']['response']['data']['warnings'] = $Organizr->warnings;
 	$response->getBody()->write(jsonE($GLOBALS['api']));
 	return $response
 		->withHeader('Content-Type', 'application/json;charset=UTF-8')
 		->withStatus($GLOBALS['responseCode']);
-	
 });

+ 50 - 0
api/vendor/adldap2/adldap2/.github/workflows/tests.yaml

@@ -0,0 +1,50 @@
+name: Tests
+
+on:
+  push:
+    branches:
+      - master
+      - v9.0
+      - v8.0
+    paths-ignore:
+      - '**.md'
+  pull_request:
+    branches:
+      - master
+      - v9.0
+      - v8.0
+    paths-ignore:
+      - '**.md'
+  # Allow manually triggering the workflow
+  workflow_dispatch:
+
+jobs:
+  test:
+    runs-on: "ubuntu-latest"
+
+    strategy:
+      matrix:
+        php-version:
+          - "7.0"
+          - "7.1"
+          - "7.2"
+          - "7.3"
+          - "7.4"
+          - "8.0"
+          - "8.1"
+
+    name: PHP ${{ matrix.php-version }} tests
+
+    steps:
+      - uses: actions/checkout@v2
+
+      - uses: shivammathur/setup-php@v2
+        with:
+          php-version: ${{ matrix.php-version }}
+          extensions: ldap
+          coverage: none
+
+      - uses: "ramsey/composer-install@v1"
+
+      - name: "Run PHPUnit"
+        run: ./vendor/bin/simple-phpunit

+ 1 - 0
api/vendor/adldap2/adldap2/.gitignore

@@ -1,3 +1,4 @@
 /.idea
 /vendor
 composer.lock
+.phpunit.result.cache

+ 1 - 1
api/vendor/adldap2/adldap2/.travis.yml

@@ -10,7 +10,7 @@ before_script:
   - travis_retry composer self-update
   - travis_retry composer install --prefer-source --no-interaction
 
-script: ./vendor/bin/phpunit
+script: ./vendor/bin/simple-phpunit
 
 branches:
   only:

+ 5 - 0
api/vendor/adldap2/adldap2/SECURITY.md

@@ -0,0 +1,5 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please report security issues to `steven_bauman@outlook.com`

+ 8 - 5
api/vendor/adldap2/adldap2/composer.json

@@ -29,20 +29,23 @@
         "php": ">=7.0",
         "ext-ldap": "*",
         "ext-json": "*",
-        "psr/log": "~1.0",
-        "psr/simple-cache": "~1.0",
+        "psr/log": "~1.0|~2.0|~3.0",
+        "psr/simple-cache": "~1.0|~2.0",
         "tightenco/collect": "~5.0|~6.0|~7.0|~8.0",
-        "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0"
+        "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0|~9.0"
     },
     "require-dev": {
-        "phpunit/phpunit": "~6.0|~7.0|~8.0",
+        "symfony/phpunit-bridge": "~5.2|~6.0",
         "mockery/mockery": "~1.0"
     },
     "suggest": {
         "ext-fileinfo": "fileinfo is required when retrieving user encoded thumbnails"
     },
     "archive": {
-        "exclude": ["/examples", "/tests"]
+        "exclude": [
+            "/examples",
+            "/tests"
+        ]
     },
     "autoload": {
         "psr-4": {

+ 4 - 0
api/vendor/adldap2/adldap2/phpunit.xml

@@ -19,4 +19,8 @@
             <directory suffix=".php">./src</directory>
         </whitelist>
     </filter>
+    <php>
+        <env name="SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT" value="1" />
+        <env name="SYMFONY_DEPRECATIONS_HELPER" value="/.*/" />
+    </php>
 </phpunit>

+ 12 - 4
api/vendor/adldap2/adldap2/readme.md

@@ -23,7 +23,7 @@
 <h1 align="center">Adldap2</h1>
 
 <p align="center">
-    <a href="https://travis-ci.org/Adldap2/Adldap2"><img src="https://img.shields.io/travis/Adldap2/Adldap2.svg?style=flat-square"/></a>
+    <a href="https://github.com/Adldap2/Adldap2/actions"><img src="https://img.shields.io/github/workflow/status/adldap2/adldap2/Tests.svg?style=flat-square"/></a>
     <a href="https://scrutinizer-ci.com/g/Adldap2/Adldap2/?branch=master"><img src="https://img.shields.io/scrutinizer/g/adLDAP2/adLDAP2/master.svg?style=flat-square"/></a>
     <a href="https://packagist.org/packages/adldap2/adldap2"><img src="https://img.shields.io/packagist/dt/adldap2/adldap2.svg?style=flat-square"/></a>
     <a href="https://packagist.org/packages/adldap2/adldap2"><img src="https://img.shields.io/packagist/v/adldap2/adldap2.svg?style=flat-square"/></a>
@@ -40,8 +40,16 @@
     <a href="http://adldap2.github.io/Adldap2/">Documentation</a>
 </h4>
 
-- **Up and running in minutes.** Effortlessly connect to your LDAP servers and start running queries & operations in a matter of minutes.
+-   **Up and running in minutes.** Effortlessly connect to your LDAP servers and start running queries & operations in a matter of minutes.
 
-- **Fluent query builder.** Building LDAP queries has never been so easy. Find the records you're looking for in a couple lines or less with a fluent interface.
+-   **Fluent query builder.** Building LDAP queries has never been so easy. Find the records you're looking for in a couple lines or less with a fluent interface.
 
-- **Supercharged Active Record.** Create and modify LDAP records with ease. All LDAP records are individual models. Simply modify the attributes on the model and save it to persist the changes to your LDAP server.
+-   **Supercharged Active Record.** Create and modify LDAP records with ease. All LDAP records are individual models. Simply modify the attributes on the model and save it to persist the changes to your LDAP server.
+
+---
+
+<h3 align="center">Security Vulnerabilities</h3>
+
+<p align="center">If you discover a security vulnerability within Adldap2, please send an e-mail to Steve Bauman via <a href="mailto:steven_bauman@outlook.com">steven_bauman@outlook.com</a>.</p>
+
+<p align="center">All security vulnerabilities will be promptly addressed.</p>

+ 1 - 1
api/vendor/adldap2/adldap2/src/Configuration/DomainConfiguration.php

@@ -150,7 +150,7 @@ class DomainConfiguration
             $validator = new Validators\IntegerValidator($key, $value);
         } elseif (is_bool($default)) {
             $validator = new Validators\BooleanValidator($key, $value);
-        } elseif (class_exists($default)) {
+        } elseif (is_string($default) && class_exists($default)) {
             $validator = new Validators\ClassValidator($key, $value);
         } else {
             $validator = new Validators\StringOrNullValidator($key, $value);

+ 1 - 1
api/vendor/adldap2/adldap2/src/Configuration/Validators/ClassValidator.php

@@ -15,7 +15,7 @@ class ClassValidator extends Validator
      */
     public function validate()
     {
-        if (!class_exists($this->value)) {
+        if (!is_string($this->value) || !class_exists($this->value)) {
             throw new ConfigurationException("Option {$this->key} must be a valid class.");
         }
 

+ 1 - 1
api/vendor/adldap2/adldap2/src/Models/Attributes/DistinguishedName.php

@@ -52,7 +52,7 @@ class DistinguishedName
         foreach ($this->components as $component => $values) {
             array_map(function ($value) use ($component, &$components) {
                 // Assemble the component and escape the value.
-                $components[] = sprintf('%s=%s', $component, ldap_escape($value, '', 2));
+                $components[] = sprintf('%s=%s', $component, ldap_escape((string) $value, '', 2));
             }, $values);
         }
 

+ 9 - 0
api/vendor/adldap2/adldap2/src/Models/Model.php

@@ -171,6 +171,7 @@ abstract class Model implements ArrayAccess, JsonSerializable
      *
      * @return bool
      */
+    #[\ReturnTypeWillChange]
     public function offsetExists($offset)
     {
         return !is_null($this->getAttribute($offset));
@@ -183,6 +184,7 @@ abstract class Model implements ArrayAccess, JsonSerializable
      *
      * @return mixed
      */
+    #[\ReturnTypeWillChange]
     public function offsetGet($offset)
     {
         return $this->getAttribute($offset);
@@ -196,6 +198,7 @@ abstract class Model implements ArrayAccess, JsonSerializable
      *
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetSet($offset, $value)
     {
         $this->setAttribute($offset, $value);
@@ -208,6 +211,7 @@ abstract class Model implements ArrayAccess, JsonSerializable
      *
      * @return void
      */
+    #[\ReturnTypeWillChange]
     public function offsetUnset($offset)
     {
         unset($this->attributes[$offset]);
@@ -230,6 +234,7 @@ abstract class Model implements ArrayAccess, JsonSerializable
      *
      * @return array
      */
+    #[\ReturnTypeWillChange]
     public function jsonSerialize()
     {
         $attributes = $this->getAttributes();
@@ -829,6 +834,10 @@ abstract class Model implements ArrayAccess, JsonSerializable
     {
         $age = $this->getMaxPasswordAge();
 
+        if ($age === null) {
+            return 0;
+        }
+
         return (int) (abs($age) / 10000000 / 60 / 60 / 24);
     }
 

+ 4 - 0
api/vendor/adldap2/adldap2/src/Models/User.php

@@ -327,6 +327,10 @@ class User extends Entry implements Authenticatable
     {
         $workstations = $this->getFirstAttribute($this->schema->userWorkstations());
 
+        if ($workstations === null) {
+            return [];
+        }
+
         return array_filter(explode(',', $workstations));
     }
 

+ 9 - 8
api/vendor/adldap2/adldap2/src/Query/Builder.php

@@ -13,6 +13,7 @@ use Adldap\Schemas\SchemaInterface;
 use Adldap\Query\Events\QueryExecuted;
 use Adldap\Models\ModelNotFoundException;
 use Adldap\Connections\ConnectionInterface;
+use LDAP\Result;
 
 class Builder
 {
@@ -585,19 +586,19 @@ class Builder
     }
 
     /**
-     * Parses the given LDAP resource by retrieving its entries.
+     * Parses the given LDAP result by retrieving its entries.
      *
-     * @param resource $resource
+     * @param resource|Result $result
      *
      * @return array
      */
-    protected function parse($resource)
+    protected function parse($result)
     {
-        if (is_resource($resource)) {
-            $entries = $this->connection->getEntries($resource);
+        if (is_resource($result) || $result instanceof Result) {
+            $entries = $this->connection->getEntries($result);
             
             // Free up memory.
-            $this->connection->freeResult($resource);
+            $this->connection->freeResult($result);
         } else {
             $entries = [];
         }
@@ -1091,7 +1092,7 @@ class Builder
         // We'll escape the value if raw isn't requested.
         $value = $raw ? $value : $this->escape($value);
 
-        $field = $this->escape($field, $ignore = null, 3);
+        $field = $this->escape($field, $ignore = '', 3);
 
         $this->addFilter($boolean, compact('field', 'operator', 'value'));
 
@@ -1705,7 +1706,7 @@ class Builder
      */
     public function escape($value, $ignore = '', $flags = 0)
     {
-        return ldap_escape($value, $ignore, $flags);
+        return ldap_escape((string) $value, $ignore, $flags);
     }
 
     /**

+ 2 - 0
api/vendor/adldap2/adldap2/src/Query/Paginator.php

@@ -65,6 +65,7 @@ class Paginator implements Countable, IteratorAggregate
      *
      * @return ArrayIterator
      */
+    #[\ReturnTypeWillChange]
     public function getIterator()
     {
         $entries = array_slice($this->getResults(), $this->getCurrentOffset(), $this->getPerPage(), true);
@@ -129,6 +130,7 @@ class Paginator implements Countable, IteratorAggregate
      *
      * @return int
      */
+    #[\ReturnTypeWillChange]
     public function count()
     {
         return count($this->results);

+ 3 - 3
api/vendor/adldap2/adldap2/src/Query/Processor.php

@@ -54,7 +54,7 @@ class Processor
 
         $models = [];
 
-        if (array_key_exists('count', $entries)) {
+	if (is_array($entries) && array_key_exists('count', $entries)) {
             for ($i = 0; $i < $entries['count']; $i++) {
                 // We'll go through each entry and construct a new
                 // model instance with the raw LDAP attributes.
@@ -153,7 +153,7 @@ class Processor
      */
     public function newModel($attributes = [], $model = null)
     {
-        $model = (class_exists($model) ? $model : $this->schema->entryModel());
+        $model = ($model !== null && class_exists($model) ? $model : $this->schema->entryModel());
 
         if (!is_subclass_of($model, $base = Model::class)) {
             throw new InvalidArgumentException("The given model class '{$model}' must extend the base model class '{$base}'");
@@ -200,7 +200,7 @@ class Processor
     {
         $field = $this->builder->getSortByField();
 
-        $flags = $this->builder->getSortByFlags();
+        $flags = $this->builder->getSortByFlags() ?? \SORT_REGULAR;
 
         $direction = $this->builder->getSortByDirection();
 

+ 1 - 1
api/vendor/adldap2/adldap2/src/Utilities.php

@@ -105,7 +105,7 @@ class Utilities
      */
     public static function binaryGuidToString($binGuid)
     {
-        if (trim($binGuid) == '' || is_null($binGuid)) {
+        if ($binGuid === null || trim($binGuid) === '') {
             return;
         }
 

+ 139 - 12
api/vendor/composer/ClassLoader.php

@@ -42,21 +42,75 @@ namespace Composer\Autoload;
  */
 class ClassLoader
 {
+    /** @var ?string */
+    private $vendorDir;
+
     // PSR-4
+    /**
+     * @var array[]
+     * @psalm-var array<string, array<string, int>>
+     */
     private $prefixLengthsPsr4 = array();
+    /**
+     * @var array[]
+     * @psalm-var array<string, array<int, string>>
+     */
     private $prefixDirsPsr4 = array();
+    /**
+     * @var array[]
+     * @psalm-var array<string, string>
+     */
     private $fallbackDirsPsr4 = array();
 
     // PSR-0
+    /**
+     * @var array[]
+     * @psalm-var array<string, array<string, string[]>>
+     */
     private $prefixesPsr0 = array();
+    /**
+     * @var array[]
+     * @psalm-var array<string, string>
+     */
     private $fallbackDirsPsr0 = array();
 
+    /** @var bool */
     private $useIncludePath = false;
+
+    /**
+     * @var string[]
+     * @psalm-var array<string, string>
+     */
     private $classMap = array();
+
+    /** @var bool */
     private $classMapAuthoritative = false;
+
+    /**
+     * @var bool[]
+     * @psalm-var array<string, bool>
+     */
     private $missingClasses = array();
+
+    /** @var ?string */
     private $apcuPrefix;
 
+    /**
+     * @var self[]
+     */
+    private static $registeredLoaders = array();
+
+    /**
+     * @param ?string $vendorDir
+     */
+    public function __construct($vendorDir = null)
+    {
+        $this->vendorDir = $vendorDir;
+    }
+
+    /**
+     * @return string[]
+     */
     public function getPrefixes()
     {
         if (!empty($this->prefixesPsr0)) {
@@ -66,28 +120,47 @@ class ClassLoader
         return array();
     }
 
+    /**
+     * @return array[]
+     * @psalm-return array<string, array<int, string>>
+     */
     public function getPrefixesPsr4()
     {
         return $this->prefixDirsPsr4;
     }
 
+    /**
+     * @return array[]
+     * @psalm-return array<string, string>
+     */
     public function getFallbackDirs()
     {
         return $this->fallbackDirsPsr0;
     }
 
+    /**
+     * @return array[]
+     * @psalm-return array<string, string>
+     */
     public function getFallbackDirsPsr4()
     {
         return $this->fallbackDirsPsr4;
     }
 
+    /**
+     * @return string[] Array of classname => path
+     * @psalm-return array<string, string>
+     */
     public function getClassMap()
     {
         return $this->classMap;
     }
 
     /**
-     * @param array $classMap Class to filename map
+     * @param string[] $classMap Class to filename map
+     * @psalm-param array<string, string> $classMap
+     *
+     * @return void
      */
     public function addClassMap(array $classMap)
     {
@@ -102,9 +175,11 @@ class ClassLoader
      * Registers a set of PSR-0 directories for a given prefix, either
      * appending or prepending to the ones previously set for this prefix.
      *
-     * @param string       $prefix  The prefix
-     * @param array|string $paths   The PSR-0 root directories
-     * @param bool         $prepend Whether to prepend the directories
+     * @param string          $prefix  The prefix
+     * @param string[]|string $paths   The PSR-0 root directories
+     * @param bool            $prepend Whether to prepend the directories
+     *
+     * @return void
      */
     public function add($prefix, $paths, $prepend = false)
     {
@@ -147,11 +222,13 @@ class ClassLoader
      * Registers a set of PSR-4 directories for a given namespace, either
      * appending or prepending to the ones previously set for this namespace.
      *
-     * @param string       $prefix  The prefix/namespace, with trailing '\\'
-     * @param array|string $paths   The PSR-4 base directories
-     * @param bool         $prepend Whether to prepend the directories
+     * @param string          $prefix  The prefix/namespace, with trailing '\\'
+     * @param string[]|string $paths   The PSR-4 base directories
+     * @param bool            $prepend Whether to prepend the directories
      *
      * @throws \InvalidArgumentException
+     *
+     * @return void
      */
     public function addPsr4($prefix, $paths, $prepend = false)
     {
@@ -195,8 +272,10 @@ class ClassLoader
      * Registers a set of PSR-0 directories for a given prefix,
      * replacing any others previously set for this prefix.
      *
-     * @param string       $prefix The prefix
-     * @param array|string $paths  The PSR-0 base directories
+     * @param string          $prefix The prefix
+     * @param string[]|string $paths  The PSR-0 base directories
+     *
+     * @return void
      */
     public function set($prefix, $paths)
     {
@@ -211,10 +290,12 @@ class ClassLoader
      * Registers a set of PSR-4 directories for a given namespace,
      * replacing any others previously set for this namespace.
      *
-     * @param string       $prefix The prefix/namespace, with trailing '\\'
-     * @param array|string $paths  The PSR-4 base directories
+     * @param string          $prefix The prefix/namespace, with trailing '\\'
+     * @param string[]|string $paths  The PSR-4 base directories
      *
      * @throws \InvalidArgumentException
+     *
+     * @return void
      */
     public function setPsr4($prefix, $paths)
     {
@@ -234,6 +315,8 @@ class ClassLoader
      * Turns on searching the include path for class files.
      *
      * @param bool $useIncludePath
+     *
+     * @return void
      */
     public function setUseIncludePath($useIncludePath)
     {
@@ -256,6 +339,8 @@ class ClassLoader
      * that have not been registered with the class map.
      *
      * @param bool $classMapAuthoritative
+     *
+     * @return void
      */
     public function setClassMapAuthoritative($classMapAuthoritative)
     {
@@ -276,6 +361,8 @@ class ClassLoader
      * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
      *
      * @param string|null $apcuPrefix
+     *
+     * @return void
      */
     public function setApcuPrefix($apcuPrefix)
     {
@@ -296,25 +383,44 @@ class ClassLoader
      * Registers this instance as an autoloader.
      *
      * @param bool $prepend Whether to prepend the autoloader or not
+     *
+     * @return void
      */
     public function register($prepend = false)
     {
         spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+
+        if (null === $this->vendorDir) {
+            return;
+        }
+
+        if ($prepend) {
+            self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
+        } else {
+            unset(self::$registeredLoaders[$this->vendorDir]);
+            self::$registeredLoaders[$this->vendorDir] = $this;
+        }
     }
 
     /**
      * Unregisters this instance as an autoloader.
+     *
+     * @return void
      */
     public function unregister()
     {
         spl_autoload_unregister(array($this, 'loadClass'));
+
+        if (null !== $this->vendorDir) {
+            unset(self::$registeredLoaders[$this->vendorDir]);
+        }
     }
 
     /**
      * Loads the given class or interface.
      *
      * @param  string    $class The name of the class
-     * @return bool|null True if loaded, null otherwise
+     * @return true|null True if loaded, null otherwise
      */
     public function loadClass($class)
     {
@@ -323,6 +429,8 @@ class ClassLoader
 
             return true;
         }
+
+        return null;
     }
 
     /**
@@ -367,6 +475,21 @@ class ClassLoader
         return $file;
     }
 
+    /**
+     * Returns the currently registered loaders indexed by their corresponding vendor directories.
+     *
+     * @return self[]
+     */
+    public static function getRegisteredLoaders()
+    {
+        return self::$registeredLoaders;
+    }
+
+    /**
+     * @param  string       $class
+     * @param  string       $ext
+     * @return string|false
+     */
     private function findFileWithExtension($class, $ext)
     {
         // PSR-4 lookup
@@ -438,6 +561,10 @@ class ClassLoader
  * Scope isolated include.
  *
  * Prevents access to $this/self from included files.
+ *
+ * @param  string $file
+ * @return void
+ * @private
  */
 function includeFile($file)
 {

+ 340 - 789
api/vendor/composer/InstalledVersions.php

@@ -1,799 +1,350 @@
 <?php
 
-
-
-
-
-
-
-
-
-
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
 
 namespace Composer;
 
+use Composer\Autoload\ClassLoader;
 use Composer\Semver\VersionParser;
 
-
-
-
-
-
+/**
+ * This class is copied in every Composer installed project and available to all
+ *
+ * See also https://getcomposer.org/doc/07-runtime.md#installed-versions
+ *
+ * To require its presence, you can require `composer-runtime-api ^2.0`
+ */
 class InstalledVersions
 {
-private static $installed = array (
-  'root' => 
-  array (
-    'pretty_version' => 'dev-master',
-    'version' => 'dev-master',
-    'aliases' => 
-    array (
-    ),
-    'reference' => 'ed5f925046b2d5d1fcfd5b5a24fbe7d16936c1d4',
-    'name' => '__root__',
-  ),
-  'versions' => 
-  array (
-    '__root__' => 
-    array (
-      'pretty_version' => 'dev-master',
-      'version' => 'dev-master',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ed5f925046b2d5d1fcfd5b5a24fbe7d16936c1d4',
-    ),
-    'adldap2/adldap2' => 
-    array (
-      'pretty_version' => 'v10.3.3',
-      'version' => '10.3.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c2a8f72455d3438377d955fc0f4b9ed836b47463',
-    ),
-    'bcremer/line-reader' => 
-    array (
-      'pretty_version' => '1.1.0',
-      'version' => '1.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3ec3e200577630f1e58d30b4c1c468b877d8d0a7',
-    ),
-    'bogstag/oauth2-trakt' => 
-    array (
-      'pretty_version' => 'v1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2',
-    ),
-    'composer/semver' => 
-    array (
-      'pretty_version' => '1.7.2',
-      'version' => '1.7.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '647490bbcaf7fc4891c58f47b825eb99d19c377a',
-    ),
-    'dg/dibi' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '*',
-      ),
-    ),
-    'dibi/dibi' => 
-    array (
-      'pretty_version' => 'v4.2.3',
-      'version' => '4.2.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '73e16eb1a322599e8cdf350adcfdbc15eaf16577',
-    ),
-    'doctrine/annotations' => 
-    array (
-      'pretty_version' => '1.10.3',
-      'version' => '1.10.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5db60a4969eba0e0c197a19c077780aadbc43c5d',
-    ),
-    'doctrine/lexer' => 
-    array (
-      'pretty_version' => '1.2.1',
-      'version' => '1.2.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'e864bbf5904cb8f5bb334f99209b48018522f042',
-    ),
-    'dragonmantank/cron-expression' => 
-    array (
-      'pretty_version' => 'v3.1.0',
-      'version' => '3.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c',
-    ),
-    'fig/http-message-util' => 
-    array (
-      'pretty_version' => '1.1.4',
-      'version' => '1.1.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3242caa9da7221a304b8f84eb9eaddae0a7cf422',
-    ),
-    'guzzlehttp/guzzle' => 
-    array (
-      'pretty_version' => '7.3.0',
-      'version' => '7.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7008573787b430c1c1f650e3722d9bba59967628',
-    ),
-    'guzzlehttp/promises' => 
-    array (
-      'pretty_version' => '1.4.1',
-      'version' => '1.4.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8e7d04f1f6450fef59366c399cfad4b9383aa30d',
-    ),
-    'guzzlehttp/psr7' => 
-    array (
-      'pretty_version' => '1.8.2',
-      'version' => '1.8.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'dc960a912984efb74d0a90222870c72c87f10c91',
-    ),
-    'illuminate/contracts' => 
-    array (
-      'pretty_version' => 'v5.8.0',
-      'version' => '5.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3e3a9a654adbf798e05491a5dbf90112df1effde',
-    ),
-    'kryptonit3/couchpotato' => 
-    array (
-      'pretty_version' => '1.0.0',
-      'version' => '1.0.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7a1fc892f70f120f74ff005850e923a0f1566376',
-    ),
-    'kryptonit3/sickrage' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '441a293b5c219c3cdd1ebebd2bcf4518598f84aa',
-    ),
-    'kryptonit3/sonarr' => 
-    array (
-      'pretty_version' => '1.0.6.1',
-      'version' => '1.0.6.1',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'e30c5c783a837270bcef81571ca9b95909c52e5e',
-    ),
-    'lcobucci/jwt' => 
-    array (
-      'pretty_version' => '3.3.1',
-      'version' => '3.3.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a11ec5f4b4d75d1fcd04e133dede4c317aac9e18',
-    ),
-    'league/oauth2-client' => 
-    array (
-      'pretty_version' => '2.6.0',
-      'version' => '2.6.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'badb01e62383430706433191b82506b6df24ad98',
-    ),
-    'monolog/monolog' => 
-    array (
-      'pretty_version' => '1.26.1',
-      'version' => '1.26.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c6b00f05152ae2c9b04a448f99c7590beb6042f5',
-    ),
-    'mtdowling/cron-expression' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '^1.0',
-      ),
-    ),
-    'myclabs/php-enum' => 
-    array (
-      'pretty_version' => '1.8.0',
-      'version' => '1.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '46cf3d8498b095bd33727b13fd5707263af99421',
-    ),
-    'nekonomokochan/php-json-logger' => 
-    array (
-      'pretty_version' => 'v1.3.1',
-      'version' => '1.3.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6df126a82940a00d8ea2da6e0b7c58e3e57eb132',
-    ),
-    'nikic/fast-route' => 
-    array (
-      'pretty_version' => 'v1.3.0',
-      'version' => '1.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '181d480e08d9476e61381e04a71b34dc0432e812',
-    ),
-    'paquettg/php-html-parser' => 
-    array (
-      'pretty_version' => '3.1.1',
-      'version' => '3.1.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4e01a438ad5961cc2d7427eb9798d213c8a12629',
-    ),
-    'paquettg/string-encode' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a8708e9fac9d5ddfc8fc2aac6004e2cd05d80fee',
-    ),
-    'paragonie/constant_time_encoding' => 
-    array (
-      'pretty_version' => 'v2.2.2',
-      'version' => '2.2.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'eccf915f45f911bfb189d1d1638d940ec6ee6e33',
-    ),
-    'paragonie/random_compat' => 
-    array (
-      'pretty_version' => 'v9.99.100',
-      'version' => '9.99.100.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
-    ),
-    'paragonie/sodium_compat' => 
-    array (
-      'pretty_version' => 'v1.6.4',
-      'version' => '1.6.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3f2fd07977541b4d630ea0365ad0eceddee5179c',
-    ),
-    'peppeocchi/php-cron-scheduler' => 
-    array (
-      'pretty_version' => 'v4.0',
-      'version' => '4.0.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
-    ),
-    'php-http/httplug' => 
-    array (
-      'pretty_version' => '2.2.0',
-      'version' => '2.2.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '191a0a1b41ed026b717421931f8d3bd2514ffbf9',
-    ),
-    'php-http/promise' => 
-    array (
-      'pretty_version' => '1.1.0',
-      'version' => '1.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4c4c1f9b7289a2ec57cde7f1e9762a5789506f88',
-    ),
-    'phpmailer/phpmailer' => 
-    array (
-      'pretty_version' => 'v6.5.0',
-      'version' => '6.5.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a5b5c43e50b7fba655f793ad27303cd74c57363c',
-    ),
-    'pragmarx/google2fa' => 
-    array (
-      'pretty_version' => 'v3.0.3',
-      'version' => '3.0.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6949226739e4424f40031e6f1c96b1fd64047335',
-    ),
-    'psr/container' => 
-    array (
-      'pretty_version' => '1.1.1',
-      'version' => '1.1.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8622567409010282b7aeebe4bb841fe98b58dcaf',
-    ),
-    'psr/http-client' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
-    ),
-    'psr/http-client-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-factory' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
-    ),
-    'psr/http-factory-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-message' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
-    ),
-    'psr/http-message-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-server-handler' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'aff2f80e33b7f026ec96bb42f63242dc50ffcae7',
-    ),
-    'psr/http-server-middleware' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '2296f45510945530b9dceb8bcedb5cb84d40c5f5',
-    ),
-    'psr/log' => 
-    array (
-      'pretty_version' => '1.1.4',
-      'version' => '1.1.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
-    ),
-    'psr/log-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0.0',
-      ),
-    ),
-    'psr/simple-cache' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
-    ),
-    'pusher/pusher-php-server' => 
-    array (
-      'pretty_version' => 'v4.1.5',
-      'version' => '4.1.5.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '251f22602320c1b1aff84798fe74f3f7ee0504a9',
-    ),
-    'ralouphie/getallheaders' => 
-    array (
-      'pretty_version' => '3.0.3',
-      'version' => '3.0.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '120b605dfeb996808c31b6477290a714d356e822',
-    ),
-    'ramsey/uuid' => 
-    array (
-      'pretty_version' => '3.9.6',
-      'version' => '3.9.6.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ffa80ab953edd85d5b6c004f96181a538aad35a3',
-    ),
-    'rhumsaa/uuid' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '3.9.6',
-      ),
-    ),
-    'rmccue/requests' => 
-    array (
-      'pretty_version' => 'v1.8.0',
-      'version' => '1.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'afbe4790e4def03581c4a0963a1e8aa01f6030f1',
-    ),
-    'slim/psr7' => 
-    array (
-      'pretty_version' => '1.3.0',
-      'version' => '1.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '235d2e5a5ee1ad4b97b96870f37f3091b22fffd7',
-    ),
-    'slim/slim' => 
-    array (
-      'pretty_version' => '4.7.1',
-      'version' => '4.7.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f',
-    ),
-    'symfony/deprecation-contracts' => 
-    array (
-      'pretty_version' => 'v2.1.3',
-      'version' => '2.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5e20b83385a77593259c9f8beb2c43cd03b2ac14',
-    ),
-    'symfony/finder' => 
-    array (
-      'pretty_version' => 'v5.1.3',
-      'version' => '5.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4298870062bfc667cb78d2b379be4bf5dec5f187',
-    ),
-    'symfony/polyfill-ctype' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c6c942b1ac76c82448322025e084cadc56048b4e',
-    ),
-    'symfony/polyfill-mbstring' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5232de97ee3b75b0360528dae24e73db49566ab1',
-    ),
-    'symfony/polyfill-php56' => 
-    array (
-      'pretty_version' => 'v1.9.0',
-      'version' => '1.9.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7b4fc009172cc0196535b0328bd1226284a28000',
-    ),
-    'symfony/polyfill-php72' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9',
-    ),
-    'symfony/polyfill-php80' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'dc3063ba22c2a1fd2f45ed856374d79114998f91',
-    ),
-    'symfony/polyfill-util' => 
-    array (
-      'pretty_version' => 'v1.9.0',
-      'version' => '1.9.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8e15d04ba3440984d23e7964b2ee1d25c8de1581',
-    ),
-    'symfony/var-dumper' => 
-    array (
-      'pretty_version' => 'v4.2.3',
-      'version' => '4.2.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '223bda89f9be41cf7033eeaf11bc61a280489c17',
-    ),
-    'symfony/yaml' => 
-    array (
-      'pretty_version' => 'v5.1.3',
-      'version' => '5.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ea342353a3ef4f453809acc4ebc55382231d4d23',
-    ),
-    'tightenco/collect' => 
-    array (
-      'pretty_version' => 'v5.7.27',
-      'version' => '5.7.27.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c1a36a2a8a0aa731c1acdcd83f57724ffe630d00',
-    ),
-    'webmozart/assert' => 
-    array (
-      'pretty_version' => '1.10.0',
-      'version' => '1.10.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6964c76c7804814a842473e0c8fd15bab0f18e25',
-    ),
-    'zircote/swagger-php' => 
-    array (
-      'pretty_version' => '3.0.4',
-      'version' => '3.0.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'fa47d62c22c95272625624fbf8109fa46ffac43b',
-    ),
-  ),
-);
-
-
-
-
-
-
-
-public static function getInstalledPackages()
-{
-return array_keys(self::$installed['versions']);
-}
-
-
-
-
-
-
-
-
-
-public static function isInstalled($packageName)
-{
-return isset(self::$installed['versions'][$packageName]);
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-public static function satisfies(VersionParser $parser, $packageName, $constraint)
-{
-$constraint = $parser->parseConstraints($constraint);
-$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
-
-return $provided->matches($constraint);
-}
-
-
-
-
-
-
-
-
-
-
-public static function getVersionRanges($packageName)
-{
-if (!isset(self::$installed['versions'][$packageName])) {
-throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
-}
-
-$ranges = array();
-if (isset(self::$installed['versions'][$packageName]['pretty_version'])) {
-$ranges[] = self::$installed['versions'][$packageName]['pretty_version'];
-}
-if (array_key_exists('aliases', self::$installed['versions'][$packageName])) {
-$ranges = array_merge($ranges, self::$installed['versions'][$packageName]['aliases']);
-}
-if (array_key_exists('replaced', self::$installed['versions'][$packageName])) {
-$ranges = array_merge($ranges, self::$installed['versions'][$packageName]['replaced']);
-}
-if (array_key_exists('provided', self::$installed['versions'][$packageName])) {
-$ranges = array_merge($ranges, self::$installed['versions'][$packageName]['provided']);
-}
-
-return implode(' || ', $ranges);
-}
-
-
-
-
-
-public static function getVersion($packageName)
-{
-if (!isset(self::$installed['versions'][$packageName])) {
-throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
-}
-
-if (!isset(self::$installed['versions'][$packageName]['version'])) {
-return null;
-}
-
-return self::$installed['versions'][$packageName]['version'];
-}
-
-
-
-
-
-public static function getPrettyVersion($packageName)
-{
-if (!isset(self::$installed['versions'][$packageName])) {
-throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
-}
-
-if (!isset(self::$installed['versions'][$packageName]['pretty_version'])) {
-return null;
-}
-
-return self::$installed['versions'][$packageName]['pretty_version'];
-}
-
-
-
-
-
-public static function getReference($packageName)
-{
-if (!isset(self::$installed['versions'][$packageName])) {
-throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
-}
-
-if (!isset(self::$installed['versions'][$packageName]['reference'])) {
-return null;
-}
-
-return self::$installed['versions'][$packageName]['reference'];
-}
-
-
-
-
-
-public static function getRootPackage()
-{
-return self::$installed['root'];
-}
-
-
-
-
-
-
-
-public static function getRawData()
-{
-return self::$installed;
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-public static function reload($data)
-{
-self::$installed = $data;
-}
+    /**
+     * @var mixed[]|null
+     * @psalm-var array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}|array{}|null
+     */
+    private static $installed;
+
+    /**
+     * @var bool|null
+     */
+    private static $canGetVendors;
+
+    /**
+     * @var array[]
+     * @psalm-var array<string, array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     */
+    private static $installedByVendor = array();
+
+    /**
+     * Returns a list of all package names which are present, either by being installed, replaced or provided
+     *
+     * @return string[]
+     * @psalm-return list<string>
+     */
+    public static function getInstalledPackages()
+    {
+        $packages = array();
+        foreach (self::getInstalled() as $installed) {
+            $packages[] = array_keys($installed['versions']);
+        }
+
+        if (1 === \count($packages)) {
+            return $packages[0];
+        }
+
+        return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
+    }
+
+    /**
+     * Returns a list of all package names with a specific type e.g. 'library'
+     *
+     * @param  string   $type
+     * @return string[]
+     * @psalm-return list<string>
+     */
+    public static function getInstalledPackagesByType($type)
+    {
+        $packagesByType = array();
+
+        foreach (self::getInstalled() as $installed) {
+            foreach ($installed['versions'] as $name => $package) {
+                if (isset($package['type']) && $package['type'] === $type) {
+                    $packagesByType[] = $name;
+                }
+            }
+        }
+
+        return $packagesByType;
+    }
+
+    /**
+     * Checks whether the given package is installed
+     *
+     * This also returns true if the package name is provided or replaced by another package
+     *
+     * @param  string $packageName
+     * @param  bool   $includeDevRequirements
+     * @return bool
+     */
+    public static function isInstalled($packageName, $includeDevRequirements = true)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (isset($installed['versions'][$packageName])) {
+                return $includeDevRequirements || empty($installed['versions'][$packageName]['dev_requirement']);
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Checks whether the given package satisfies a version constraint
+     *
+     * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
+     *
+     *   Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
+     *
+     * @param  VersionParser $parser      Install composer/semver to have access to this class and functionality
+     * @param  string        $packageName
+     * @param  string|null   $constraint  A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
+     * @return bool
+     */
+    public static function satisfies(VersionParser $parser, $packageName, $constraint)
+    {
+        $constraint = $parser->parseConstraints($constraint);
+        $provided = $parser->parseConstraints(self::getVersionRanges($packageName));
+
+        return $provided->matches($constraint);
+    }
+
+    /**
+     * Returns a version constraint representing all the range(s) which are installed for a given package
+     *
+     * It is easier to use this via isInstalled() with the $constraint argument if you need to check
+     * whether a given version of a package is installed, and not just whether it exists
+     *
+     * @param  string $packageName
+     * @return string Version constraint usable with composer/semver
+     */
+    public static function getVersionRanges($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            $ranges = array();
+            if (isset($installed['versions'][$packageName]['pretty_version'])) {
+                $ranges[] = $installed['versions'][$packageName]['pretty_version'];
+            }
+            if (array_key_exists('aliases', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
+            }
+            if (array_key_exists('replaced', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
+            }
+            if (array_key_exists('provided', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
+            }
+
+            return implode(' || ', $ranges);
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+     */
+    public static function getVersion($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['version'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['version'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+     */
+    public static function getPrettyVersion($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['pretty_version'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['pretty_version'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
+     */
+    public static function getReference($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['reference'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['reference'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
+     */
+    public static function getInstallPath($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @return array
+     * @psalm-return array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}
+     */
+    public static function getRootPackage()
+    {
+        $installed = self::getInstalled();
+
+        return $installed[0]['root'];
+    }
+
+    /**
+     * Returns the raw installed.php data for custom implementations
+     *
+     * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
+     * @return array[]
+     * @psalm-return array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}
+     */
+    public static function getRawData()
+    {
+        @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
+
+        if (null === self::$installed) {
+            // only require the installed.php file if this file is loaded from its dumped location,
+            // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+            if (substr(__DIR__, -8, 1) !== 'C') {
+                self::$installed = include __DIR__ . '/installed.php';
+            } else {
+                self::$installed = array();
+            }
+        }
+
+        return self::$installed;
+    }
+
+    /**
+     * Returns the raw data of all installed.php which are currently loaded for custom implementations
+     *
+     * @return array[]
+     * @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     */
+    public static function getAllRawData()
+    {
+        return self::getInstalled();
+    }
+
+    /**
+     * Lets you reload the static array from another file
+     *
+     * This is only useful for complex integrations in which a project needs to use
+     * this class but then also needs to execute another project's autoloader in process,
+     * and wants to ensure both projects have access to their version of installed.php.
+     *
+     * A typical case would be PHPUnit, where it would need to make sure it reads all
+     * the data it needs from this class, then call reload() with
+     * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
+     * the project in which it runs can then also use this class safely, without
+     * interference between PHPUnit's dependencies and the project's dependencies.
+     *
+     * @param  array[] $data A vendor/composer/installed.php data set
+     * @return void
+     *
+     * @psalm-param array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>} $data
+     */
+    public static function reload($data)
+    {
+        self::$installed = $data;
+        self::$installedByVendor = array();
+    }
+
+    /**
+     * @return array[]
+     * @psalm-return list<array{root: array{name: string, version: string, reference: string, pretty_version: string, aliases: string[], dev: bool, install_path: string, type: string}, versions: array<string, array{dev_requirement: bool, pretty_version?: string, version?: string, aliases?: string[], reference?: string, replaced?: string[], provided?: string[], install_path?: string, type?: string}>}>
+     */
+    private static function getInstalled()
+    {
+        if (null === self::$canGetVendors) {
+            self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
+        }
+
+        $installed = array();
+
+        if (self::$canGetVendors) {
+            foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
+                if (isset(self::$installedByVendor[$vendorDir])) {
+                    $installed[] = self::$installedByVendor[$vendorDir];
+                } elseif (is_file($vendorDir.'/composer/installed.php')) {
+                    $installed[] = self::$installedByVendor[$vendorDir] = require $vendorDir.'/composer/installed.php';
+                    if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
+                        self::$installed = $installed[count($installed) - 1];
+                    }
+                }
+            }
+        }
+
+        if (null === self::$installed) {
+            // only require the installed.php file if this file is loaded from its dumped location,
+            // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+            if (substr(__DIR__, -8, 1) !== 'C') {
+                self::$installed = require __DIR__ . '/installed.php';
+            } else {
+                self::$installed = array();
+            }
+        }
+        $installed[] = self::$installed;
+
+        return $installed;
+    }
 }

+ 2 - 2
api/vendor/composer/autoload_files.php

@@ -8,12 +8,12 @@ $baseDir = dirname($vendorDir);
 return array(
     '7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
     'a0edc8309cc5e1d60e3047b5df6b7052' => $vendorDir . '/guzzlehttp/psr7/src/functions_include.php',
-    'c964ee0ededf28c96ebd9db5099ef910' => $vendorDir . '/guzzlehttp/promises/src/functions_include.php',
     '320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
+    'c964ee0ededf28c96ebd9db5099ef910' => $vendorDir . '/guzzlehttp/promises/src/functions_include.php',
     '37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
+    '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
     '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
     '25072dd6e2470089de65ae7bf11d3109' => $vendorDir . '/symfony/polyfill-php72/bootstrap.php',
-    '6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
     'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
     '667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php',
     '253c157292f75eb38082b5acb06f3f01' => $vendorDir . '/nikic/fast-route/src/functions.php',

+ 3 - 0
api/vendor/composer/autoload_psr4.php

@@ -17,8 +17,10 @@ return array(
     'Symfony\\Component\\Yaml\\' => array($vendorDir . '/symfony/yaml'),
     'Symfony\\Component\\VarDumper\\' => array($vendorDir . '/symfony/var-dumper'),
     'Symfony\\Component\\Finder\\' => array($vendorDir . '/symfony/finder'),
+    'Stripe\\' => array($vendorDir . '/stripe/stripe-php/lib'),
     'Slim\\Psr7\\' => array($vendorDir . '/slim/psr7/src'),
     'Slim\\' => array($vendorDir . '/slim/slim/Slim'),
+    'Recurr\\' => array($vendorDir . '/simshaun/recurr/src/Recurr'),
     'Ramsey\\Uuid\\' => array($vendorDir . '/ramsey/uuid/src'),
     'Pusher\\' => array($vendorDir . '/pusher/pusher-php-server/src'),
     'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
@@ -51,6 +53,7 @@ return array(
     'Fig\\Http\\Message\\' => array($vendorDir . '/fig/http-message-util/src'),
     'FastRoute\\' => array($vendorDir . '/nikic/fast-route/src'),
     'Doctrine\\Common\\Lexer\\' => array($vendorDir . '/doctrine/lexer/lib/Doctrine/Common/Lexer'),
+    'Doctrine\\Common\\Collections\\' => array($vendorDir . '/doctrine/collections/lib/Doctrine/Common/Collections'),
     'Doctrine\\Common\\Annotations\\' => array($vendorDir . '/doctrine/annotations/lib/Doctrine/Common/Annotations'),
     'Cron\\' => array($vendorDir . '/dragonmantank/cron-expression/src/Cron'),
     'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'),

+ 8 - 3
api/vendor/composer/autoload_real.php

@@ -25,7 +25,7 @@ class ComposerAutoloaderInitcbdc783d76f8e7563dcce7d8af053ecb
         require __DIR__ . '/platform_check.php';
 
         spl_autoload_register(array('ComposerAutoloaderInitcbdc783d76f8e7563dcce7d8af053ecb', 'loadClassLoader'), true, true);
-        self::$loader = $loader = new \Composer\Autoload\ClassLoader();
+        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(\dirname(__FILE__)));
         spl_autoload_unregister(array('ComposerAutoloaderInitcbdc783d76f8e7563dcce7d8af053ecb', 'loadClassLoader'));
 
         $useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded());
@@ -65,11 +65,16 @@ class ComposerAutoloaderInitcbdc783d76f8e7563dcce7d8af053ecb
     }
 }
 
+/**
+ * @param string $fileIdentifier
+ * @param string $file
+ * @return void
+ */
 function composerRequirecbdc783d76f8e7563dcce7d8af053ecb($fileIdentifier, $file)
 {
     if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
-        require $file;
-
         $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
+
+        require $file;
     }
 }

+ 17 - 2
api/vendor/composer/autoload_static.php

@@ -9,12 +9,12 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
     public static $files = array (
         '7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
         'a0edc8309cc5e1d60e3047b5df6b7052' => __DIR__ . '/..' . '/guzzlehttp/psr7/src/functions_include.php',
-        'c964ee0ededf28c96ebd9db5099ef910' => __DIR__ . '/..' . '/guzzlehttp/promises/src/functions_include.php',
         '320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php',
+        'c964ee0ededf28c96ebd9db5099ef910' => __DIR__ . '/..' . '/guzzlehttp/promises/src/functions_include.php',
         '37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php',
+        '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
         '0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
         '25072dd6e2470089de65ae7bf11d3109' => __DIR__ . '/..' . '/symfony/polyfill-php72/bootstrap.php',
-        '6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
         'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php',
         '667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php',
         '253c157292f75eb38082b5acb06f3f01' => __DIR__ . '/..' . '/nikic/fast-route/src/functions.php',
@@ -46,11 +46,13 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
             'Symfony\\Component\\Yaml\\' => 23,
             'Symfony\\Component\\VarDumper\\' => 28,
             'Symfony\\Component\\Finder\\' => 25,
+            'Stripe\\' => 7,
             'Slim\\Psr7\\' => 10,
             'Slim\\' => 5,
         ),
         'R' => 
         array (
+            'Recurr\\' => 7,
             'Ramsey\\Uuid\\' => 12,
         ),
         'P' => 
@@ -116,6 +118,7 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
         'D' => 
         array (
             'Doctrine\\Common\\Lexer\\' => 22,
+            'Doctrine\\Common\\Collections\\' => 28,
             'Doctrine\\Common\\Annotations\\' => 28,
         ),
         'C' => 
@@ -179,6 +182,10 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
         array (
             0 => __DIR__ . '/..' . '/symfony/finder',
         ),
+        'Stripe\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/stripe/stripe-php/lib',
+        ),
         'Slim\\Psr7\\' => 
         array (
             0 => __DIR__ . '/..' . '/slim/psr7/src',
@@ -187,6 +194,10 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
         array (
             0 => __DIR__ . '/..' . '/slim/slim/Slim',
         ),
+        'Recurr\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/simshaun/recurr/src/Recurr',
+        ),
         'Ramsey\\Uuid\\' => 
         array (
             0 => __DIR__ . '/..' . '/ramsey/uuid/src',
@@ -317,6 +328,10 @@ class ComposerStaticInitcbdc783d76f8e7563dcce7d8af053ecb
         array (
             0 => __DIR__ . '/..' . '/doctrine/lexer/lib/Doctrine/Common/Lexer',
         ),
+        'Doctrine\\Common\\Collections\\' => 
+        array (
+            0 => __DIR__ . '/..' . '/doctrine/collections/lib/Doctrine/Common/Collections',
+        ),
         'Doctrine\\Common\\Annotations\\' => 
         array (
             0 => __DIR__ . '/..' . '/doctrine/annotations/lib/Doctrine/Common/Annotations',

+ 207 - 10
api/vendor/composer/installed.json

@@ -2,36 +2,36 @@
     "packages": [
         {
             "name": "adldap2/adldap2",
-            "version": "v10.3.3",
-            "version_normalized": "10.3.3.0",
+            "version": "v10.4.1",
+            "version_normalized": "10.4.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Adldap2/Adldap2.git",
-                "reference": "c2a8f72455d3438377d955fc0f4b9ed836b47463"
+                "reference": "81aeb283f56e216ae925a9cc4241de56b1fd4453"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/c2a8f72455d3438377d955fc0f4b9ed836b47463",
-                "reference": "c2a8f72455d3438377d955fc0f4b9ed836b47463",
+                "url": "https://api.github.com/repos/Adldap2/Adldap2/zipball/81aeb283f56e216ae925a9cc4241de56b1fd4453",
+                "reference": "81aeb283f56e216ae925a9cc4241de56b1fd4453",
                 "shasum": ""
             },
             "require": {
                 "ext-json": "*",
                 "ext-ldap": "*",
-                "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0",
+                "illuminate/contracts": "~5.0|~6.0|~7.0|~8.0|~9.0",
                 "php": ">=7.0",
-                "psr/log": "~1.0",
-                "psr/simple-cache": "~1.0",
+                "psr/log": "~1.0|~2.0|~3.0",
+                "psr/simple-cache": "~1.0|~2.0",
                 "tightenco/collect": "~5.0|~6.0|~7.0|~8.0"
             },
             "require-dev": {
                 "mockery/mockery": "~1.0",
-                "phpunit/phpunit": "~6.0|~7.0|~8.0"
+                "symfony/phpunit-bridge": "~5.2|~6.0"
             },
             "suggest": {
                 "ext-fileinfo": "fileinfo is required when retrieving user encoded thumbnails"
             },
-            "time": "2021-08-09T15:22:35+00:00",
+            "time": "2022-02-09T13:54:20+00:00",
             "type": "library",
             "installation-source": "dist",
             "autoload": {
@@ -401,6 +401,78 @@
             ],
             "install-path": "../doctrine/annotations"
         },
+        {
+            "name": "doctrine/collections",
+            "version": "1.6.8",
+            "version_normalized": "1.6.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/collections.git",
+                "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/collections/zipball/1958a744696c6bb3bb0d28db2611dc11610e78af",
+                "reference": "1958a744696c6bb3bb0d28db2611dc11610e78af",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3 || ^8.0"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9.0",
+                "phpstan/phpstan": "^0.12",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.1.5",
+                "vimeo/psalm": "^4.2.1"
+            },
+            "time": "2021-08-10T18:51:53+00:00",
+            "type": "library",
+            "installation-source": "dist",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                },
+                {
+                    "name": "Johannes Schmitt",
+                    "email": "schmittjoh@gmail.com"
+                }
+            ],
+            "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.",
+            "homepage": "https://www.doctrine-project.org/projects/collections.html",
+            "keywords": [
+                "array",
+                "collections",
+                "iterators",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/collections/issues",
+                "source": "https://github.com/doctrine/collections/tree/1.6.8"
+            },
+            "install-path": "../doctrine/collections"
+        },
         {
             "name": "doctrine/lexer",
             "version": "1.2.1",
@@ -2741,6 +2813,68 @@
             },
             "install-path": "../rmccue/requests"
         },
+        {
+            "name": "simshaun/recurr",
+            "version": "v5.0.0",
+            "version_normalized": "5.0.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/simshaun/recurr.git",
+                "reference": "b5aa5b07a595023b67a558b810390dfa7160e3f5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/simshaun/recurr/zipball/b5aa5b07a595023b67a558b810390dfa7160e3f5",
+                "reference": "b5aa5b07a595023b67a558b810390dfa7160e3f5",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/collections": "~1.6",
+                "php": "^7.2||^8.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.16",
+                "symfony/yaml": "^5.3"
+            },
+            "time": "2021-09-09T03:42:57+00:00",
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "0.x-dev"
+                }
+            },
+            "installation-source": "dist",
+            "autoload": {
+                "psr-4": {
+                    "Recurr\\": "src/Recurr/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Shaun Simmons",
+                    "email": "shaun@shaun.pub",
+                    "homepage": "https://shaun.pub"
+                }
+            ],
+            "description": "PHP library for working with recurrence rules",
+            "homepage": "https://github.com/simshaun/recurr",
+            "keywords": [
+                "dates",
+                "events",
+                "recurrence",
+                "recurring",
+                "rrule"
+            ],
+            "support": {
+                "issues": "https://github.com/simshaun/recurr/issues",
+                "source": "https://github.com/simshaun/recurr/tree/v5.0.0"
+            },
+            "install-path": "../simshaun/recurr"
+        },
         {
             "name": "slim/psr7",
             "version": "1.3.0",
@@ -2942,6 +3076,69 @@
             ],
             "install-path": "../slim/slim"
         },
+        {
+            "name": "stripe/stripe-php",
+            "version": "v7.116.0",
+            "version_normalized": "7.116.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/stripe/stripe-php.git",
+                "reference": "7a39f594f213ed3f443a95adf769d1ecbc8393e7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/stripe/stripe-php/zipball/7a39f594f213ed3f443a95adf769d1ecbc8393e7",
+                "reference": "7a39f594f213ed3f443a95adf769d1ecbc8393e7",
+                "shasum": ""
+            },
+            "require": {
+                "ext-curl": "*",
+                "ext-json": "*",
+                "ext-mbstring": "*",
+                "php": ">=5.6.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "3.5.0",
+                "phpstan/phpstan": "^1.2",
+                "phpunit/phpunit": "^5.7 || ^9.0",
+                "squizlabs/php_codesniffer": "^3.3"
+            },
+            "time": "2022-03-02T15:51:15+00:00",
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "installation-source": "dist",
+            "autoload": {
+                "psr-4": {
+                    "Stripe\\": "lib/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Stripe and contributors",
+                    "homepage": "https://github.com/stripe/stripe-php/contributors"
+                }
+            ],
+            "description": "Stripe PHP Library",
+            "homepage": "https://stripe.com/",
+            "keywords": [
+                "api",
+                "payment processing",
+                "stripe"
+            ],
+            "support": {
+                "issues": "https://github.com/stripe/stripe-php/issues",
+                "source": "https://github.com/stripe/stripe-php/tree/v7.116.0"
+            },
+            "install-path": "../stripe/stripe-php"
+        },
         {
             "name": "symfony/deprecation-contracts",
             "version": "v2.1.3",

+ 621 - 602
api/vendor/composer/installed.php

@@ -1,604 +1,623 @@
-<?php return array (
-  'root' => 
-  array (
-    'pretty_version' => 'dev-master',
-    'version' => 'dev-master',
-    'aliases' => 
-    array (
+<?php return array(
+    'root' => array(
+        'pretty_version' => 'dev-master',
+        'version' => 'dev-master',
+        'type' => 'library',
+        'install_path' => __DIR__ . '/../../',
+        'aliases' => array(),
+        'reference' => '25cfc0e03bca212cab4bcf9336d3fc8449b11e61',
+        'name' => '__root__',
+        'dev' => true,
+    ),
+    'versions' => array(
+        '__root__' => array(
+            'pretty_version' => 'dev-master',
+            'version' => 'dev-master',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../../',
+            'aliases' => array(),
+            'reference' => '25cfc0e03bca212cab4bcf9336d3fc8449b11e61',
+            'dev_requirement' => false,
+        ),
+        'adldap2/adldap2' => array(
+            'pretty_version' => 'v10.4.1',
+            'version' => '10.4.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../adldap2/adldap2',
+            'aliases' => array(),
+            'reference' => '81aeb283f56e216ae925a9cc4241de56b1fd4453',
+            'dev_requirement' => false,
+        ),
+        'bcremer/line-reader' => array(
+            'pretty_version' => '1.1.0',
+            'version' => '1.1.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../bcremer/line-reader',
+            'aliases' => array(),
+            'reference' => '3ec3e200577630f1e58d30b4c1c468b877d8d0a7',
+            'dev_requirement' => false,
+        ),
+        'bogstag/oauth2-trakt' => array(
+            'pretty_version' => 'v1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../bogstag/oauth2-trakt',
+            'aliases' => array(),
+            'reference' => 'fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2',
+            'dev_requirement' => false,
+        ),
+        'composer/semver' => array(
+            'pretty_version' => '1.7.2',
+            'version' => '1.7.2.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/./semver',
+            'aliases' => array(),
+            'reference' => '647490bbcaf7fc4891c58f47b825eb99d19c377a',
+            'dev_requirement' => false,
+        ),
+        'dg/dibi' => array(
+            'dev_requirement' => false,
+            'replaced' => array(
+                0 => '*',
+            ),
+        ),
+        'dibi/dibi' => array(
+            'pretty_version' => 'v4.2.3',
+            'version' => '4.2.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../dibi/dibi',
+            'aliases' => array(),
+            'reference' => '73e16eb1a322599e8cdf350adcfdbc15eaf16577',
+            'dev_requirement' => false,
+        ),
+        'doctrine/annotations' => array(
+            'pretty_version' => '1.10.3',
+            'version' => '1.10.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../doctrine/annotations',
+            'aliases' => array(),
+            'reference' => '5db60a4969eba0e0c197a19c077780aadbc43c5d',
+            'dev_requirement' => false,
+        ),
+        'doctrine/collections' => array(
+            'pretty_version' => '1.6.8',
+            'version' => '1.6.8.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../doctrine/collections',
+            'aliases' => array(),
+            'reference' => '1958a744696c6bb3bb0d28db2611dc11610e78af',
+            'dev_requirement' => false,
+        ),
+        'doctrine/lexer' => array(
+            'pretty_version' => '1.2.1',
+            'version' => '1.2.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../doctrine/lexer',
+            'aliases' => array(),
+            'reference' => 'e864bbf5904cb8f5bb334f99209b48018522f042',
+            'dev_requirement' => false,
+        ),
+        'dragonmantank/cron-expression' => array(
+            'pretty_version' => 'v3.1.0',
+            'version' => '3.1.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../dragonmantank/cron-expression',
+            'aliases' => array(),
+            'reference' => '7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c',
+            'dev_requirement' => false,
+        ),
+        'fig/http-message-util' => array(
+            'pretty_version' => '1.1.4',
+            'version' => '1.1.4.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../fig/http-message-util',
+            'aliases' => array(),
+            'reference' => '3242caa9da7221a304b8f84eb9eaddae0a7cf422',
+            'dev_requirement' => false,
+        ),
+        'guzzlehttp/guzzle' => array(
+            'pretty_version' => '7.3.0',
+            'version' => '7.3.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../guzzlehttp/guzzle',
+            'aliases' => array(),
+            'reference' => '7008573787b430c1c1f650e3722d9bba59967628',
+            'dev_requirement' => false,
+        ),
+        'guzzlehttp/promises' => array(
+            'pretty_version' => '1.4.1',
+            'version' => '1.4.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../guzzlehttp/promises',
+            'aliases' => array(),
+            'reference' => '8e7d04f1f6450fef59366c399cfad4b9383aa30d',
+            'dev_requirement' => false,
+        ),
+        'guzzlehttp/psr7' => array(
+            'pretty_version' => '1.8.2',
+            'version' => '1.8.2.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../guzzlehttp/psr7',
+            'aliases' => array(),
+            'reference' => 'dc960a912984efb74d0a90222870c72c87f10c91',
+            'dev_requirement' => false,
+        ),
+        'illuminate/contracts' => array(
+            'pretty_version' => 'v5.8.0',
+            'version' => '5.8.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../illuminate/contracts',
+            'aliases' => array(),
+            'reference' => '3e3a9a654adbf798e05491a5dbf90112df1effde',
+            'dev_requirement' => false,
+        ),
+        'kryptonit3/couchpotato' => array(
+            'pretty_version' => '1.0.0',
+            'version' => '1.0.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../kryptonit3/couchpotato',
+            'aliases' => array(),
+            'reference' => '7a1fc892f70f120f74ff005850e923a0f1566376',
+            'dev_requirement' => false,
+        ),
+        'kryptonit3/sickrage' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../kryptonit3/sickrage',
+            'aliases' => array(),
+            'reference' => '441a293b5c219c3cdd1ebebd2bcf4518598f84aa',
+            'dev_requirement' => false,
+        ),
+        'kryptonit3/sonarr' => array(
+            'pretty_version' => '1.0.6.1',
+            'version' => '1.0.6.1',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../kryptonit3/sonarr',
+            'aliases' => array(),
+            'reference' => 'e30c5c783a837270bcef81571ca9b95909c52e5e',
+            'dev_requirement' => false,
+        ),
+        'lcobucci/jwt' => array(
+            'pretty_version' => '3.3.1',
+            'version' => '3.3.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../lcobucci/jwt',
+            'aliases' => array(),
+            'reference' => 'a11ec5f4b4d75d1fcd04e133dede4c317aac9e18',
+            'dev_requirement' => false,
+        ),
+        'league/oauth2-client' => array(
+            'pretty_version' => '2.6.0',
+            'version' => '2.6.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../league/oauth2-client',
+            'aliases' => array(),
+            'reference' => 'badb01e62383430706433191b82506b6df24ad98',
+            'dev_requirement' => false,
+        ),
+        'monolog/monolog' => array(
+            'pretty_version' => '1.26.1',
+            'version' => '1.26.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../monolog/monolog',
+            'aliases' => array(),
+            'reference' => 'c6b00f05152ae2c9b04a448f99c7590beb6042f5',
+            'dev_requirement' => false,
+        ),
+        'mtdowling/cron-expression' => array(
+            'dev_requirement' => false,
+            'replaced' => array(
+                0 => '^1.0',
+            ),
+        ),
+        'myclabs/php-enum' => array(
+            'pretty_version' => '1.8.0',
+            'version' => '1.8.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../myclabs/php-enum',
+            'aliases' => array(),
+            'reference' => '46cf3d8498b095bd33727b13fd5707263af99421',
+            'dev_requirement' => false,
+        ),
+        'nekonomokochan/php-json-logger' => array(
+            'pretty_version' => 'v1.3.1',
+            'version' => '1.3.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../nekonomokochan/php-json-logger',
+            'aliases' => array(),
+            'reference' => '6df126a82940a00d8ea2da6e0b7c58e3e57eb132',
+            'dev_requirement' => false,
+        ),
+        'nikic/fast-route' => array(
+            'pretty_version' => 'v1.3.0',
+            'version' => '1.3.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../nikic/fast-route',
+            'aliases' => array(),
+            'reference' => '181d480e08d9476e61381e04a71b34dc0432e812',
+            'dev_requirement' => false,
+        ),
+        'paquettg/php-html-parser' => array(
+            'pretty_version' => '3.1.1',
+            'version' => '3.1.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../paquettg/php-html-parser',
+            'aliases' => array(),
+            'reference' => '4e01a438ad5961cc2d7427eb9798d213c8a12629',
+            'dev_requirement' => false,
+        ),
+        'paquettg/string-encode' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../paquettg/string-encode',
+            'aliases' => array(),
+            'reference' => 'a8708e9fac9d5ddfc8fc2aac6004e2cd05d80fee',
+            'dev_requirement' => false,
+        ),
+        'paragonie/constant_time_encoding' => array(
+            'pretty_version' => 'v2.2.2',
+            'version' => '2.2.2.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../paragonie/constant_time_encoding',
+            'aliases' => array(),
+            'reference' => 'eccf915f45f911bfb189d1d1638d940ec6ee6e33',
+            'dev_requirement' => false,
+        ),
+        'paragonie/random_compat' => array(
+            'pretty_version' => 'v9.99.100',
+            'version' => '9.99.100.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../paragonie/random_compat',
+            'aliases' => array(),
+            'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
+            'dev_requirement' => false,
+        ),
+        'paragonie/sodium_compat' => array(
+            'pretty_version' => 'v1.6.4',
+            'version' => '1.6.4.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../paragonie/sodium_compat',
+            'aliases' => array(),
+            'reference' => '3f2fd07977541b4d630ea0365ad0eceddee5179c',
+            'dev_requirement' => false,
+        ),
+        'peppeocchi/php-cron-scheduler' => array(
+            'pretty_version' => 'v4.0',
+            'version' => '4.0.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../peppeocchi/php-cron-scheduler',
+            'aliases' => array(),
+            'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
+            'dev_requirement' => false,
+        ),
+        'php-http/httplug' => array(
+            'pretty_version' => '2.2.0',
+            'version' => '2.2.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../php-http/httplug',
+            'aliases' => array(),
+            'reference' => '191a0a1b41ed026b717421931f8d3bd2514ffbf9',
+            'dev_requirement' => false,
+        ),
+        'php-http/promise' => array(
+            'pretty_version' => '1.1.0',
+            'version' => '1.1.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../php-http/promise',
+            'aliases' => array(),
+            'reference' => '4c4c1f9b7289a2ec57cde7f1e9762a5789506f88',
+            'dev_requirement' => false,
+        ),
+        'phpmailer/phpmailer' => array(
+            'pretty_version' => 'v6.5.0',
+            'version' => '6.5.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../phpmailer/phpmailer',
+            'aliases' => array(),
+            'reference' => 'a5b5c43e50b7fba655f793ad27303cd74c57363c',
+            'dev_requirement' => false,
+        ),
+        'pragmarx/google2fa' => array(
+            'pretty_version' => 'v3.0.3',
+            'version' => '3.0.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../pragmarx/google2fa',
+            'aliases' => array(),
+            'reference' => '6949226739e4424f40031e6f1c96b1fd64047335',
+            'dev_requirement' => false,
+        ),
+        'psr/container' => array(
+            'pretty_version' => '1.1.1',
+            'version' => '1.1.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/container',
+            'aliases' => array(),
+            'reference' => '8622567409010282b7aeebe4bb841fe98b58dcaf',
+            'dev_requirement' => false,
+        ),
+        'psr/http-client' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/http-client',
+            'aliases' => array(),
+            'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
+            'dev_requirement' => false,
+        ),
+        'psr/http-client-implementation' => array(
+            'dev_requirement' => false,
+            'provided' => array(
+                0 => '1.0',
+            ),
+        ),
+        'psr/http-factory' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/http-factory',
+            'aliases' => array(),
+            'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
+            'dev_requirement' => false,
+        ),
+        'psr/http-factory-implementation' => array(
+            'dev_requirement' => false,
+            'provided' => array(
+                0 => '1.0',
+            ),
+        ),
+        'psr/http-message' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/http-message',
+            'aliases' => array(),
+            'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
+            'dev_requirement' => false,
+        ),
+        'psr/http-message-implementation' => array(
+            'dev_requirement' => false,
+            'provided' => array(
+                0 => '1.0',
+            ),
+        ),
+        'psr/http-server-handler' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/http-server-handler',
+            'aliases' => array(),
+            'reference' => 'aff2f80e33b7f026ec96bb42f63242dc50ffcae7',
+            'dev_requirement' => false,
+        ),
+        'psr/http-server-middleware' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/http-server-middleware',
+            'aliases' => array(),
+            'reference' => '2296f45510945530b9dceb8bcedb5cb84d40c5f5',
+            'dev_requirement' => false,
+        ),
+        'psr/log' => array(
+            'pretty_version' => '1.1.4',
+            'version' => '1.1.4.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/log',
+            'aliases' => array(),
+            'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
+            'dev_requirement' => false,
+        ),
+        'psr/log-implementation' => array(
+            'dev_requirement' => false,
+            'provided' => array(
+                0 => '1.0.0',
+            ),
+        ),
+        'psr/simple-cache' => array(
+            'pretty_version' => '1.0.1',
+            'version' => '1.0.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../psr/simple-cache',
+            'aliases' => array(),
+            'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
+            'dev_requirement' => false,
+        ),
+        'pusher/pusher-php-server' => array(
+            'pretty_version' => 'v4.1.5',
+            'version' => '4.1.5.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../pusher/pusher-php-server',
+            'aliases' => array(),
+            'reference' => '251f22602320c1b1aff84798fe74f3f7ee0504a9',
+            'dev_requirement' => false,
+        ),
+        'ralouphie/getallheaders' => array(
+            'pretty_version' => '3.0.3',
+            'version' => '3.0.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../ralouphie/getallheaders',
+            'aliases' => array(),
+            'reference' => '120b605dfeb996808c31b6477290a714d356e822',
+            'dev_requirement' => false,
+        ),
+        'ramsey/uuid' => array(
+            'pretty_version' => '3.9.6',
+            'version' => '3.9.6.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../ramsey/uuid',
+            'aliases' => array(),
+            'reference' => 'ffa80ab953edd85d5b6c004f96181a538aad35a3',
+            'dev_requirement' => false,
+        ),
+        'rhumsaa/uuid' => array(
+            'dev_requirement' => false,
+            'replaced' => array(
+                0 => '3.9.6',
+            ),
+        ),
+        'rmccue/requests' => array(
+            'pretty_version' => 'v1.8.0',
+            'version' => '1.8.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../rmccue/requests',
+            'aliases' => array(),
+            'reference' => 'afbe4790e4def03581c4a0963a1e8aa01f6030f1',
+            'dev_requirement' => false,
+        ),
+        'simshaun/recurr' => array(
+            'pretty_version' => 'v5.0.0',
+            'version' => '5.0.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../simshaun/recurr',
+            'aliases' => array(),
+            'reference' => 'b5aa5b07a595023b67a558b810390dfa7160e3f5',
+            'dev_requirement' => false,
+        ),
+        'slim/psr7' => array(
+            'pretty_version' => '1.3.0',
+            'version' => '1.3.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../slim/psr7',
+            'aliases' => array(),
+            'reference' => '235d2e5a5ee1ad4b97b96870f37f3091b22fffd7',
+            'dev_requirement' => false,
+        ),
+        'slim/slim' => array(
+            'pretty_version' => '4.7.1',
+            'version' => '4.7.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../slim/slim',
+            'aliases' => array(),
+            'reference' => '0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f',
+            'dev_requirement' => false,
+        ),
+        'stripe/stripe-php' => array(
+            'pretty_version' => 'v7.116.0',
+            'version' => '7.116.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../stripe/stripe-php',
+            'aliases' => array(),
+            'reference' => '7a39f594f213ed3f443a95adf769d1ecbc8393e7',
+            'dev_requirement' => false,
+        ),
+        'symfony/deprecation-contracts' => array(
+            'pretty_version' => 'v2.1.3',
+            'version' => '2.1.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/deprecation-contracts',
+            'aliases' => array(),
+            'reference' => '5e20b83385a77593259c9f8beb2c43cd03b2ac14',
+            'dev_requirement' => false,
+        ),
+        'symfony/finder' => array(
+            'pretty_version' => 'v5.1.3',
+            'version' => '5.1.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/finder',
+            'aliases' => array(),
+            'reference' => '4298870062bfc667cb78d2b379be4bf5dec5f187',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-ctype' => array(
+            'pretty_version' => 'v1.22.1',
+            'version' => '1.22.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
+            'aliases' => array(),
+            'reference' => 'c6c942b1ac76c82448322025e084cadc56048b4e',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-mbstring' => array(
+            'pretty_version' => 'v1.22.1',
+            'version' => '1.22.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
+            'aliases' => array(),
+            'reference' => '5232de97ee3b75b0360528dae24e73db49566ab1',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-php56' => array(
+            'pretty_version' => 'v1.9.0',
+            'version' => '1.9.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-php56',
+            'aliases' => array(),
+            'reference' => '7b4fc009172cc0196535b0328bd1226284a28000',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-php72' => array(
+            'pretty_version' => 'v1.22.1',
+            'version' => '1.22.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-php72',
+            'aliases' => array(),
+            'reference' => 'cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-php80' => array(
+            'pretty_version' => 'v1.22.1',
+            'version' => '1.22.1.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-php80',
+            'aliases' => array(),
+            'reference' => 'dc3063ba22c2a1fd2f45ed856374d79114998f91',
+            'dev_requirement' => false,
+        ),
+        'symfony/polyfill-util' => array(
+            'pretty_version' => 'v1.9.0',
+            'version' => '1.9.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/polyfill-util',
+            'aliases' => array(),
+            'reference' => '8e15d04ba3440984d23e7964b2ee1d25c8de1581',
+            'dev_requirement' => false,
+        ),
+        'symfony/var-dumper' => array(
+            'pretty_version' => 'v4.2.3',
+            'version' => '4.2.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/var-dumper',
+            'aliases' => array(),
+            'reference' => '223bda89f9be41cf7033eeaf11bc61a280489c17',
+            'dev_requirement' => false,
+        ),
+        'symfony/yaml' => array(
+            'pretty_version' => 'v5.1.3',
+            'version' => '5.1.3.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../symfony/yaml',
+            'aliases' => array(),
+            'reference' => 'ea342353a3ef4f453809acc4ebc55382231d4d23',
+            'dev_requirement' => false,
+        ),
+        'tightenco/collect' => array(
+            'pretty_version' => 'v5.7.27',
+            'version' => '5.7.27.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../tightenco/collect',
+            'aliases' => array(),
+            'reference' => 'c1a36a2a8a0aa731c1acdcd83f57724ffe630d00',
+            'dev_requirement' => false,
+        ),
+        'webmozart/assert' => array(
+            'pretty_version' => '1.10.0',
+            'version' => '1.10.0.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../webmozart/assert',
+            'aliases' => array(),
+            'reference' => '6964c76c7804814a842473e0c8fd15bab0f18e25',
+            'dev_requirement' => false,
+        ),
+        'zircote/swagger-php' => array(
+            'pretty_version' => '3.0.4',
+            'version' => '3.0.4.0',
+            'type' => 'library',
+            'install_path' => __DIR__ . '/../zircote/swagger-php',
+            'aliases' => array(),
+            'reference' => 'fa47d62c22c95272625624fbf8109fa46ffac43b',
+            'dev_requirement' => false,
+        ),
     ),
-    'reference' => 'ed5f925046b2d5d1fcfd5b5a24fbe7d16936c1d4',
-    'name' => '__root__',
-  ),
-  'versions' => 
-  array (
-    '__root__' => 
-    array (
-      'pretty_version' => 'dev-master',
-      'version' => 'dev-master',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ed5f925046b2d5d1fcfd5b5a24fbe7d16936c1d4',
-    ),
-    'adldap2/adldap2' => 
-    array (
-      'pretty_version' => 'v10.3.3',
-      'version' => '10.3.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c2a8f72455d3438377d955fc0f4b9ed836b47463',
-    ),
-    'bcremer/line-reader' => 
-    array (
-      'pretty_version' => '1.1.0',
-      'version' => '1.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3ec3e200577630f1e58d30b4c1c468b877d8d0a7',
-    ),
-    'bogstag/oauth2-trakt' => 
-    array (
-      'pretty_version' => 'v1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'fbb9253d9e317e84dc2b3f1253afc1dcbb4414a2',
-    ),
-    'composer/semver' => 
-    array (
-      'pretty_version' => '1.7.2',
-      'version' => '1.7.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '647490bbcaf7fc4891c58f47b825eb99d19c377a',
-    ),
-    'dg/dibi' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '*',
-      ),
-    ),
-    'dibi/dibi' => 
-    array (
-      'pretty_version' => 'v4.2.3',
-      'version' => '4.2.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '73e16eb1a322599e8cdf350adcfdbc15eaf16577',
-    ),
-    'doctrine/annotations' => 
-    array (
-      'pretty_version' => '1.10.3',
-      'version' => '1.10.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5db60a4969eba0e0c197a19c077780aadbc43c5d',
-    ),
-    'doctrine/lexer' => 
-    array (
-      'pretty_version' => '1.2.1',
-      'version' => '1.2.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'e864bbf5904cb8f5bb334f99209b48018522f042',
-    ),
-    'dragonmantank/cron-expression' => 
-    array (
-      'pretty_version' => 'v3.1.0',
-      'version' => '3.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c',
-    ),
-    'fig/http-message-util' => 
-    array (
-      'pretty_version' => '1.1.4',
-      'version' => '1.1.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3242caa9da7221a304b8f84eb9eaddae0a7cf422',
-    ),
-    'guzzlehttp/guzzle' => 
-    array (
-      'pretty_version' => '7.3.0',
-      'version' => '7.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7008573787b430c1c1f650e3722d9bba59967628',
-    ),
-    'guzzlehttp/promises' => 
-    array (
-      'pretty_version' => '1.4.1',
-      'version' => '1.4.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8e7d04f1f6450fef59366c399cfad4b9383aa30d',
-    ),
-    'guzzlehttp/psr7' => 
-    array (
-      'pretty_version' => '1.8.2',
-      'version' => '1.8.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'dc960a912984efb74d0a90222870c72c87f10c91',
-    ),
-    'illuminate/contracts' => 
-    array (
-      'pretty_version' => 'v5.8.0',
-      'version' => '5.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3e3a9a654adbf798e05491a5dbf90112df1effde',
-    ),
-    'kryptonit3/couchpotato' => 
-    array (
-      'pretty_version' => '1.0.0',
-      'version' => '1.0.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7a1fc892f70f120f74ff005850e923a0f1566376',
-    ),
-    'kryptonit3/sickrage' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '441a293b5c219c3cdd1ebebd2bcf4518598f84aa',
-    ),
-    'kryptonit3/sonarr' => 
-    array (
-      'pretty_version' => '1.0.6.1',
-      'version' => '1.0.6.1',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'e30c5c783a837270bcef81571ca9b95909c52e5e',
-    ),
-    'lcobucci/jwt' => 
-    array (
-      'pretty_version' => '3.3.1',
-      'version' => '3.3.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a11ec5f4b4d75d1fcd04e133dede4c317aac9e18',
-    ),
-    'league/oauth2-client' => 
-    array (
-      'pretty_version' => '2.6.0',
-      'version' => '2.6.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'badb01e62383430706433191b82506b6df24ad98',
-    ),
-    'monolog/monolog' => 
-    array (
-      'pretty_version' => '1.26.1',
-      'version' => '1.26.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c6b00f05152ae2c9b04a448f99c7590beb6042f5',
-    ),
-    'mtdowling/cron-expression' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '^1.0',
-      ),
-    ),
-    'myclabs/php-enum' => 
-    array (
-      'pretty_version' => '1.8.0',
-      'version' => '1.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '46cf3d8498b095bd33727b13fd5707263af99421',
-    ),
-    'nekonomokochan/php-json-logger' => 
-    array (
-      'pretty_version' => 'v1.3.1',
-      'version' => '1.3.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6df126a82940a00d8ea2da6e0b7c58e3e57eb132',
-    ),
-    'nikic/fast-route' => 
-    array (
-      'pretty_version' => 'v1.3.0',
-      'version' => '1.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '181d480e08d9476e61381e04a71b34dc0432e812',
-    ),
-    'paquettg/php-html-parser' => 
-    array (
-      'pretty_version' => '3.1.1',
-      'version' => '3.1.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4e01a438ad5961cc2d7427eb9798d213c8a12629',
-    ),
-    'paquettg/string-encode' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a8708e9fac9d5ddfc8fc2aac6004e2cd05d80fee',
-    ),
-    'paragonie/constant_time_encoding' => 
-    array (
-      'pretty_version' => 'v2.2.2',
-      'version' => '2.2.2.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'eccf915f45f911bfb189d1d1638d940ec6ee6e33',
-    ),
-    'paragonie/random_compat' => 
-    array (
-      'pretty_version' => 'v9.99.100',
-      'version' => '9.99.100.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '996434e5492cb4c3edcb9168db6fbb1359ef965a',
-    ),
-    'paragonie/sodium_compat' => 
-    array (
-      'pretty_version' => 'v1.6.4',
-      'version' => '1.6.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '3f2fd07977541b4d630ea0365ad0eceddee5179c',
-    ),
-    'peppeocchi/php-cron-scheduler' => 
-    array (
-      'pretty_version' => 'v4.0',
-      'version' => '4.0.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '0acfa032e60f0ea22a27b96a6b15a673a31d3448',
-    ),
-    'php-http/httplug' => 
-    array (
-      'pretty_version' => '2.2.0',
-      'version' => '2.2.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '191a0a1b41ed026b717421931f8d3bd2514ffbf9',
-    ),
-    'php-http/promise' => 
-    array (
-      'pretty_version' => '1.1.0',
-      'version' => '1.1.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4c4c1f9b7289a2ec57cde7f1e9762a5789506f88',
-    ),
-    'phpmailer/phpmailer' => 
-    array (
-      'pretty_version' => 'v6.5.0',
-      'version' => '6.5.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'a5b5c43e50b7fba655f793ad27303cd74c57363c',
-    ),
-    'pragmarx/google2fa' => 
-    array (
-      'pretty_version' => 'v3.0.3',
-      'version' => '3.0.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6949226739e4424f40031e6f1c96b1fd64047335',
-    ),
-    'psr/container' => 
-    array (
-      'pretty_version' => '1.1.1',
-      'version' => '1.1.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8622567409010282b7aeebe4bb841fe98b58dcaf',
-    ),
-    'psr/http-client' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '2dfb5f6c5eff0e91e20e913f8c5452ed95b86621',
-    ),
-    'psr/http-client-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-factory' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '12ac7fcd07e5b077433f5f2bee95b3a771bf61be',
-    ),
-    'psr/http-factory-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-message' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'f6561bf28d520154e4b0ec72be95418abe6d9363',
-    ),
-    'psr/http-message-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0',
-      ),
-    ),
-    'psr/http-server-handler' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'aff2f80e33b7f026ec96bb42f63242dc50ffcae7',
-    ),
-    'psr/http-server-middleware' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '2296f45510945530b9dceb8bcedb5cb84d40c5f5',
-    ),
-    'psr/log' => 
-    array (
-      'pretty_version' => '1.1.4',
-      'version' => '1.1.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'd49695b909c3b7628b6289db5479a1c204601f11',
-    ),
-    'psr/log-implementation' => 
-    array (
-      'provided' => 
-      array (
-        0 => '1.0.0',
-      ),
-    ),
-    'psr/simple-cache' => 
-    array (
-      'pretty_version' => '1.0.1',
-      'version' => '1.0.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '408d5eafb83c57f6365a3ca330ff23aa4a5fa39b',
-    ),
-    'pusher/pusher-php-server' => 
-    array (
-      'pretty_version' => 'v4.1.5',
-      'version' => '4.1.5.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '251f22602320c1b1aff84798fe74f3f7ee0504a9',
-    ),
-    'ralouphie/getallheaders' => 
-    array (
-      'pretty_version' => '3.0.3',
-      'version' => '3.0.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '120b605dfeb996808c31b6477290a714d356e822',
-    ),
-    'ramsey/uuid' => 
-    array (
-      'pretty_version' => '3.9.6',
-      'version' => '3.9.6.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ffa80ab953edd85d5b6c004f96181a538aad35a3',
-    ),
-    'rhumsaa/uuid' => 
-    array (
-      'replaced' => 
-      array (
-        0 => '3.9.6',
-      ),
-    ),
-    'rmccue/requests' => 
-    array (
-      'pretty_version' => 'v1.8.0',
-      'version' => '1.8.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'afbe4790e4def03581c4a0963a1e8aa01f6030f1',
-    ),
-    'slim/psr7' => 
-    array (
-      'pretty_version' => '1.3.0',
-      'version' => '1.3.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '235d2e5a5ee1ad4b97b96870f37f3091b22fffd7',
-    ),
-    'slim/slim' => 
-    array (
-      'pretty_version' => '4.7.1',
-      'version' => '4.7.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '0905e0775f8c1cfb3bbcfabeb6588dcfd8b82d3f',
-    ),
-    'symfony/deprecation-contracts' => 
-    array (
-      'pretty_version' => 'v2.1.3',
-      'version' => '2.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5e20b83385a77593259c9f8beb2c43cd03b2ac14',
-    ),
-    'symfony/finder' => 
-    array (
-      'pretty_version' => 'v5.1.3',
-      'version' => '5.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '4298870062bfc667cb78d2b379be4bf5dec5f187',
-    ),
-    'symfony/polyfill-ctype' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c6c942b1ac76c82448322025e084cadc56048b4e',
-    ),
-    'symfony/polyfill-mbstring' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '5232de97ee3b75b0360528dae24e73db49566ab1',
-    ),
-    'symfony/polyfill-php56' => 
-    array (
-      'pretty_version' => 'v1.9.0',
-      'version' => '1.9.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '7b4fc009172cc0196535b0328bd1226284a28000',
-    ),
-    'symfony/polyfill-php72' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'cc6e6f9b39fe8075b3dabfbaf5b5f645ae1340c9',
-    ),
-    'symfony/polyfill-php80' => 
-    array (
-      'pretty_version' => 'v1.22.1',
-      'version' => '1.22.1.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'dc3063ba22c2a1fd2f45ed856374d79114998f91',
-    ),
-    'symfony/polyfill-util' => 
-    array (
-      'pretty_version' => 'v1.9.0',
-      'version' => '1.9.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '8e15d04ba3440984d23e7964b2ee1d25c8de1581',
-    ),
-    'symfony/var-dumper' => 
-    array (
-      'pretty_version' => 'v4.2.3',
-      'version' => '4.2.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '223bda89f9be41cf7033eeaf11bc61a280489c17',
-    ),
-    'symfony/yaml' => 
-    array (
-      'pretty_version' => 'v5.1.3',
-      'version' => '5.1.3.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'ea342353a3ef4f453809acc4ebc55382231d4d23',
-    ),
-    'tightenco/collect' => 
-    array (
-      'pretty_version' => 'v5.7.27',
-      'version' => '5.7.27.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'c1a36a2a8a0aa731c1acdcd83f57724ffe630d00',
-    ),
-    'webmozart/assert' => 
-    array (
-      'pretty_version' => '1.10.0',
-      'version' => '1.10.0.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => '6964c76c7804814a842473e0c8fd15bab0f18e25',
-    ),
-    'zircote/swagger-php' => 
-    array (
-      'pretty_version' => '3.0.4',
-      'version' => '3.0.4.0',
-      'aliases' => 
-      array (
-      ),
-      'reference' => 'fa47d62c22c95272625624fbf8109fa46ffac43b',
-    ),
-  ),
 );

+ 26 - 0
api/vendor/doctrine/collections/.doctrine-project.json

@@ -0,0 +1,26 @@
+{
+    "active": true,
+    "name": "Collections",
+    "slug": "collections",
+    "docsSlug": "doctrine-collections",
+    "versions": [
+        {
+            "name": "2.0",
+            "branchName": "2.0.x",
+            "slug": "latest",
+            "upcoming": true
+        },
+        {
+            "name": "1.7",
+            "branchName": "1.7.x",
+            "slug": "1.7",
+            "upcoming": true
+        },
+        {
+            "name": "1.6",
+            "branchName": "1.6.x",
+            "slug": "1.6",
+            "current": true
+        }
+    ]
+}

+ 54 - 0
api/vendor/doctrine/collections/CONTRIBUTING.md

@@ -0,0 +1,54 @@
+# Contribute to Doctrine
+
+Thank you for contributing to Doctrine!
+
+Before we can merge your Pull-Request here are some guidelines that you need to follow.
+These guidelines exist not to annoy you, but to keep the code base clean,
+unified and future proof.
+
+## We only accept PRs  to "master"
+
+Our branching strategy is "everything to master first", even
+bugfixes and we then merge them into the stable branches. You should only 
+open pull requests against the master branch. Otherwise we cannot accept the PR.
+
+There is one exception to the rule, when we merged a bug into some stable branches
+we do occasionally accept pull requests that merge the same bug fix into earlier
+branches.
+
+## Coding Standard
+
+We use the [Doctrine Coding Standard](https://github.com/doctrine/coding-standard).
+
+## Unit-Tests
+
+Please try to add a test for your pull-request.
+
+* If you want to contribute new functionality add unit- or functional tests
+  depending on the scope of the feature.
+
+You can run the unit-tests by calling ``vendor/bin/phpunit`` from the root of the project.
+It will run all the project tests.
+
+In order to do that, you will need a fresh copy of doctrine/collections, and you
+will have to run a composer installation in the project:
+
+```sh
+git clone git@github.com:doctrine/collections.git
+cd collections
+curl -sS https://getcomposer.org/installer | php --
+./composer.phar install
+```
+
+## Github Actions
+
+We automatically run your pull request through Github Actions against supported
+PHP versions. If you break the tests, we cannot merge your code, so please make
+sure that your code is working before opening up a Pull-Request.
+
+## Getting merged
+
+Please allow us time to review your pull requests. We will give our best to review
+everything as fast as possible, but cannot always live up to our own expectations.
+
+Thank you very much again for your contribution!

+ 19 - 0
api/vendor/doctrine/collections/LICENSE

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

+ 92 - 0
api/vendor/doctrine/collections/README.md

@@ -0,0 +1,92 @@
+# Doctrine Collections
+
+[![Build Status](https://github.com/doctrine/collections/workflows/Continuous%20Integration/badge.svg)](https://github.com/doctrine/collections/actions)
+[![Code Coverage](https://codecov.io/gh/doctrine/collections/branch/master/graph/badge.svg)](https://codecov.io/gh/doctrine/collections/branch/master)
+
+Collections Abstraction library
+
+## Changelog
+
+### v1.6.1
+
+This release, combined with the release of [`doctrine/collections` `v1.6.1`](https://github.com/doctrine/collections/releases/tag/v1.6.1),
+fixes an issue where parsing annotations was not possible
+for classes within `doctrine/collections`.
+
+Specifically, `v1.6.0` introduced Psalm-specific annotations
+such as (for example) `@template` and `@template-implements`,
+which were both incorrectly recognized as `@template`.
+
+`@template` has therefore been removed, and instead we use
+the prefixed `@psalm-template`, which is no longer parsed
+by `doctrine/collections` `v1.6.1`
+
+Total issues resolved: **1**
+
+- [186: Use `@psalm-template` annotation to avoid clashes](https://github.com/doctrine/collections/pull/186) thanks to @muglug
+
+### v1.6.0
+
+This release bumps the minimum required PHP version to 7.1.3.
+
+Following improvements were introduced:
+
+ * `ArrayCollection#filter()` now allows filtering by key, value or both.
+ * When using the `ClosureExpressionVisitor` over objects with a defined
+   accessor and property, the accessor is prioritised.
+ * Updated testing tools and coding standards, autoloading, which also
+   led to marginal performance improvements
+ * Introduced generic type docblock declarations from [psalm](https://github.com/vimeo/psalm),
+   which should allow users to declare `/** @var Collection<KeyType, ValueType> */`
+   in their code, and leverage the type propagation deriving from that.
+
+Total issues resolved: **16**
+
+- [127: Use PSR-4](https://github.com/doctrine/collections/pull/127) thanks to @Nyholm
+- [129: Remove space in method declaration](https://github.com/doctrine/collections/pull/129) thanks to @bounoable
+- [130: Update build to add PHPCS and PHPStan](https://github.com/doctrine/collections/pull/130) thanks to @lcobucci
+- [131: ClosureExpressionVisitor &gt; Don't duplicate the accessor when the field already starts with it](https://github.com/doctrine/collections/pull/131) thanks to @ruudk
+- [139: Apply Doctrine CS 2.1](https://github.com/doctrine/collections/pull/139) thanks to @Majkl578
+- [142: CS 4.0, version composer.lock, merge stages](https://github.com/doctrine/collections/pull/142) thanks to @Majkl578
+- [144: Update to PHPUnit 7](https://github.com/doctrine/collections/pull/144) thanks to @carusogabriel
+- [146: Update changelog for v1.4.0 and v1.5.0](https://github.com/doctrine/collections/pull/146) thanks to @GromNaN
+- [154: Update index.rst](https://github.com/doctrine/collections/pull/154) thanks to @chraiet
+- [158: Extract Selectable method into own documentation section](https://github.com/doctrine/collections/pull/158) thanks to @SenseException
+- [160: Update homepage](https://github.com/doctrine/collections/pull/160) thanks to @Majkl578
+- [165: Allow `ArrayCollection#filter()` to filter by key, value or both](https://github.com/doctrine/collections/issues/165) thanks to @0x13a
+- [167: Allow `ArrayCollection#filter()` to filter by key and also value](https://github.com/doctrine/collections/pull/167) thanks to @0x13a
+- [175: CI: Test against PHP 7.4snapshot instead of nightly (8.0)](https://github.com/doctrine/collections/pull/175) thanks to @Majkl578
+- [177: Generify collections using Psalm](https://github.com/doctrine/collections/pull/177) thanks to @nschoellhorn
+- [178: Updated doctrine/coding-standard to 6.0](https://github.com/doctrine/collections/pull/178) thanks to @patrickjahns
+
+### v1.5.0
+
+* [Require PHP 7.1+](https://github.com/doctrine/collections/pull/105)
+* [Drop HHVM support](https://github.com/doctrine/collections/pull/118)
+
+### v1.4.0
+
+* [Require PHP 5.6+](https://github.com/doctrine/collections/pull/105)
+* [Add `ArrayCollection::createFrom()`](https://github.com/doctrine/collections/pull/91)
+* [Support non-camel-case naming](https://github.com/doctrine/collections/pull/57)
+* [Comparison `START_WITH`, `END_WITH`](https://github.com/doctrine/collections/pull/78)
+* [Comparison `MEMBER_OF`](https://github.com/doctrine/collections/pull/66)
+* [Add Contributing guide](https://github.com/doctrine/collections/pull/103)
+
+### v1.3.0
+
+* [Explicit casting of first and max results in criteria API](https://github.com/doctrine/collections/pull/26)
+* [Keep keys when using `ArrayCollection#matching()` with sorting](https://github.com/doctrine/collections/pull/49)
+* [Made `AbstractLazyCollection#$initialized` protected for extensibility](https://github.com/doctrine/collections/pull/52)
+
+### v1.2.0
+
+* Add a new ``AbstractLazyCollection``
+
+### v1.1.0
+
+* Deprecated ``Comparison::IS``, because it's only there for SQL semantics.
+  These are fixed in the ORM instead.
+* Add ``Comparison::CONTAINS`` to perform partial string matches:
+
+        $criteria->andWhere($criteria->expr()->contains('property', 'Foo'));

+ 37 - 0
api/vendor/doctrine/collections/composer.json

@@ -0,0 +1,37 @@
+{
+    "name": "doctrine/collections",
+    "type": "library",
+    "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.",
+    "keywords": [
+        "php",
+        "collections",
+        "array",
+        "iterators"
+    ],
+    "homepage": "https://www.doctrine-project.org/projects/collections.html",
+    "license": "MIT",
+    "authors": [
+        {"name": "Guilherme Blanco", "email": "guilhermeblanco@gmail.com"},
+        {"name": "Roman Borschel", "email": "roman@code-factory.org"},
+        {"name": "Benjamin Eberlei", "email": "kontakt@beberlei.de"},
+        {"name": "Jonathan Wage", "email": "jonwage@gmail.com"},
+        {"name": "Johannes Schmitt", "email": "schmittjoh@gmail.com"}
+    ],
+    "require": {
+        "php": "^7.1.3 || ^8.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^7.5 || ^8.5 || ^9.1.5",
+        "doctrine/coding-standard": "^9.0",
+        "phpstan/phpstan": "^0.12",
+        "vimeo/psalm": "^4.2.1"
+    },
+    "autoload": {
+        "psr-4": { "Doctrine\\Common\\Collections\\": "lib/Doctrine/Common/Collections" }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "Doctrine\\Tests\\": "tests/Doctrine/Tests"
+        }
+    }
+}

+ 26 - 0
api/vendor/doctrine/collections/docs/en/derived-collections.rst

@@ -0,0 +1,26 @@
+Derived Collections
+===================
+
+You can create custom collection classes by extending the
+``Doctrine\Common\Collections\ArrayCollection`` class. If the
+``__construct`` semantics are different from the default ``ArrayCollection``
+you can override the ``createFrom`` method:
+
+.. code-block:: php
+    final class DerivedArrayCollection extends ArrayCollection
+    {
+        /** @var \stdClass */
+        private $foo;
+
+        public function __construct(\stdClass $foo, array $elements = [])
+        {
+            $this->foo = $foo;
+
+            parent::__construct($elements);
+        }
+
+        protected function createFrom(array $elements) : self
+        {
+            return new static($this->foo, $elements);
+        }
+    }

+ 173 - 0
api/vendor/doctrine/collections/docs/en/expression-builder.rst

@@ -0,0 +1,173 @@
+Expression Builder
+==================
+
+The Expression Builder is a convenient fluent interface for
+building expressions to be used with the ``Doctrine\Common\Collections\Criteria``
+class:
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $criteria = new Criteria();
+    $criteria->where($expressionBuilder->eq('name', 'jwage'));
+    $criteria->orWhere($expressionBuilder->eq('name', 'romanb'));
+
+    $collection->matching($criteria);
+
+The ``ExpressionBuilder`` has the following API:
+
+andX
+----
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->andX(
+        $expressionBuilder->eq('foo', 1),
+        $expressionBuilder->eq('bar', 1)
+    );
+
+    $collection->matching(new Criteria($expression));
+
+orX
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->orX(
+        $expressionBuilder->eq('foo', 1),
+        $expressionBuilder->eq('bar', 1)
+    );
+
+    $collection->matching(new Criteria($expression));
+
+eq
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->eq('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+gt
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->gt('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+lt
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->lt('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+gte
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->gte('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+lte
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->lte('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+neq
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->neq('foo', 1);
+
+    $collection->matching(new Criteria($expression));
+
+isNull
+------
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->isNull('foo');
+
+    $collection->matching(new Criteria($expression));
+
+in
+---
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->in('foo', ['value1', 'value2']);
+
+    $collection->matching(new Criteria($expression));
+
+notIn
+-----
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->notIn('foo', ['value1', 'value2']);
+
+    $collection->matching(new Criteria($expression));
+
+contains
+--------
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->contains('foo', 'value1');
+
+    $collection->matching(new Criteria($expression));
+
+memberOf
+--------
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->memberOf('foo', ['value1', 'value2']);
+
+    $collection->matching(new Criteria($expression));
+
+startsWith
+----------
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->startsWith('foo', 'hello');
+
+    $collection->matching(new Criteria($expression));
+
+endsWith
+--------
+
+.. code-block:: php
+    $expressionBuilder = Criteria::expr();
+
+    $expression = $expressionBuilder->endsWith('foo', 'world');
+
+    $collection->matching(new Criteria($expression));

+ 102 - 0
api/vendor/doctrine/collections/docs/en/expressions.rst

@@ -0,0 +1,102 @@
+Expressions
+===========
+
+The ``Doctrine\Common\Collections\Expr\Comparison`` class
+can be used to create expressions to be used with the
+``Doctrine\Common\Collections\Criteria`` class. It has the
+following operator constants:
+
+- ``Comparison::EQ``
+- ``Comparison::NEQ``
+- ``Comparison::LT``
+- ``Comparison::LTE``
+- ``Comparison::GT``
+- ``Comparison::GTE``
+- ``Comparison::IS``
+- ``Comparison::IN``
+- ``Comparison::NIN``
+- ``Comparison::CONTAINS``
+- ``Comparison::MEMBER_OF``
+- ``Comparison::STARTS_WITH``
+- ``Comparison::ENDS_WITH``
+
+The ``Doctrine\Common\Collections\Criteria`` class has the following
+API to be used with expressions:
+
+where
+-----
+
+Sets the where expression to evaluate when this Criteria is searched for.
+
+.. code-block:: php
+    $expr = new Comparison('key', Comparison::EQ, 'value');
+
+    $criteria->where($expr);
+
+andWhere
+--------
+
+Appends the where expression to evaluate when this Criteria is searched for
+using an AND with previous expression.
+
+.. code-block:: php
+    $expr = new Comparison('key', Comparison::EQ, 'value');
+
+    $criteria->andWhere($expr);
+
+orWhere
+-------
+
+Appends the where expression to evaluate when this Criteria is searched for
+using an OR with previous expression.
+
+.. code-block:: php
+    $expr1 = new Comparison('key', Comparison::EQ, 'value1');
+    $expr2 = new Comparison('key', Comparison::EQ, 'value2');
+
+    $criteria->where($expr1);
+    $criteria->orWhere($expr2);
+
+orderBy
+-------
+
+Sets the ordering of the result of this Criteria.
+
+.. code-block:: php
+    $criteria->orderBy(['name' => Criteria::ASC]);
+
+setFirstResult
+--------------
+
+Set the number of first result that this Criteria should return.
+
+.. code-block:: php
+    $criteria->setFirstResult(0);
+
+getFirstResult
+--------------
+
+Gets the current first result option of this Criteria.
+
+.. code-block:: php
+    $criteria->setFirstResult(10);
+
+    echo $criteria->getFirstResult(); // 10
+
+setMaxResults
+-------------
+
+Sets the max results that this Criteria should return.
+
+.. code-block:: php
+    $criteria->setMaxResults(20);
+
+getMaxResults
+-------------
+
+Gets the current max results option of this Criteria.
+
+.. code-block:: php
+    $criteria->setMaxResults(20);
+
+    echo $criteria->getMaxResults(); // 20

+ 328 - 0
api/vendor/doctrine/collections/docs/en/index.rst

@@ -0,0 +1,328 @@
+Introduction
+============
+
+Doctrine Collections is a library that contains classes for working with
+arrays of data. Here is an example using the simple
+``Doctrine\Common\Collections\ArrayCollection`` class:
+
+.. code-block:: php
+    <?php
+
+    use Doctrine\Common\Collections\ArrayCollection;
+
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $filteredCollection = $collection->filter(function($element) {
+        return $element > 1;
+    }); // [2, 3]
+
+Collection Methods
+==================
+
+Doctrine Collections provides an interface named ``Doctrine\Common\Collections\Collection``
+that resembles the nature of a regular PHP array. That is,
+it is essentially an **ordered map** that can also be used
+like a list.
+
+A Collection has an internal iterator just like a PHP array. In addition,
+a Collection can be iterated with external iterators, which is preferable.
+To use an external iterator simply use the foreach language construct to
+iterate over the collection, which calls ``getIterator()`` internally, or
+explicitly retrieve an iterator though ``getIterator()`` which can then be
+used to iterate over the collection. You can not rely on the internal iterator
+of the collection being at a certain position unless you explicitly positioned it before.
+
+The methods available on the interface are:
+
+add
+---
+
+Adds an element at the end of the collection.
+
+.. code-block:: php
+    $collection->add('test');
+
+clear
+-----
+
+Clears the collection, removing all elements.
+
+.. code-block:: php
+    $collection->clear();
+
+contains
+--------
+
+Checks whether an element is contained in the collection. This is an O(n) operation, where n is the size of the collection.
+
+.. code-block:: php
+    $collection = new Collection(['test']);
+
+    $contains = $collection->contains('test'); // true
+
+containsKey
+-----------
+
+Checks whether the collection contains an element with the specified key/index.
+
+.. code-block:: php
+    $collection = new Collection(['test' => true]);
+
+    $contains = $collection->containsKey('test'); // true
+
+current
+-------
+
+Gets the element of the collection at the current iterator position.
+
+.. code-block:: php
+    $collection = new Collection(['first', 'second', 'third']);
+
+    $current = $collection->current(); // first
+
+get
+---
+
+Gets the element at the specified key/index.
+
+.. code-block:: php
+    $collection = new Collection([
+        'key' => 'value',
+    ]);
+
+    $value = $collection->get('key'); // value
+
+getKeys
+-------
+
+Gets all keys/indices of the collection.
+
+.. code-block:: php
+    $collection = new Collection(['a', 'b', 'c']);
+
+    $keys = $collection->getKeys(); // [0, 1, 2]
+
+getValues
+---------
+
+Gets all values of the collection.
+
+.. code-block:: php
+    $collection = new Collection([
+        'key1' => 'value1',
+        'key2' => 'value2',
+        'key3' => 'value3',
+    ]);
+
+    $values = $collection->getValues(); // ['value1', 'value2', 'value3']
+
+isEmpty
+-------
+
+Checks whether the collection is empty (contains no elements).
+
+.. code-block:: php
+    $collection = new Collection(['a', 'b', 'c']);
+
+    $isEmpty = $collection->isEmpty(); // false
+
+first
+-----
+
+Sets the internal iterator to the first element in the collection and returns this element.
+
+.. code-block:: php
+    $collection = new Collection(['first', 'second', 'third']);
+
+    $first = $collection->first(); // first
+
+exists
+------
+
+Tests for the existence of an element that satisfies the given predicate.
+
+.. code-block:: php
+    $collection = new Collection(['first', 'second', 'third']);
+
+    $exists = $collection->exists(function($key, $value) {
+        return $value === 'first';
+    }); // true
+
+filter
+------
+
+Returns all the elements of this collection for which your callback function returns `true`.
+The order and keys of the elements are preserved.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $filteredCollection = $collection->filter(function($element) {
+        return $element > 1;
+    }); // [2, 3]
+
+forAll
+------
+
+Tests whether the given predicate holds for all elements of this collection.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $forAll = $collection->forAll(function($key, $value) {
+        return $value > 1;
+    }); // false
+
+indexOf
+-------
+
+Gets the index/key of a given element. The comparison of two elements is strict, that means not only the value but also the type must match. For objects this means reference equality.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $indexOf = $collection->indexOf(3); // 2
+
+key
+---
+
+Gets the key/index of the element at the current iterator position.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $collection->next();
+
+    $key = $collection->key(); // 1
+
+last
+----
+
+Sets the internal iterator to the last element in the collection and returns this element.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $last = $collection->last(); // 3
+
+map
+---
+
+Applies the given function to each element in the collection and returns a new collection with the elements returned by the function.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $mappedCollection = $collection->map(function($value) {
+        return $value + 1;
+    }); // [2, 3, 4]
+
+next
+----
+
+Moves the internal iterator position to the next element and returns this element.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $next = $collection->next(); // 2
+
+partition
+---------
+
+Partitions this collection in two collections according to a predicate. Keys are preserved in the resulting collections.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $mappedCollection = $collection->partition(function($key, $value) {
+        return $value > 1
+    }); // [[2, 3], [1]]
+
+remove
+------
+
+Removes the element at the specified index from the collection.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $collection->remove(0); // [2, 3]
+
+removeElement
+-------------
+
+Removes the specified element from the collection, if it is found.
+
+.. code-block:: php
+    $collection = new ArrayCollection([1, 2, 3]);
+
+    $collection->removeElement(3); // [1, 2]
+
+set
+---
+
+Sets an element in the collection at the specified key/index.
+
+.. code-block:: php
+    $collection = new ArrayCollection();
+
+    $collection->set('name', 'jwage');
+
+slice
+-----
+
+Extracts a slice of $length elements starting at position $offset from the Collection. If $length is null it returns all elements from $offset to the end of the Collection. Keys have to be preserved by this method. Calling this method will only return the selected slice and NOT change the elements contained in the collection slice is called on.
+
+.. code-block:: php
+    $collection = new ArrayCollection([0, 1, 2, 3, 4, 5]);
+
+    $slice = $collection->slice(1, 2); // [1, 2]
+
+toArray
+-------
+
+Gets a native PHP array representation of the collection.
+
+.. code-block:: php
+    $collection = new ArrayCollection([0, 1, 2, 3, 4, 5]);
+
+    $array = $collection->toArray(); // [0, 1, 2, 3, 4, 5]
+
+Selectable Methods
+==================
+
+Some Doctrine Collections, like ``Doctrine\Common\Collections\ArrayCollection``,
+implement an interface named ``Doctrine\Common\Collections\Selectable``
+that offers the usage of a powerful expressions API, where conditions
+can be applied to a collection to get a result with matching elements
+only.
+
+matching
+--------
+
+Selects all elements from a selectable that match the expression and
+returns a new collection containing these elements.
+
+.. code-block:: php
+    use Doctrine\Common\Collections\Criteria;
+    use Doctrine\Common\Collections\Expr\Comparison;
+
+    $collection = new ArrayCollection([
+        [
+            'name' => 'jwage',
+        ],
+        [
+            'name' => 'romanb',
+        ],
+    ]);
+
+    $expr = new Comparison('name', '=', 'jwage');
+
+    $criteria = new Criteria();
+
+    $criteria->where($expr);
+
+    $matched = $collection->matching($criteria); // ['jwage']
+
+You can read more about expressions :ref:`here <expressions>`.

+ 26 - 0
api/vendor/doctrine/collections/docs/en/lazy-collections.rst

@@ -0,0 +1,26 @@
+Lazy Collections
+================
+
+To create a lazy collection you can extend the
+``Doctrine\Common\Collections\AbstractLazyCollection`` class
+and define the ``doInitialize`` method. Here is an example where
+we lazily query the database for a collection of user records:
+
+.. code-block:: php
+    use Doctrine\DBAL\Connection;
+
+    class UsersLazyCollection extends AbstractLazyCollection
+    {
+        /** @var Connection */
+        private $connection;
+
+        public function __construct(Connection $connection)
+        {
+            $this->connection = $connection;
+        }
+
+        protected function doInitialize() : void
+        {
+            $this->collection = $this->connection->fetchAll('SELECT * FROM users');
+        }
+    }

+ 8 - 0
api/vendor/doctrine/collections/docs/en/sidebar.rst

@@ -0,0 +1,8 @@
+.. toctree::
+    :depth: 3
+
+    index
+    expressions
+    expression-builder
+    derived-collections
+    lazy-collections

+ 385 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/AbstractLazyCollection.php

@@ -0,0 +1,385 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use Closure;
+use ReturnTypeWillChange;
+use Traversable;
+
+/**
+ * Lazy collection that is backed by a concrete collection
+ *
+ * @psalm-template TKey of array-key
+ * @psalm-template T
+ * @template-implements Collection<TKey,T>
+ */
+abstract class AbstractLazyCollection implements Collection
+{
+    /**
+     * The backed collection to use
+     *
+     * @psalm-var Collection<TKey,T>
+     * @var Collection<mixed>
+     */
+    protected $collection;
+
+    /** @var bool */
+    protected $initialized = false;
+
+    /**
+     * {@inheritDoc}
+     *
+     * @return int
+     */
+    #[ReturnTypeWillChange]
+    public function count()
+    {
+        $this->initialize();
+
+        return $this->collection->count();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function add($element)
+    {
+        $this->initialize();
+
+        return $this->collection->add($element);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function clear()
+    {
+        $this->initialize();
+        $this->collection->clear();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function contains($element)
+    {
+        $this->initialize();
+
+        return $this->collection->contains($element);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isEmpty()
+    {
+        $this->initialize();
+
+        return $this->collection->isEmpty();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function remove($key)
+    {
+        $this->initialize();
+
+        return $this->collection->remove($key);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function removeElement($element)
+    {
+        $this->initialize();
+
+        return $this->collection->removeElement($element);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function containsKey($key)
+    {
+        $this->initialize();
+
+        return $this->collection->containsKey($key);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function get($key)
+    {
+        $this->initialize();
+
+        return $this->collection->get($key);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getKeys()
+    {
+        $this->initialize();
+
+        return $this->collection->getKeys();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getValues()
+    {
+        $this->initialize();
+
+        return $this->collection->getValues();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function set($key, $value)
+    {
+        $this->initialize();
+        $this->collection->set($key, $value);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function toArray()
+    {
+        $this->initialize();
+
+        return $this->collection->toArray();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function first()
+    {
+        $this->initialize();
+
+        return $this->collection->first();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function last()
+    {
+        $this->initialize();
+
+        return $this->collection->last();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function key()
+    {
+        $this->initialize();
+
+        return $this->collection->key();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function current()
+    {
+        $this->initialize();
+
+        return $this->collection->current();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function next()
+    {
+        $this->initialize();
+
+        return $this->collection->next();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function exists(Closure $p)
+    {
+        $this->initialize();
+
+        return $this->collection->exists($p);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function filter(Closure $p)
+    {
+        $this->initialize();
+
+        return $this->collection->filter($p);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function forAll(Closure $p)
+    {
+        $this->initialize();
+
+        return $this->collection->forAll($p);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function map(Closure $func)
+    {
+        $this->initialize();
+
+        return $this->collection->map($func);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function partition(Closure $p)
+    {
+        $this->initialize();
+
+        return $this->collection->partition($p);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function indexOf($element)
+    {
+        $this->initialize();
+
+        return $this->collection->indexOf($element);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function slice($offset, $length = null)
+    {
+        $this->initialize();
+
+        return $this->collection->slice($offset, $length);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @return Traversable<int|string, mixed>
+     * @psalm-return Traversable<TKey,T>
+     */
+    #[ReturnTypeWillChange]
+    public function getIterator()
+    {
+        $this->initialize();
+
+        return $this->collection->getIterator();
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @psalm-param TKey $offset
+     *
+     * @return bool
+     */
+    #[ReturnTypeWillChange]
+    public function offsetExists($offset)
+    {
+        $this->initialize();
+
+        return $this->collection->offsetExists($offset);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @param int|string $offset
+     * @psalm-param TKey $offset
+     *
+     * @return mixed
+     */
+    #[ReturnTypeWillChange]
+    public function offsetGet($offset)
+    {
+        $this->initialize();
+
+        return $this->collection->offsetGet($offset);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @param mixed $value
+     * @psalm-param TKey $offset
+     *
+     * @return void
+     */
+    #[ReturnTypeWillChange]
+    public function offsetSet($offset, $value)
+    {
+        $this->initialize();
+        $this->collection->offsetSet($offset, $value);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @psalm-param TKey $offset
+     *
+     * @return void
+     */
+    #[ReturnTypeWillChange]
+    public function offsetUnset($offset)
+    {
+        $this->initialize();
+        $this->collection->offsetUnset($offset);
+    }
+
+    /**
+     * Is the lazy collection already initialized?
+     *
+     * @return bool
+     */
+    public function isInitialized()
+    {
+        return $this->initialized;
+    }
+
+    /**
+     * Initialize the collection
+     *
+     * @return void
+     */
+    protected function initialize()
+    {
+        if ($this->initialized) {
+            return;
+        }
+
+        $this->doInitialize();
+        $this->initialized = true;
+    }
+
+    /**
+     * Do the initialization logic
+     *
+     * @return void
+     */
+    abstract protected function doInitialize();
+}

+ 463 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/ArrayCollection.php

@@ -0,0 +1,463 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use ArrayIterator;
+use Closure;
+use Doctrine\Common\Collections\Expr\ClosureExpressionVisitor;
+use ReturnTypeWillChange;
+use Traversable;
+
+use function array_filter;
+use function array_key_exists;
+use function array_keys;
+use function array_map;
+use function array_reverse;
+use function array_search;
+use function array_slice;
+use function array_values;
+use function count;
+use function current;
+use function end;
+use function in_array;
+use function key;
+use function next;
+use function reset;
+use function spl_object_hash;
+use function uasort;
+
+use const ARRAY_FILTER_USE_BOTH;
+
+/**
+ * An ArrayCollection is a Collection implementation that wraps a regular PHP array.
+ *
+ * Warning: Using (un-)serialize() on a collection is not a supported use-case
+ * and may break when we change the internals in the future. If you need to
+ * serialize a collection use {@link toArray()} and reconstruct the collection
+ * manually.
+ *
+ * @psalm-template TKey of array-key
+ * @psalm-template T
+ * @template-implements Collection<TKey,T>
+ * @template-implements Selectable<TKey,T>
+ * @psalm-consistent-constructor
+ */
+class ArrayCollection implements Collection, Selectable
+{
+    /**
+     * An array containing the entries of this collection.
+     *
+     * @psalm-var array<TKey,T>
+     * @var mixed[]
+     */
+    private $elements;
+
+    /**
+     * Initializes a new ArrayCollection.
+     *
+     * @param array $elements
+     * @psalm-param array<TKey,T> $elements
+     */
+    public function __construct(array $elements = [])
+    {
+        $this->elements = $elements;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function toArray()
+    {
+        return $this->elements;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function first()
+    {
+        return reset($this->elements);
+    }
+
+    /**
+     * Creates a new instance from the specified elements.
+     *
+     * This method is provided for derived classes to specify how a new
+     * instance should be created when constructor semantics have changed.
+     *
+     * @param array $elements Elements.
+     * @psalm-param array<K,V> $elements
+     *
+     * @return static
+     * @psalm-return static<K,V>
+     *
+     * @psalm-template K of array-key
+     * @psalm-template V
+     */
+    protected function createFrom(array $elements)
+    {
+        return new static($elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function last()
+    {
+        return end($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function key()
+    {
+        return key($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function next()
+    {
+        return next($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function current()
+    {
+        return current($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function remove($key)
+    {
+        if (! isset($this->elements[$key]) && ! array_key_exists($key, $this->elements)) {
+            return null;
+        }
+
+        $removed = $this->elements[$key];
+        unset($this->elements[$key]);
+
+        return $removed;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function removeElement($element)
+    {
+        $key = array_search($element, $this->elements, true);
+
+        if ($key === false) {
+            return false;
+        }
+
+        unset($this->elements[$key]);
+
+        return true;
+    }
+
+    /**
+     * Required by interface ArrayAccess.
+     *
+     * {@inheritDoc}
+     *
+     * @psalm-param TKey $offset
+     *
+     * @return bool
+     */
+    #[ReturnTypeWillChange]
+    public function offsetExists($offset)
+    {
+        return $this->containsKey($offset);
+    }
+
+    /**
+     * Required by interface ArrayAccess.
+     *
+     * {@inheritDoc}
+     *
+     * @psalm-param TKey $offset
+     *
+     * @return mixed
+     */
+    #[ReturnTypeWillChange]
+    public function offsetGet($offset)
+    {
+        return $this->get($offset);
+    }
+
+    /**
+     * Required by interface ArrayAccess.
+     *
+     * {@inheritDoc}
+     *
+     * @return void
+     */
+    #[ReturnTypeWillChange]
+    public function offsetSet($offset, $value)
+    {
+        if (! isset($offset)) {
+            $this->add($value);
+
+            return;
+        }
+
+        $this->set($offset, $value);
+    }
+
+    /**
+     * Required by interface ArrayAccess.
+     *
+     * {@inheritDoc}
+     *
+     * @psalm-param TKey $offset
+     *
+     * @return void
+     */
+    #[ReturnTypeWillChange]
+    public function offsetUnset($offset)
+    {
+        $this->remove($offset);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function containsKey($key)
+    {
+        return isset($this->elements[$key]) || array_key_exists($key, $this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function contains($element)
+    {
+        return in_array($element, $this->elements, true);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function exists(Closure $p)
+    {
+        foreach ($this->elements as $key => $element) {
+            if ($p($key, $element)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function indexOf($element)
+    {
+        return array_search($element, $this->elements, true);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function get($key)
+    {
+        return $this->elements[$key] ?? null;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getKeys()
+    {
+        return array_keys($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getValues()
+    {
+        return array_values($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @return int
+     */
+    #[ReturnTypeWillChange]
+    public function count()
+    {
+        return count($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function set($key, $value)
+    {
+        $this->elements[$key] = $value;
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @psalm-suppress InvalidPropertyAssignmentValue
+     *
+     * This breaks assumptions about the template type, but it would
+     * be a backwards-incompatible change to remove this method
+     */
+    public function add($element)
+    {
+        $this->elements[] = $element;
+
+        return true;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function isEmpty()
+    {
+        return empty($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @return Traversable<int|string, mixed>
+     * @psalm-return Traversable<TKey,T>
+     */
+    #[ReturnTypeWillChange]
+    public function getIterator()
+    {
+        return new ArrayIterator($this->elements);
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @psalm-param Closure(T=):U $func
+     *
+     * @return static
+     * @psalm-return static<TKey, U>
+     *
+     * @psalm-template U
+     */
+    public function map(Closure $func)
+    {
+        return $this->createFrom(array_map($func, $this->elements));
+    }
+
+    /**
+     * {@inheritDoc}
+     *
+     * @return static
+     * @psalm-return static<TKey,T>
+     */
+    public function filter(Closure $p)
+    {
+        return $this->createFrom(array_filter($this->elements, $p, ARRAY_FILTER_USE_BOTH));
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function forAll(Closure $p)
+    {
+        foreach ($this->elements as $key => $element) {
+            if (! $p($key, $element)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function partition(Closure $p)
+    {
+        $matches = $noMatches = [];
+
+        foreach ($this->elements as $key => $element) {
+            if ($p($key, $element)) {
+                $matches[$key] = $element;
+            } else {
+                $noMatches[$key] = $element;
+            }
+        }
+
+        return [$this->createFrom($matches), $this->createFrom($noMatches)];
+    }
+
+    /**
+     * Returns a string representation of this object.
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return self::class . '@' . spl_object_hash($this);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function clear()
+    {
+        $this->elements = [];
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function slice($offset, $length = null)
+    {
+        return array_slice($this->elements, $offset, $length, true);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function matching(Criteria $criteria)
+    {
+        $expr     = $criteria->getWhereExpression();
+        $filtered = $this->elements;
+
+        if ($expr) {
+            $visitor  = new ClosureExpressionVisitor();
+            $filter   = $visitor->dispatch($expr);
+            $filtered = array_filter($filtered, $filter);
+        }
+
+        $orderings = $criteria->getOrderings();
+
+        if ($orderings) {
+            $next = null;
+            foreach (array_reverse($orderings) as $field => $ordering) {
+                $next = ClosureExpressionVisitor::sortByField($field, $ordering === Criteria::DESC ? -1 : 1, $next);
+            }
+
+            uasort($filtered, $next);
+        }
+
+        $offset = $criteria->getFirstResult();
+        $length = $criteria->getMaxResults();
+
+        if ($offset || $length) {
+            $filtered = array_slice($filtered, (int) $offset, $length);
+        }
+
+        return $this->createFrom($filtered);
+    }
+}

+ 276 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Collection.php

@@ -0,0 +1,276 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use ArrayAccess;
+use Closure;
+use Countable;
+use IteratorAggregate;
+
+/**
+ * The missing (SPL) Collection/Array/OrderedMap interface.
+ *
+ * A Collection resembles the nature of a regular PHP array. That is,
+ * it is essentially an <b>ordered map</b> that can also be used
+ * like a list.
+ *
+ * A Collection has an internal iterator just like a PHP array. In addition,
+ * a Collection can be iterated with external iterators, which is preferable.
+ * To use an external iterator simply use the foreach language construct to
+ * iterate over the collection (which calls {@link getIterator()} internally) or
+ * explicitly retrieve an iterator though {@link getIterator()} which can then be
+ * used to iterate over the collection.
+ * You can not rely on the internal iterator of the collection being at a certain
+ * position unless you explicitly positioned it before. Prefer iteration with
+ * external iterators.
+ *
+ * @psalm-template TKey of array-key
+ * @psalm-template T
+ * @template-extends IteratorAggregate<TKey, T>
+ * @template-extends ArrayAccess<TKey|null, T>
+ */
+interface Collection extends Countable, IteratorAggregate, ArrayAccess
+{
+    /**
+     * Adds an element at the end of the collection.
+     *
+     * @param mixed $element The element to add.
+     * @psalm-param T $element
+     *
+     * @return true Always TRUE.
+     */
+    public function add($element);
+
+    /**
+     * Clears the collection, removing all elements.
+     *
+     * @return void
+     */
+    public function clear();
+
+    /**
+     * Checks whether an element is contained in the collection.
+     * This is an O(n) operation, where n is the size of the collection.
+     *
+     * @param mixed $element The element to search for.
+     * @psalm-param T $element
+     *
+     * @return bool TRUE if the collection contains the element, FALSE otherwise.
+     */
+    public function contains($element);
+
+    /**
+     * Checks whether the collection is empty (contains no elements).
+     *
+     * @return bool TRUE if the collection is empty, FALSE otherwise.
+     */
+    public function isEmpty();
+
+    /**
+     * Removes the element at the specified index from the collection.
+     *
+     * @param string|int $key The key/index of the element to remove.
+     * @psalm-param TKey $key
+     *
+     * @return mixed The removed element or NULL, if the collection did not contain the element.
+     * @psalm-return T|null
+     */
+    public function remove($key);
+
+    /**
+     * Removes the specified element from the collection, if it is found.
+     *
+     * @param mixed $element The element to remove.
+     * @psalm-param T $element
+     *
+     * @return bool TRUE if this collection contained the specified element, FALSE otherwise.
+     */
+    public function removeElement($element);
+
+    /**
+     * Checks whether the collection contains an element with the specified key/index.
+     *
+     * @param string|int $key The key/index to check for.
+     * @psalm-param TKey $key
+     *
+     * @return bool TRUE if the collection contains an element with the specified key/index,
+     *              FALSE otherwise.
+     */
+    public function containsKey($key);
+
+    /**
+     * Gets the element at the specified key/index.
+     *
+     * @param string|int $key The key/index of the element to retrieve.
+     * @psalm-param TKey $key
+     *
+     * @return mixed
+     * @psalm-return T|null
+     */
+    public function get($key);
+
+    /**
+     * Gets all keys/indices of the collection.
+     *
+     * @return int[]|string[] The keys/indices of the collection, in the order of the corresponding
+     *               elements in the collection.
+     * @psalm-return TKey[]
+     */
+    public function getKeys();
+
+    /**
+     * Gets all values of the collection.
+     *
+     * @return mixed[] The values of all elements in the collection, in the
+     *                 order they appear in the collection.
+     * @psalm-return T[]
+     */
+    public function getValues();
+
+    /**
+     * Sets an element in the collection at the specified key/index.
+     *
+     * @param string|int $key   The key/index of the element to set.
+     * @param mixed      $value The element to set.
+     * @psalm-param TKey $key
+     * @psalm-param T $value
+     *
+     * @return void
+     */
+    public function set($key, $value);
+
+    /**
+     * Gets a native PHP array representation of the collection.
+     *
+     * @return mixed[]
+     * @psalm-return array<TKey,T>
+     */
+    public function toArray();
+
+    /**
+     * Sets the internal iterator to the first element in the collection and returns this element.
+     *
+     * @return mixed
+     * @psalm-return T|false
+     */
+    public function first();
+
+    /**
+     * Sets the internal iterator to the last element in the collection and returns this element.
+     *
+     * @return mixed
+     * @psalm-return T|false
+     */
+    public function last();
+
+    /**
+     * Gets the key/index of the element at the current iterator position.
+     *
+     * @return int|string|null
+     * @psalm-return TKey|null
+     */
+    public function key();
+
+    /**
+     * Gets the element of the collection at the current iterator position.
+     *
+     * @return mixed
+     * @psalm-return T|false
+     */
+    public function current();
+
+    /**
+     * Moves the internal iterator position to the next element and returns this element.
+     *
+     * @return mixed
+     * @psalm-return T|false
+     */
+    public function next();
+
+    /**
+     * Tests for the existence of an element that satisfies the given predicate.
+     *
+     * @param Closure $p The predicate.
+     * @psalm-param Closure(TKey=, T=):bool $p
+     *
+     * @return bool TRUE if the predicate is TRUE for at least one element, FALSE otherwise.
+     */
+    public function exists(Closure $p);
+
+    /**
+     * Returns all the elements of this collection that satisfy the predicate p.
+     * The order of the elements is preserved.
+     *
+     * @param Closure $p The predicate used for filtering.
+     * @psalm-param Closure(T=):bool $p
+     *
+     * @return Collection<mixed> A collection with the results of the filter operation.
+     * @psalm-return Collection<TKey, T>
+     */
+    public function filter(Closure $p);
+
+    /**
+     * Tests whether the given predicate p holds for all elements of this collection.
+     *
+     * @param Closure $p The predicate.
+     * @psalm-param Closure(TKey=, T=):bool $p
+     *
+     * @return bool TRUE, if the predicate yields TRUE for all elements, FALSE otherwise.
+     */
+    public function forAll(Closure $p);
+
+    /**
+     * Applies the given function to each element in the collection and returns
+     * a new collection with the elements returned by the function.
+     *
+     * @psalm-param Closure(T=):U $func
+     *
+     * @return Collection<mixed>
+     * @psalm-return Collection<TKey, U>
+     *
+     * @psalm-template U
+     */
+    public function map(Closure $func);
+
+    /**
+     * Partitions this collection in two collections according to a predicate.
+     * Keys are preserved in the resulting collections.
+     *
+     * @param Closure $p The predicate on which to partition.
+     * @psalm-param Closure(TKey=, T=):bool $p
+     *
+     * @return Collection<mixed> An array with two elements. The first element contains the collection
+     *                      of elements where the predicate returned TRUE, the second element
+     *                      contains the collection of elements where the predicate returned FALSE.
+     * @psalm-return array{0: Collection<TKey, T>, 1: Collection<TKey, T>}
+     */
+    public function partition(Closure $p);
+
+    /**
+     * Gets the index/key of a given element. The comparison of two elements is strict,
+     * that means not only the value but also the type must match.
+     * For objects this means reference equality.
+     *
+     * @param mixed $element The element to search for.
+     * @psalm-param T $element
+     *
+     * @return int|string|bool The key/index of the element or FALSE if the element was not found.
+     * @psalm-return TKey|false
+     */
+    public function indexOf($element);
+
+    /**
+     * Extracts a slice of $length elements starting at position $offset from the Collection.
+     *
+     * If $length is null it returns all elements from $offset to the end of the Collection.
+     * Keys have to be preserved by this method. Calling this method will only return the
+     * selected slice and NOT change the elements contained in the collection slice is called on.
+     *
+     * @param int      $offset The offset to start from.
+     * @param int|null $length The maximum number of elements to return, or null for no limit.
+     *
+     * @return mixed[]
+     * @psalm-return array<TKey,T>
+     */
+    public function slice($offset, $length = null);
+}

+ 225 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Criteria.php

@@ -0,0 +1,225 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use Doctrine\Common\Collections\Expr\CompositeExpression;
+use Doctrine\Common\Collections\Expr\Expression;
+
+use function array_map;
+use function strtoupper;
+
+/**
+ * Criteria for filtering Selectable collections.
+ *
+ * @psalm-consistent-constructor
+ */
+class Criteria
+{
+    public const ASC = 'ASC';
+
+    public const DESC = 'DESC';
+
+    /** @var ExpressionBuilder|null */
+    private static $expressionBuilder;
+
+    /** @var Expression|null */
+    private $expression;
+
+    /** @var string[] */
+    private $orderings = [];
+
+    /** @var int|null */
+    private $firstResult;
+
+    /** @var int|null */
+    private $maxResults;
+
+    /**
+     * Creates an instance of the class.
+     *
+     * @return Criteria
+     */
+    public static function create()
+    {
+        return new static();
+    }
+
+    /**
+     * Returns the expression builder.
+     *
+     * @return ExpressionBuilder
+     */
+    public static function expr()
+    {
+        if (self::$expressionBuilder === null) {
+            self::$expressionBuilder = new ExpressionBuilder();
+        }
+
+        return self::$expressionBuilder;
+    }
+
+    /**
+     * Construct a new Criteria.
+     *
+     * @param string[]|null $orderings
+     * @param int|null      $firstResult
+     * @param int|null      $maxResults
+     */
+    public function __construct(?Expression $expression = null, ?array $orderings = null, $firstResult = null, $maxResults = null)
+    {
+        $this->expression = $expression;
+
+        $this->setFirstResult($firstResult);
+        $this->setMaxResults($maxResults);
+
+        if ($orderings === null) {
+            return;
+        }
+
+        $this->orderBy($orderings);
+    }
+
+    /**
+     * Sets the where expression to evaluate when this Criteria is searched for.
+     *
+     * @return Criteria
+     */
+    public function where(Expression $expression)
+    {
+        $this->expression = $expression;
+
+        return $this;
+    }
+
+    /**
+     * Appends the where expression to evaluate when this Criteria is searched for
+     * using an AND with previous expression.
+     *
+     * @return Criteria
+     */
+    public function andWhere(Expression $expression)
+    {
+        if ($this->expression === null) {
+            return $this->where($expression);
+        }
+
+        $this->expression = new CompositeExpression(
+            CompositeExpression::TYPE_AND,
+            [$this->expression, $expression]
+        );
+
+        return $this;
+    }
+
+    /**
+     * Appends the where expression to evaluate when this Criteria is searched for
+     * using an OR with previous expression.
+     *
+     * @return Criteria
+     */
+    public function orWhere(Expression $expression)
+    {
+        if ($this->expression === null) {
+            return $this->where($expression);
+        }
+
+        $this->expression = new CompositeExpression(
+            CompositeExpression::TYPE_OR,
+            [$this->expression, $expression]
+        );
+
+        return $this;
+    }
+
+    /**
+     * Gets the expression attached to this Criteria.
+     *
+     * @return Expression|null
+     */
+    public function getWhereExpression()
+    {
+        return $this->expression;
+    }
+
+    /**
+     * Gets the current orderings of this Criteria.
+     *
+     * @return string[]
+     */
+    public function getOrderings()
+    {
+        return $this->orderings;
+    }
+
+    /**
+     * Sets the ordering of the result of this Criteria.
+     *
+     * Keys are field and values are the order, being either ASC or DESC.
+     *
+     * @see Criteria::ASC
+     * @see Criteria::DESC
+     *
+     * @param string[] $orderings
+     *
+     * @return Criteria
+     */
+    public function orderBy(array $orderings)
+    {
+        $this->orderings = array_map(
+            static function (string $ordering): string {
+                return strtoupper($ordering) === Criteria::ASC ? Criteria::ASC : Criteria::DESC;
+            },
+            $orderings
+        );
+
+        return $this;
+    }
+
+    /**
+     * Gets the current first result option of this Criteria.
+     *
+     * @return int|null
+     */
+    public function getFirstResult()
+    {
+        return $this->firstResult;
+    }
+
+    /**
+     * Set the number of first result that this Criteria should return.
+     *
+     * @param int|null $firstResult The value to set.
+     *
+     * @return Criteria
+     */
+    public function setFirstResult($firstResult)
+    {
+        $this->firstResult = $firstResult;
+
+        return $this;
+    }
+
+    /**
+     * Gets maxResults.
+     *
+     * @return int|null
+     */
+    public function getMaxResults()
+    {
+        return $this->maxResults;
+    }
+
+    /**
+     * Sets maxResults.
+     *
+     * @param int|null $maxResults The value to set.
+     *
+     * @return Criteria
+     */
+    public function setMaxResults($maxResults)
+    {
+        $this->maxResults = $maxResults;
+
+        return $this;
+    }
+}

+ 265 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/ClosureExpressionVisitor.php

@@ -0,0 +1,265 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+use ArrayAccess;
+use Closure;
+use RuntimeException;
+
+use function in_array;
+use function is_array;
+use function is_scalar;
+use function iterator_to_array;
+use function method_exists;
+use function preg_match;
+use function preg_replace_callback;
+use function strlen;
+use function strpos;
+use function strtoupper;
+use function substr;
+
+/**
+ * Walks an expression graph and turns it into a PHP closure.
+ *
+ * This closure can be used with {@Collection#filter()} and is used internally
+ * by {@ArrayCollection#select()}.
+ */
+class ClosureExpressionVisitor extends ExpressionVisitor
+{
+    /**
+     * Accesses the field of a given object. This field has to be public
+     * directly or indirectly (through an accessor get*, is*, or a magic
+     * method, __get, __call).
+     *
+     * @param object|mixed[] $object
+     * @param string         $field
+     *
+     * @return mixed
+     */
+    public static function getObjectFieldValue($object, $field)
+    {
+        if (is_array($object)) {
+            return $object[$field];
+        }
+
+        $accessors = ['get', 'is'];
+
+        foreach ($accessors as $accessor) {
+            $accessor .= $field;
+
+            if (method_exists($object, $accessor)) {
+                return $object->$accessor();
+            }
+        }
+
+        if (preg_match('/^is[A-Z]+/', $field) === 1 && method_exists($object, $field)) {
+            return $object->$field();
+        }
+
+        // __call should be triggered for get.
+        $accessor = $accessors[0] . $field;
+
+        if (method_exists($object, '__call')) {
+            return $object->$accessor();
+        }
+
+        if ($object instanceof ArrayAccess) {
+            return $object[$field];
+        }
+
+        if (isset($object->$field)) {
+            return $object->$field;
+        }
+
+        // camelcase field name to support different variable naming conventions
+        $ccField = preg_replace_callback('/_(.?)/', static function ($matches) {
+            return strtoupper($matches[1]);
+        }, $field);
+
+        foreach ($accessors as $accessor) {
+            $accessor .= $ccField;
+
+            if (method_exists($object, $accessor)) {
+                return $object->$accessor();
+            }
+        }
+
+        return $object->$field;
+    }
+
+    /**
+     * Helper for sorting arrays of objects based on multiple fields + orientations.
+     *
+     * @param string $name
+     * @param int    $orientation
+     *
+     * @return Closure
+     */
+    public static function sortByField($name, $orientation = 1, ?Closure $next = null)
+    {
+        if (! $next) {
+            $next = static function (): int {
+                return 0;
+            };
+        }
+
+        return static function ($a, $b) use ($name, $next, $orientation): int {
+            $aValue = ClosureExpressionVisitor::getObjectFieldValue($a, $name);
+
+            $bValue = ClosureExpressionVisitor::getObjectFieldValue($b, $name);
+
+            if ($aValue === $bValue) {
+                return $next($a, $b);
+            }
+
+            return ($aValue > $bValue ? 1 : -1) * $orientation;
+        };
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function walkComparison(Comparison $comparison)
+    {
+        $field = $comparison->getField();
+        $value = $comparison->getValue()->getValue(); // shortcut for walkValue()
+
+        switch ($comparison->getOperator()) {
+            case Comparison::EQ:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) === $value;
+                };
+
+            case Comparison::NEQ:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) !== $value;
+                };
+
+            case Comparison::LT:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) < $value;
+                };
+
+            case Comparison::LTE:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) <= $value;
+                };
+
+            case Comparison::GT:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) > $value;
+                };
+
+            case Comparison::GTE:
+                return static function ($object) use ($field, $value): bool {
+                    return ClosureExpressionVisitor::getObjectFieldValue($object, $field) >= $value;
+                };
+
+            case Comparison::IN:
+                return static function ($object) use ($field, $value): bool {
+                    $fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
+
+                    return in_array($fieldValue, $value, is_scalar($fieldValue));
+                };
+
+            case Comparison::NIN:
+                return static function ($object) use ($field, $value): bool {
+                    $fieldValue = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
+
+                    return ! in_array($fieldValue, $value, is_scalar($fieldValue));
+                };
+
+            case Comparison::CONTAINS:
+                return static function ($object) use ($field, $value) {
+                    return strpos(ClosureExpressionVisitor::getObjectFieldValue($object, $field), $value) !== false;
+                };
+
+            case Comparison::MEMBER_OF:
+                return static function ($object) use ($field, $value): bool {
+                    $fieldValues = ClosureExpressionVisitor::getObjectFieldValue($object, $field);
+
+                    if (! is_array($fieldValues)) {
+                        $fieldValues = iterator_to_array($fieldValues);
+                    }
+
+                    return in_array($value, $fieldValues, true);
+                };
+
+            case Comparison::STARTS_WITH:
+                return static function ($object) use ($field, $value): bool {
+                    return strpos(ClosureExpressionVisitor::getObjectFieldValue($object, $field), $value) === 0;
+                };
+
+            case Comparison::ENDS_WITH:
+                return static function ($object) use ($field, $value): bool {
+                    return $value === substr(ClosureExpressionVisitor::getObjectFieldValue($object, $field), -strlen($value));
+                };
+
+            default:
+                throw new RuntimeException('Unknown comparison operator: ' . $comparison->getOperator());
+        }
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function walkValue(Value $value)
+    {
+        return $value->getValue();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function walkCompositeExpression(CompositeExpression $expr)
+    {
+        $expressionList = [];
+
+        foreach ($expr->getExpressionList() as $child) {
+            $expressionList[] = $this->dispatch($child);
+        }
+
+        switch ($expr->getType()) {
+            case CompositeExpression::TYPE_AND:
+                return $this->andExpressions($expressionList);
+
+            case CompositeExpression::TYPE_OR:
+                return $this->orExpressions($expressionList);
+
+            default:
+                throw new RuntimeException('Unknown composite ' . $expr->getType());
+        }
+    }
+
+    /**
+     * @param callable[] $expressions
+     */
+    private function andExpressions(array $expressions): callable
+    {
+        return static function ($object) use ($expressions): bool {
+            foreach ($expressions as $expression) {
+                if (! $expression($object)) {
+                    return false;
+                }
+            }
+
+            return true;
+        };
+    }
+
+    /**
+     * @param callable[] $expressions
+     */
+    private function orExpressions(array $expressions): callable
+    {
+        return static function ($object) use ($expressions): bool {
+            foreach ($expressions as $expression) {
+                if ($expression($object)) {
+                    return true;
+                }
+            }
+
+            return false;
+        };
+    }
+}

+ 80 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Comparison.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+/**
+ * Comparison of a field with a value by the given operator.
+ */
+class Comparison implements Expression
+{
+    public const EQ          = '=';
+    public const NEQ         = '<>';
+    public const LT          = '<';
+    public const LTE         = '<=';
+    public const GT          = '>';
+    public const GTE         = '>=';
+    public const IS          = '='; // no difference with EQ
+    public const IN          = 'IN';
+    public const NIN         = 'NIN';
+    public const CONTAINS    = 'CONTAINS';
+    public const MEMBER_OF   = 'MEMBER_OF';
+    public const STARTS_WITH = 'STARTS_WITH';
+    public const ENDS_WITH   = 'ENDS_WITH';
+
+    /** @var string */
+    private $field;
+
+    /** @var string */
+    private $op;
+
+    /** @var Value */
+    private $value;
+
+    /**
+     * @param string $field
+     * @param string $operator
+     * @param mixed  $value
+     */
+    public function __construct($field, $operator, $value)
+    {
+        if (! ($value instanceof Value)) {
+            $value = new Value($value);
+        }
+
+        $this->field = $field;
+        $this->op    = $operator;
+        $this->value = $value;
+    }
+
+    /**
+     * @return string
+     */
+    public function getField()
+    {
+        return $this->field;
+    }
+
+    /**
+     * @return Value
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * @return string
+     */
+    public function getOperator()
+    {
+        return $this->op;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function visit(ExpressionVisitor $visitor)
+    {
+        return $visitor->walkComparison($this);
+    }
+}

+ 69 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/CompositeExpression.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+use RuntimeException;
+
+/**
+ * Expression of Expressions combined by AND or OR operation.
+ */
+class CompositeExpression implements Expression
+{
+    public const TYPE_AND = 'AND';
+    public const TYPE_OR  = 'OR';
+
+    /** @var string */
+    private $type;
+
+    /** @var Expression[] */
+    private $expressions = [];
+
+    /**
+     * @param string  $type
+     * @param mixed[] $expressions
+     *
+     * @throws RuntimeException
+     */
+    public function __construct($type, array $expressions)
+    {
+        $this->type = $type;
+
+        foreach ($expressions as $expr) {
+            if ($expr instanceof Value) {
+                throw new RuntimeException('Values are not supported expressions as children of and/or expressions.');
+            }
+
+            if (! ($expr instanceof Expression)) {
+                throw new RuntimeException('No expression given to CompositeExpression.');
+            }
+
+            $this->expressions[] = $expr;
+        }
+    }
+
+    /**
+     * Returns the list of expressions nested in this composite.
+     *
+     * @return Expression[]
+     */
+    public function getExpressionList()
+    {
+        return $this->expressions;
+    }
+
+    /**
+     * @return string
+     */
+    public function getType()
+    {
+        return $this->type;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function visit(ExpressionVisitor $visitor)
+    {
+        return $visitor->walkCompositeExpression($this);
+    }
+}

+ 14 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Expression.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+/**
+ * Expression for the {@link Selectable} interface.
+ */
+interface Expression
+{
+    /**
+     * @return mixed
+     */
+    public function visit(ExpressionVisitor $visitor);
+}

+ 59 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/ExpressionVisitor.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+use RuntimeException;
+
+use function get_class;
+
+/**
+ * An Expression visitor walks a graph of expressions and turns them into a
+ * query for the underlying implementation.
+ */
+abstract class ExpressionVisitor
+{
+    /**
+     * Converts a comparison expression into the target query language output.
+     *
+     * @return mixed
+     */
+    abstract public function walkComparison(Comparison $comparison);
+
+    /**
+     * Converts a value expression into the target query language part.
+     *
+     * @return mixed
+     */
+    abstract public function walkValue(Value $value);
+
+    /**
+     * Converts a composite expression into the target query language output.
+     *
+     * @return mixed
+     */
+    abstract public function walkCompositeExpression(CompositeExpression $expr);
+
+    /**
+     * Dispatches walking an expression to the appropriate handler.
+     *
+     * @return mixed
+     *
+     * @throws RuntimeException
+     */
+    public function dispatch(Expression $expr)
+    {
+        switch (true) {
+            case $expr instanceof Comparison:
+                return $this->walkComparison($expr);
+
+            case $expr instanceof Value:
+                return $this->walkValue($expr);
+
+            case $expr instanceof CompositeExpression:
+                return $this->walkCompositeExpression($expr);
+
+            default:
+                throw new RuntimeException('Unknown Expression ' . get_class($expr));
+        }
+    }
+}

+ 33 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Expr/Value.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace Doctrine\Common\Collections\Expr;
+
+class Value implements Expression
+{
+    /** @var mixed */
+    private $value;
+
+    /**
+     * @param mixed $value
+     */
+    public function __construct($value)
+    {
+        $this->value = $value;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function visit(ExpressionVisitor $visitor)
+    {
+        return $visitor->walkValue($this);
+    }
+}

+ 181 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/ExpressionBuilder.php

@@ -0,0 +1,181 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+use Doctrine\Common\Collections\Expr\Comparison;
+use Doctrine\Common\Collections\Expr\CompositeExpression;
+use Doctrine\Common\Collections\Expr\Value;
+
+use function func_get_args;
+
+/**
+ * Builder for Expressions in the {@link Selectable} interface.
+ *
+ * Important Notice for interoperable code: You have to use scalar
+ * values only for comparisons, otherwise the behavior of the comparison
+ * may be different between implementations (Array vs ORM vs ODM).
+ */
+class ExpressionBuilder
+{
+    /**
+     * @param mixed ...$x
+     *
+     * @return CompositeExpression
+     */
+    public function andX($x = null)
+    {
+        return new CompositeExpression(CompositeExpression::TYPE_AND, func_get_args());
+    }
+
+    /**
+     * @param mixed ...$x
+     *
+     * @return CompositeExpression
+     */
+    public function orX($x = null)
+    {
+        return new CompositeExpression(CompositeExpression::TYPE_OR, func_get_args());
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function eq($field, $value)
+    {
+        return new Comparison($field, Comparison::EQ, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function gt($field, $value)
+    {
+        return new Comparison($field, Comparison::GT, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function lt($field, $value)
+    {
+        return new Comparison($field, Comparison::LT, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function gte($field, $value)
+    {
+        return new Comparison($field, Comparison::GTE, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function lte($field, $value)
+    {
+        return new Comparison($field, Comparison::LTE, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function neq($field, $value)
+    {
+        return new Comparison($field, Comparison::NEQ, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     *
+     * @return Comparison
+     */
+    public function isNull($field)
+    {
+        return new Comparison($field, Comparison::EQ, new Value(null));
+    }
+
+    /**
+     * @param string  $field
+     * @param mixed[] $values
+     *
+     * @return Comparison
+     */
+    public function in($field, array $values)
+    {
+        return new Comparison($field, Comparison::IN, new Value($values));
+    }
+
+    /**
+     * @param string  $field
+     * @param mixed[] $values
+     *
+     * @return Comparison
+     */
+    public function notIn($field, array $values)
+    {
+        return new Comparison($field, Comparison::NIN, new Value($values));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function contains($field, $value)
+    {
+        return new Comparison($field, Comparison::CONTAINS, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function memberOf($field, $value)
+    {
+        return new Comparison($field, Comparison::MEMBER_OF, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function startsWith($field, $value)
+    {
+        return new Comparison($field, Comparison::STARTS_WITH, new Value($value));
+    }
+
+    /**
+     * @param string $field
+     * @param mixed  $value
+     *
+     * @return Comparison
+     */
+    public function endsWith($field, $value)
+    {
+        return new Comparison($field, Comparison::ENDS_WITH, new Value($value));
+    }
+}

+ 30 - 0
api/vendor/doctrine/collections/lib/Doctrine/Common/Collections/Selectable.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Doctrine\Common\Collections;
+
+/**
+ * Interface for collections that allow efficient filtering with an expression API.
+ *
+ * Goal of this interface is a backend independent method to fetch elements
+ * from a collections. {@link Expression} is crafted in a way that you can
+ * implement queries from both in-memory and database-backed collections.
+ *
+ * For database backed collections this allows very efficient access by
+ * utilizing the query APIs, for example SQL in the ORM. Applications using
+ * this API can implement efficient database access without having to ask the
+ * EntityManager or Repositories.
+ *
+ * @psalm-template TKey as array-key
+ * @psalm-template T
+ */
+interface Selectable
+{
+    /**
+     * Selects all elements from a selectable that match the expression and
+     * returns a new collection containing these elements.
+     *
+     * @return Collection<mixed>
+     * @psalm-return Collection<TKey,T>
+     */
+    public function matching(Criteria $criteria);
+}

+ 17 - 0
api/vendor/doctrine/collections/phpstan.neon.dist

@@ -0,0 +1,17 @@
+parameters:
+    level: 3
+    paths:
+        - lib
+    ignoreErrors:
+        # Making classes final as suggested would be a BC-break
+        -
+            message: '~Unsafe usage of new static\(\)\.~'
+            paths:
+                - 'lib/Doctrine/Common/Collections/ArrayCollection.php'
+                - 'lib/Doctrine/Common/Collections/Criteria.php'
+        -
+            message: '~Array \(array\<TKey of \(int\|string\), T\>\) does not accept key int\.~'
+            path: 'lib/Doctrine/Common/Collections/ArrayCollection.php'
+
+        # This class is new in PHP 8.1 and PHPStan does not know it yet.
+        - '/Attribute class ReturnTypeWillChange does not exist./'

+ 65 - 0
api/vendor/doctrine/collections/psalm.xml.dist

@@ -0,0 +1,65 @@
+<?xml version="1.0"?>
+<psalm
+    totallyTyped="false"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="https://getpsalm.org/schema/config"
+    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+>
+    <projectFiles>
+        <directory name="lib" />
+        <ignoreFiles>
+            <directory name="vendor" />
+            <directory name="lib/Doctrine/Common/Collections/Expr"/>
+        </ignoreFiles>
+    </projectFiles>
+
+    <issueHandlers>
+        <LessSpecificReturnType errorLevel="info" />
+
+        <!-- level 3 issues - slightly lazy code writing, but provably low false-negatives -->
+
+        <DeprecatedMethod errorLevel="info" />
+        <DeprecatedProperty errorLevel="info" />
+        <DeprecatedClass errorLevel="info" />
+        <DeprecatedConstant errorLevel="info" />
+        <DeprecatedInterface errorLevel="info" />
+        <DeprecatedTrait errorLevel="info" />
+
+        <InternalMethod errorLevel="info" />
+        <InternalProperty errorLevel="info" />
+        <InternalClass errorLevel="info" />
+
+        <MissingClosureReturnType errorLevel="info" />
+        <MissingReturnType errorLevel="info" />
+        <MissingPropertyType errorLevel="info" />
+        <InvalidDocblock errorLevel="info" />
+
+        <PropertyNotSetInConstructor errorLevel="info" />
+        <MissingConstructor errorLevel="info" />
+        <MissingClosureParamType errorLevel="info" />
+        <MissingParamType errorLevel="info" />
+
+        <RedundantCondition errorLevel="info" />
+
+        <DocblockTypeContradiction errorLevel="info" />
+        <RedundantConditionGivenDocblockType errorLevel="info" />
+
+        <UnresolvableInclude errorLevel="info" />
+
+        <RawObjectIteration errorLevel="info" />
+
+        <InvalidStringClass errorLevel="info" />
+        <UnsafeGenericInstantiation>
+            <errorLevel type="suppress">
+                <file name="lib/Doctrine/Common/Collections/ArrayCollection.php"/>
+            </errorLevel>
+        </UnsafeGenericInstantiation>
+
+        <UndefinedAttributeClass>
+            <errorLevel type="suppress">
+                <!-- This class is new in PHP 8.1 and Psalm does not know it yet. -->
+                <referencedClass name="ReturnTypeWillChange"/>
+            </errorLevel>
+        </UndefinedAttributeClass>
+    </issueHandlers>
+</psalm>

+ 3 - 0
api/vendor/simshaun/recurr/.gitattributes

@@ -0,0 +1,3 @@
+/.github export-ignore
+/tests export-ignore
+/phpunit.xml.dist export-ignore

+ 4 - 0
api/vendor/simshaun/recurr/.gitignore

@@ -0,0 +1,4 @@
+/vendor/
+/.idea/
+/composer.lock
+/.phpunit.result.cache

+ 50 - 0
api/vendor/simshaun/recurr/LICENSE

@@ -0,0 +1,50 @@
+Copyright (c) 2015 Shaun Simmons
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+----------------------------------------------
+
+Recurr is heavily based on rrule.js:
+
+Copyright 2010, Jakub Roztocil <jakub@roztocil.name> and Lars Schöning
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+
+    2. 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.
+
+    3. Neither the name of The author 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 AUTHOR AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE AUTHOR AND CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 164 - 0
api/vendor/simshaun/recurr/README.md

@@ -0,0 +1,164 @@
+# Recurr 
+
+[![tests](https://github.com/simshaun/recurr/workflows/tests/badge.svg)](https://github.com/simshaun/recurr/actions)
+[![Latest Stable Version](https://poser.pugx.org/simshaun/recurr/v/stable.svg)](https://packagist.org/packages/simshaun/recurr) 
+[![Total Downloads](https://poser.pugx.org/simshaun/recurr/downloads.svg)](https://packagist.org/packages/simshaun/recurr) 
+[![Latest Unstable Version](https://poser.pugx.org/simshaun/recurr/v/unstable.svg)](https://packagist.org/packages/simshaun/recurr) 
+[![License](https://poser.pugx.org/simshaun/recurr/license.svg)](https://packagist.org/packages/simshaun/recurr)
+
+Recurr is a PHP library for working with recurrence rules ([RRULE](https://tools.ietf.org/html/rfc5545)) and converting them in to DateTime objects.
+
+Recurr was developed as a precursor for a calendar with recurring events, and is heavily inspired by [rrule.js](https://github.com/jkbr/rrule).
+
+Installing Recurr
+------------
+
+The recommended way to install Recurr is through [Composer](http://getcomposer.org).
+
+```bash
+composer require simshaun/recurr
+```
+
+Using Recurr 
+-----------
+
+### Creating RRULE rule objects ###
+
+You can create a new Rule object by passing the ([RRULE](https://tools.ietf.org/html/rfc5545)) string or an array with the rule parts, the start date, end date (optional) and timezone.
+
+```php
+$timezone    = 'America/New_York';
+$startDate   = new \DateTime('2013-06-12 20:00:00', new \DateTimeZone($timezone));
+$endDate     = new \DateTime('2013-06-14 20:00:00', new \DateTimeZone($timezone)); // Optional
+$rule        = new \Recurr\Rule('FREQ=MONTHLY;COUNT=5', $startDate, $endDate, $timezone);
+```
+
+You can also use chained methods to build your rule programmatically and get the resulting RRULE.
+
+```php
+$rule = (new \Recurr\Rule)
+    ->setStartDate($startDate)
+    ->setTimezone($timezone)
+    ->setFreq('DAILY')
+    ->setByDay(['MO', 'TU'])
+    ->setUntil(new \DateTime('2017-12-31'))
+;
+
+echo $rule->getString(); //FREQ=DAILY;UNTIL=20171231T000000;BYDAY=MO,TU
+```
+
+### RRULE to DateTime objects ###
+
+```php
+$transformer = new \Recurr\Transformer\ArrayTransformer();
+
+print_r($transformer->transform($rule));
+```
+
+1. `$transformer->transform(...)` returns a `RecurrenceCollection` of `Recurrence` objects.
+2. Each `Recurrence` has `getStart()` and `getEnd()` methods that return a `\DateTime` object.
+3. If the transformed `Rule` lacks an end date, `getEnd()` will return a `\DateTime` object equal to that of `getStart()`.
+
+> Note: The transformer has a "virtual" limit (default 732) on the number of objects it generates.
+> This prevents the script from crashing on an infinitely recurring rule.
+> You can change the virtual limit with an `ArrayTransformerConfig` object that you pass to `ArrayTransformer`.
+
+### Transformation Constraints ###
+
+Constraints are used by the ArrayTransformer to allow or prevent certain dates from being added to a `RecurrenceCollection`. Recurr provides the following constraints:
+
+- `AfterConstraint(\DateTime $after, $inc = false)`
+- `BeforeConstraint(\DateTime $before, $inc = false)`
+- `BetweenConstraint(\DateTime $after, \DateTime $before, $inc = false)`
+
+`$inc` defines what happens if `$after` or `$before` are themselves recurrences. If `$inc = true`, they will be included in the collection. For example,
+
+```php
+$startDate   = new \DateTime('2014-06-17 04:00:00');
+$rule        = new \Recurr\Rule('FREQ=MONTHLY;COUNT=5', $startDate);
+$transformer = new \Recurr\Transformer\ArrayTransformer();
+
+$constraint = new \Recurr\Transformer\Constraint\BeforeConstraint(new \DateTime('2014-08-01 00:00:00'));
+print_r($transformer->transform($rule, $constraint));
+```
+
+> Note: If building your own constraint, it is important to know that dates which do not meet the constraint's requirements do **not** count toward the transformer's virtual limit. If you manually set your constraint's `$stopsTransformer` property to `false`, the transformer *might* crash via an infinite loop. See the `BetweenConstraint` for an example on how to prevent that.
+
+### Post-Transformation `RecurrenceCollection` Filters ###
+
+`RecurrenceCollection` provides the following chainable helper methods to filter out recurrences:
+
+- `startsBetween(\DateTime $after, \DateTime $before, $inc = false)`
+- `startsBefore(\DateTime $before, $inc = false)`
+- `startsAfter(\DateTime $after, $inc = false)`
+- `endsBetween(\DateTime $after, \DateTime $before, $inc = false)`
+- `endsBefore(\DateTime $before, $inc = false)`
+- `endsAfter(\DateTime $after, $inc = false)`
+
+`$inc` defines what happens if `$after` or `$before` are themselves recurrences. If `$inc = true`, they will be included in the filtered collection. For example,
+
+    pseudo...
+    2014-06-01 startsBetween(2014-06-01, 2014-06-20) // false
+    2014-06-01 startsBetween(2014-06-01, 2014-06-20, true) // true
+
+> Note: `RecurrenceCollection` extends the Doctrine project's [ArrayCollection](https://github.com/doctrine/collections/blob/master/lib/Doctrine/Common/Collections/ArrayCollection.php) class.
+
+RRULE to Text
+--------------------------
+
+Recurr supports transforming some recurrence rules into human readable text.
+This feature is still in beta and only supports yearly, monthly, weekly, and daily frequencies.
+
+```php
+$rule = new Rule('FREQ=YEARLY;INTERVAL=2;COUNT=3;', new \DateTime());
+
+$textTransformer = new TextTransformer();
+echo $textTransformer->transform($rule);
+```
+
+If you need more than English you can pass in a translator with one of the
+supported locales *(see translations folder)*.
+
+```php
+$rule = new Rule('FREQ=YEARLY;INTERVAL=2;COUNT=3;', new \DateTime());
+
+$textTransformer = new TextTransformer(
+    new \Recurr\Transformer\Translator('de')
+);
+echo $textTransformer->transform($rule);
+```
+
+Warnings
+---------------
+
+- **Monthly recurring rules **
+  By default, if your start date is on the 29th, 30th, or 31st, Recurr will skip following months that don't have at least that many days.
+  *(e.g. Jan 31 + 1 month = March)* 
+
+This behavior is configurable:
+
+```php
+$timezone    = 'America/New_York';
+$startDate   = new \DateTime('2013-01-31 20:00:00', new \DateTimeZone($timezone));
+$rule        = new \Recurr\Rule('FREQ=MONTHLY;COUNT=5', $startDate, null, $timezone);
+$transformer = new \Recurr\Transformer\ArrayTransformer();
+
+$transformerConfig = new \Recurr\Transformer\ArrayTransformerConfig();
+$transformerConfig->enableLastDayOfMonthFix();
+$transformer->setConfig($transformerConfig);
+
+print_r($transformer->transform($rule));
+// 2013-01-31, 2013-02-28, 2013-03-31, 2013-04-30, 2013-05-31
+```
+
+
+Contribute
+----------
+
+Feel free to comment or make pull requests. Please include tests with PRs.
+
+
+License
+-------
+
+Recurr is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.

+ 41 - 0
api/vendor/simshaun/recurr/composer.json

@@ -0,0 +1,41 @@
+{
+	"name": "simshaun/recurr",
+	"description": "PHP library for working with recurrence rules",
+	"keywords": ["rrule", "recurrence", "recurring", "events", "dates"],
+	"homepage": "https://github.com/simshaun/recurr",
+	"type": "library",
+	"license": "MIT",
+	"authors": [
+		{
+			"name": "Shaun Simmons",
+			"email": "shaun@shaun.pub",
+			"homepage": "https://shaun.pub"
+		}
+	],
+	"require": {
+		"php": "^7.2||^8.0",
+		"doctrine/collections": "~1.6"
+	},
+	"require-dev": {
+		"phpunit/phpunit": "^8.5.16",
+		"symfony/yaml": "^5.3"
+	},
+	"autoload": {
+		"psr-4": {
+			"Recurr\\": "src/Recurr/"
+		}
+	},
+	"autoload-dev": {
+		"psr-4": {
+			"Recurr\\Test\\": "tests/Recurr/Test"
+		}
+	},
+	"extra": {
+		"branch-alias": {
+			"dev-master": "0.x-dev"
+		}
+	},
+	"scripts": {
+		"test": "./vendor/bin/phpunit --color=always"
+	}
+}

+ 48 - 0
api/vendor/simshaun/recurr/src/Recurr/DateExclusion.php

@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * Copyright 2014 Shaun Simmons
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Recurr;
+
+/**
+ * Class DateExclusion is a container for a single \DateTimeInterface.
+ *
+ * The purpose of this class is to hold a flag that specifies whether
+ * or not the \DateTimeInterface was created from a DATE only, or with a
+ * DATETIME.
+ *
+ * It also tracks whether or not the exclusion is explicitly set to UTC.
+ *
+ * @package Recurr
+ * @author  Shaun Simmons <shaun@envysphere.com>
+ */
+class DateExclusion
+{
+    /** @var \DateTimeInterface */
+    public $date;
+
+    /** @var bool Day of year */
+    public $hasTime;
+
+    /** @var bool */
+    public $isUtcExplicit;
+
+    /**
+     * Constructor
+     *
+     * @param \DateTimeInterface $date
+     * @param bool               $hasTime
+     * @param bool               $isUtcExplicit
+     */
+    public function __construct(\DateTimeInterface $date, $hasTime = true, $isUtcExplicit = false)
+    {
+        $this->date          = $date;
+        $this->hasTime       = $hasTime;
+        $this->isUtcExplicit = $isUtcExplicit;
+    }
+}

+ 48 - 0
api/vendor/simshaun/recurr/src/Recurr/DateInclusion.php

@@ -0,0 +1,48 @@
+<?php
+
+/*
+ * Copyright 2015 Shaun Simmons
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Recurr;
+
+/**
+ * Class DateInclusion is a container for a single \DateTimeInterface.
+ *
+ * The purpose of this class is to hold a flag that specifies whether
+ * or not the \DateTimeInterface was created from a DATE only, or with a
+ * DATETIME.
+ *
+ * It also tracks whether or not the inclusion is explicitly set to UTC.
+ *
+ * @package Recurr
+ * @author  Shaun Simmons <shaun@envysphere.com>
+ */
+class DateInclusion
+{
+    /** @var \DateTimeInterface */
+    public $date;
+
+    /** @var bool Day of year */
+    public $hasTime;
+
+    /** @var bool */
+    public $isUtcExplicit;
+
+    /**
+     * Constructor
+     *
+     * @param \DateTimeInterface $date
+     * @param bool               $hasTime
+     * @param bool               $isUtcExplicit
+     */
+    public function __construct(\DateTimeInterface $date, $hasTime = true, $isUtcExplicit = false)
+    {
+        $this->date          = $date;
+        $this->hasTime       = $hasTime;
+        $this->isUtcExplicit = $isUtcExplicit;
+    }
+}

+ 73 - 0
api/vendor/simshaun/recurr/src/Recurr/DateInfo.php

@@ -0,0 +1,73 @@
+<?php
+
+/*
+ * Copyright 2013 Shaun Simmons
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ * Based on rrule.js
+ * Copyright 2010, Jakub Roztocil and Lars Schoning
+ * https://github.com/jkbr/rrule/blob/master/LICENCE
+ */
+
+namespace Recurr;
+
+/**
+ * Class DateInfo is responsible for holding information based on a particular
+ * date that is applicable to a Rule.
+ *
+ * @package Recurr
+ * @author  Shaun Simmons <shaun@envysphere.com>
+ */
+class DateInfo
+{
+    /** @var \DateTime */
+    public $dt;
+
+    /**
+     * @var int Number of days in the month.
+     */
+    public $monthLength;
+
+    /**
+     * @var int Number of days in the year (365 normally, 366 on leap years)
+     */
+    public $yearLength;
+
+    /**
+     * @var int Number of days in the next year (365 normally, 366 on leap years)
+     */
+    public $nextYearLength;
+
+    /**
+     * @var array Day of year of last day of each month.
+     */
+    public $mRanges;
+
+    /** @var int Day of week */
+    public $dayOfWeek;
+
+    /** @var int Day of week of the year's first day */
+    public $dayOfWeekYearDay1;
+
+    /**
+     * @var array Month number for each day of the year.
+     */
+    public $mMask;
+
+    /**
+     * @var array Month-daynumber for each day of the year.
+     */
+    public $mDayMask;
+
+    /**
+     * @var array Month-daynumber for each day of the year (in reverse).
+     */
+    public $mDayMaskNeg;
+
+    /**
+     * @var array Day of week (0-6) for each day of the year, 0 being Monday
+     */
+    public $wDayMask;
+}

+ 571 - 0
api/vendor/simshaun/recurr/src/Recurr/DateUtil.php

@@ -0,0 +1,571 @@
+<?php
+
+/*
+ * Copyright 2013 Shaun Simmons
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ *
+ * Based on rrule.js
+ * Copyright 2010, Jakub Roztocil and Lars Schoning
+ * https://github.com/jkbr/rrule/blob/master/LICENCE
+ *
+ * Based on python-dateutil - Extensions to the standard Python datetime module.
+ * Copyright (c) 2003-2011 - Gustavo Niemeyer <gustavo@niemeyer.net>
+ * Copyright (c) 2012 - Tomi Pieviläinen <tomi.pievilainen@iki.fi>
+ */
+
+namespace Recurr;
+
+/**
+ * Class DateUtil is responsible for providing utilities applicable to Rules.
+ *
+ * @package Recurr
+ * @author  Shaun Simmons <shaun@envysphere.com>
+ */
+class DateUtil
+{
+    public static $leapBug = null;
+
+    public static $monthEndDoY366 = array(
+        0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366
+    );
+
+    public static $monthEndDoY365 = array(
+        0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365
+    );
+
+    public static $wDayMask = array(
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6, 0, 1, 2, 3, 4, 5, 6,
+        0, 1, 2, 3, 4, 5, 6,
+    );
+
+    /**
+     * Get an object containing info for a particular date
+     *
+     * @param \DateTimeInterface $dt
+     *
+     * @return DateInfo
+     */
+    public static function getDateInfo(\DateTimeInterface $dt)
+    {
+        $i              = new DateInfo();
+        $i->dt          = $dt;
+        $i->dayOfWeek   = self::getDayOfWeek($dt);
+        $i->monthLength = $dt->format('t');
+        $i->yearLength  = self::getYearLength($dt);
+
+        $i->mMask       = self::getMonthMask($dt);
+        $i->mDayMask    = self::getMonthDaysMask($dt);
+        $i->mDayMaskNeg = self::getMonthDaysMask($dt, true);
+
+        if ($i->yearLength == 365) {
+            $i->mRanges = self::$monthEndDoY365;
+        } else {
+            $i->mRanges = self::$monthEndDoY366;
+        }
+
+        $tmpDt = clone $dt;
+        $tmpDt = $tmpDt->setDate($dt->format('Y') + 1, 1, 1);
+        $i->nextYearLength = self::getYearLength($tmpDt);
+
+        $tmpDt = clone $dt;
+        $tmpDt = $tmpDt->setDate($dt->format('Y'), 1, 1);
+        $i->dayOfWeekYearDay1 = self::getDayOfWeek($tmpDt);
+
+        $i->wDayMask = array_slice(
+            self::$wDayMask,
+            $i->dayOfWeekYearDay1
+        );
+
+        return $i;
+    }
+
+    /**
+     * Get an array of DOY (Day of Year) for each day in a particular week.
+     *
+     * @param \DateTimeInterface     $dt
+     * @param \DateTimeInterface     $start
+     * @param null|Rule              $rule
+     * @param null|DateInfo          $dtInfo
+     *
+     * @return DaySet
+     */
+    public static function getDaySetOfWeek(
+        \DateTimeInterface $dt,
+        \DateTimeInterface $start,
+        Rule $rule = null,
+        DateInfo $dtInfo = null
+    )
+    {
+        $start = clone $dt;
+        $start = $start->setDate($start->format('Y'), 1, 1);
+
+        $diff  = $dt->diff($start);
+        $start = $diff->days;
+
+        $set = array();
+        for ($i = $start, $k = 0; $k < 7; $k++) {
+            $set[] = $i;
+            ++$i;
+
+            if (null !== $dtInfo && null !== $rule && $dtInfo->wDayMask[$i] == $rule->getWeekStartAsNum()) {
+                break;
+            }
+        }
+
+        $obj = new DaySet($set, $start, $i);
+
+        return $obj;
+    }
+
+    /**
+     * @param Rule               $rule
+     * @param \DateTimeInterface $dt
+     * @param DateInfo           $dtInfo
+     * @param \DateTimeInterface $start
+     *
+     * @return DaySet
+     */
+    public static function getDaySet(Rule $rule, \DateTimeInterface $dt, DateInfo $dtInfo, $start)
+    {
+        switch ($rule->getFreq()) {
+            case Frequency::SECONDLY:
+                return self::getDaySetOfDay($dt, $start, $rule, $dtInfo);
+                break;
+            case Frequency::MINUTELY:
+                return self::getDaySetOfDay($dt, $start, $rule, $dtInfo);
+                break;
+            case Frequency::HOURLY:
+                return self::getDaySetOfDay($dt, $start, $rule, $dtInfo);
+                break;
+            case Frequency::DAILY:
+                return self::getDaySetOfDay($dt, $start, $rule, $dtInfo);
+                break;
+            case Frequency::WEEKLY:
+                return self::getDaySetOfWeek($dt, $start, $rule, $dtInfo);
+            case Frequency::MONTHLY:
+                return self::getDaySetOfMonth($dt, $start, $rule, $dtInfo);
+            case Frequency::YEARLY:
+                return self::getDaySetOfYear($dt, $start, $rule, $dtInfo);
+        }
+
+        throw new \RuntimeException('Invalid freq.');
+    }
+
+    /**
+     * Get an array of DOY (Day of Year) for each day in a particular year.
+     *
+     * @param \DateTimeInterface $dt The datetime
+     *
+     * @return DaySet
+     */
+    public static function getDaySetOfYear(\DateTimeInterface $dt)
+    {
+        $yearLen = self::getYearLength($dt);
+        $set     = range(0, $yearLen - 1);
+
+        return new DaySet($set, 0, $yearLen);
+    }
+
+    /**
+     * Get an array of DOY (Day of Year) for each day in a particular month.
+     *
+     * @param \DateTimeInterface $dt The datetime
+     *
+     * @return DaySet
+     */
+    public static function getDaySetOfMonth(\DateTimeInterface $dt)
+    {
+        $dateInfo = self::getDateInfo($dt);
+        $monthNum = $dt->format('n');
+
+        $start = $dateInfo->mRanges[$monthNum - 1];
+        $end   = $dateInfo->mRanges[$monthNum];
+
+        $days = range(0, $dt->format('t') - 1);
+        $set  = range($start, $end - 1);
+        $set  = array_combine($days, $set);
+        $obj  = new DaySet($set, $start, $end - 1);
+
+        return $obj;
+    }
+
+    /**
+     * Get an array of DOY (Day of Year) for each day in a particular month.
+     *
+     * @param \DateTimeInterface $dt The datetime
+     *
+     * @return DaySet
+     */
+    public static function getDaySetOfDay(\DateTimeInterface $dt)
+    {
+        $dayOfYear = $dt->format('z');
+
+        if (self::isLeapYearDate($dt) && self::hasLeapYearBug() && $dt->format('nj') > 229) {
+            $dayOfYear -= 1;
+        }
+
+        $start = $dayOfYear;
+        $end   = $dayOfYear;
+
+        $set = range($start, $end);
+        $obj = new DaySet($set, $start, $end + 1);
+
+        return $obj;
+    }
+
+    /**
+     * @param Rule               $rule
+     * @param \DateTimeInterface $dt
+     *
+     * @return array
+     */
+    public static function getTimeSetOfHour(Rule $rule, \DateTimeInterface $dt)
+    {
+        $set = array();
+
+        $hour     = $dt->format('G');
+        $byMinute = $rule->getByMinute();
+        $bySecond = $rule->getBySecond();
+
+        if (empty($byMinute)) {
+            $byMinute = array($dt->format('i'));
+        }
+
+        if (empty($bySecond)) {
+            $bySecond = array($dt->format('s'));
+        }
+
+        foreach ($byMinute as $minute) {
+            foreach ($bySecond as $second) {
+                $set[] = new Time($hour, $minute, $second);
+            }
+        }
+
+        return $set;
+    }
+
+    /**
+     * @param Rule               $rule
+     * @param \DateTimeInterface $dt
+     *
+     * @return array
+     */
+    public static function getTimeSetOfMinute(Rule $rule, \DateTimeInterface $dt)
+    {
+        $set = array();
+
+        $hour     = $dt->format('G');
+        $minute   = $dt->format('i');
+        $bySecond = $rule->getBySecond();
+
+        if (empty($bySecond)) {
+            $bySecond = array($dt->format('s'));
+        }
+
+        foreach ($bySecond as $second) {
+            $set[] = new Time($hour, $minute, $second);
+        }
+
+        return $set;
+    }
+
+    /**
+     * @param \DateTimeInterface $dt
+     *
+     * @return array
+     */
+    public static function getTimeSetOfSecond(\DateTimeInterface $dt)
+    {
+        return array(new Time($dt->format('G'), $dt->format('i'), $dt->format('s')));
+    }
+
+    /**
+     * @param Rule               $rule
+     * @param \DateTimeInterface $dt
+     *
+     * @return array
+     */
+    public static function getTimeSet(Rule $rule, \DateTimeInterface $dt)
+    {
+        $set = array();
+
+        if (null === $rule || $rule->getFreq() >= Frequency::HOURLY) {
+            return $set;
+        }
+
+        $byHour   = $rule->getByHour();
+        $byMinute = $rule->getByMinute();
+        $bySecond = $rule->getBySecond();
+
+        if (empty($byHour)) {
+            $byHour = array($dt->format('G'));
+        }
+
+        if (empty($byMinute)) {
+            $byMinute = array($dt->format('i'));
+        }
+
+        if (empty($bySecond)) {
+            $bySecond = array($dt->format('s'));
+        }
+
+        foreach ($byHour as $hour) {
+            foreach ($byMinute as $minute) {
+                foreach ($bySecond as $second) {
+                    $set[] = new Time($hour, $minute, $second);
+                }
+            }
+        }
+
+        return $set;
+    }
+
+    /**
+     * Get a reference array with the day number for each day of each month.
+     *
+     * @param \DateTimeInterface $dt The datetime
+     * @param bool               $negative
+     *
+     * @return array
+     */
+    public static function getMonthDaysMask(\DateTimeInterface $dt, $negative = false)
+    {
+        if ($negative) {
+            $m29 = range(-29, -1);
+            $m30 = range(-30, -1);
+            $m31 = range(-31, -1);
+        } else {
+            $m29 = range(1, 29);
+            $m30 = range(1, 30);
+            $m31 = range(1, 31);
+        }
+
+        $mask = array_merge(
+            $m31, // Jan (31)
+            $m29, // Feb (28)
+            $m31, // Mar (31)
+            $m30, // Apr (30)
+            $m31, // May (31)
+            $m30, // Jun (30)
+            $m31, // Jul (31)
+            $m31, // Aug (31)
+            $m30, // Sep (30)
+            $m31, // Oct (31)
+            $m30, // Nov (30)
+            $m31, // Dec (31)
+            array_slice(
+                $m31,
+                0,
+                7
+            )
+        );
+
+        if (self::isLeapYearDate($dt)) {
+            return $mask;
+        } else {
+            if ($negative) {
+                $mask = array_merge(array_slice($mask, 0, 31), array_slice($mask, 32));
+            } else {
+                $mask = array_merge(array_slice($mask, 0, 59), array_slice($mask, 60));
+            }
+
+            return $mask;
+        }
+    }
+
+    public static function getMonthMask(\DateTimeInterface $dt)
+    {
+        if (self::isLeapYearDate($dt)) {
+            return array_merge(
+                array_fill(0, 31, 1), // Jan (31)
+                array_fill(0, 29, 2), // Feb (29)
+                array_fill(0, 31, 3), // Mar (31)
+                array_fill(0, 30, 4), // Apr (30)
+                array_fill(0, 31, 5), // May (31)
+                array_fill(0, 30, 6), // Jun (30)
+                array_fill(0, 31, 7), // Jul (31)
+                array_fill(0, 31, 8), // Aug (31)
+                array_fill(0, 30, 9), // Sep (30)
+                array_fill(0, 31, 10), // Oct (31)
+                array_fill(0, 30, 11), // Nov (30)
+                array_fill(0, 31, 12), // Dec (31)
+                array_fill(0, 7, 1)
+            );
+        } else {
+            return array_merge(
+                array_fill(0, 31, 1), // Jan (31)
+                array_fill(0, 28, 2), // Feb (28)
+                array_fill(0, 31, 3), // Mar (31)
+                array_fill(0, 30, 4), // Apr (30)
+                array_fill(0, 31, 5), // May (31)
+                array_fill(0, 30, 6), // Jun (30)
+                array_fill(0, 31, 7), // Jul (31)
+                array_fill(0, 31, 8), // Aug (31)
+                array_fill(0, 30, 9), // Sep (30)
+                array_fill(0, 31, 10), // Oct (31)
+                array_fill(0, 30, 11), // Nov (30)
+                array_fill(0, 31, 12), // Dec (31)
+                array_fill(0, 7, 1)
+            );
+        }
+    }
+
+    public static function getDateTimeByDayOfYear($dayOfYear, $year, \DateTimeZone $timezone)
+    {
+        $dtTmp = new \DateTime('now', $timezone);
+        $dtTmp = $dtTmp->setDate($year, 1, 1);
+        $dtTmp = $dtTmp->modify("+$dayOfYear day");
+
+        return $dtTmp;
+    }
+
+    public static function hasLeapYearBug()
+    {
+        $leapBugTest = \DateTime::createFromFormat('Y-m-d', '2016-03-21');
+        return $leapBugTest->format('z') != '80';
+    }
+
+    /**
+     * closure/goog/math/math.js:modulo
+     * Copyright 2006 The Closure Library Authors.
+     *
+     * The % operator in PHP returns the remainder of a / b, but differs from
+     * some other languages in that the result will have the same sign as the
+     * dividend. For example, -1 % 8 == -1, whereas in some other languages
+     * (such as Python) the result would be 7. This function emulates the more
+     * correct modulo behavior, which is useful for certain applications such as
+     * calculating an offset index in a circular list.
+     *
+     * @param int $a The dividend.
+     * @param int $b The divisor.
+     *
+     * @return int $a % $b where the result is between 0 and $b
+     *   (either 0 <= x < $b
+     *     or $b < x <= 0, depending on the sign of $b).
+     */
+    public static function pymod($a, $b)
+    {
+        $x = $a % $b;
+
+        // If $x and $b differ in sign, add $b to wrap the result to the correct sign.
+        return ($x * $b < 0) ? $x + $b : $x;
+    }
+
+    /**
+     * Alias method to determine if a date falls within a leap year.
+     *
+     * @param \DateTimeInterface $dt
+     *
+     * @return bool
+     */
+    public static function isLeapYearDate(\DateTimeInterface $dt)
+    {
+        return $dt->format('L') ? true : false;
+    }
+
+    /**
+     * Alias method to determine if a year is a leap year.
+     *
+     * @param int $year
+     *
+     * @return bool
+     */
+    public static function isLeapYear($year)
+    {
+        $isDivisBy4   = $year % 4 == 0 ? true : false;
+        $isDivisBy100 = $year % 100 == 0? true : false;
+        $isDivisBy400 = $year % 400 == 0 ? true : false;
+
+        // http://en.wikipedia.org/wiki/February_29
+        if ($isDivisBy100 && !$isDivisBy400) {
+            return false;
+        }
+
+        return $isDivisBy4;
+    }
+
+    /**
+     * Method to determine the day of the week from MO-SU.
+     *
+     * MO = Monday
+     * TU = Tuesday
+     * WE = Wednesday
+     * TH = Thursday
+     * FR = Friday
+     * SA = Saturday
+     * SU = Sunday
+     *
+     * @param \DateTimeInterface $dt
+     *
+     * @return string
+     */
+    public static function getDayOfWeekAsText(\DateTimeInterface $dt)
+    {
+        $dayOfWeek = $dt->format('w') - 1;
+
+        if ($dayOfWeek < 0) {
+            $dayOfWeek = 6;
+        }
+
+        $map = array('MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU');
+
+        return $map[$dayOfWeek];
+    }
+
+    /**
+     * Alias method to determine the day of the week from 0-6.
+     *
+     * 0 = Monday
+     * 1 = Tuesday
+     * 2 = Wednesday
+     * 3 = Thursday
+     * 4 = Friday
+     * 5 = Saturday
+     * 6 = Sunday
+     *
+     * @param \DateTimeInterface $dt
+     *
+     * @return int
+     */
+    public static function getDayOfWeek(\DateTimeInterface $dt)
+    {
+        $dayOfWeek = $dt->format('w') - 1;
+
+        if ($dayOfWeek < 0) {
+            $dayOfWeek = 6;
+        }
+
+        return $dayOfWeek;
+    }
+
+    /**
+     * Get the number of days in a year.
+     *
+     * @param \DateTimeInterface $dt
+     *
+     * @return int
+     */
+    public static function getYearLength(\DateTimeInterface $dt)
+    {
+        return self::isLeapYearDate($dt) ? 366 : 365;
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов