diff --git a/.gitignore b/.gitignore
index 293c98f..ad1ae10 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
-/config/git-broadcast.ini
-/config/flashii.ini
+[Tt]humbs.db
+[Dd]esktop.ini
+.DS_Store
+/.debug
+/vendor
+/config.cfg
+/config.ini
+/.migrating
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..fde3add
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,13 @@
+{
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "autoload": {
+ "psr-4": {
+ "Satori\\": "src"
+ }
+ },
+ "require": {
+ "flashwave/index": "dev-master",
+ "flashwave/syokuhou": "dev-master"
+ }
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..7d04793
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,107 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "d909826a501788db19148054e389ec5f",
+ "packages": [
+ {
+ "name": "flashwave/index",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "https://git.flash.moe/flash/index.git",
+ "reference": "82a350a5c719cc83aa22382201683a68a2629f2a"
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10.2"
+ },
+ "suggest": {
+ "ext-mysqli": "Support for the Index\\Data\\MariaDB namespace (both mysqlnd and libmysql are supported).",
+ "ext-sqlite3": "Support for the Index\\Data\\SQLite namespace."
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "files": [
+ "index.php"
+ ],
+ "psr-4": {
+ "Index\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "bsd-3-clause-clear"
+ ],
+ "authors": [
+ {
+ "name": "flashwave",
+ "email": "packagist@flash.moe",
+ "homepage": "https://flash.moe",
+ "role": "mom"
+ }
+ ],
+ "description": "Composer package for the common library for my projects.",
+ "homepage": "https://railgun.sh/index",
+ "time": "2023-09-15T22:44:36+00:00"
+ },
+ {
+ "name": "flashwave/syokuhou",
+ "version": "dev-master",
+ "source": {
+ "type": "git",
+ "url": "https://git.flash.moe/flash/syokuhou.git",
+ "reference": "b3470ad8605b0484294c73cd95be6e7ba4551e5a"
+ },
+ "require": {
+ "flashwave/index": "dev-master",
+ "php": ">=8.2"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^10.4"
+ },
+ "default-branch": true,
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Syokuhou\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "bsd-3-clause-clear"
+ ],
+ "authors": [
+ {
+ "name": "flashwave",
+ "email": "packagist@flash.moe",
+ "homepage": "https://flash.moe",
+ "role": "mom"
+ }
+ ],
+ "description": "Configuration library for PHP.",
+ "homepage": "https://railgun.sh/syokuhou",
+ "time": "2023-10-20T21:26:38+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": {
+ "flashwave/index": 20,
+ "flashwave/syokuhou": 20
+ },
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "2.3.0"
+}
diff --git a/database/2023_11_06_004121_initial_structure.php b/database/2023_11_06_004121_initial_structure.php
new file mode 100644
index 0000000..e1b2848
--- /dev/null
+++ b/database/2023_11_06_004121_initial_structure.php
@@ -0,0 +1,24 @@
+query('SHOW TABLES');
+ while($result->next())
+ $existingTables[] = $result->getString(0);
+
+ if(!in_array('exchange-rates', $existingTables))
+ $conn->execute('
+ CREATE TABLE `exchange-rates` (
+ rate_from BINARY(3) NOT NULL,
+ rate_to BINARY(3) NOT NULL,
+ rate_value DECIMAL(20,6) NOT NULL,
+ rate_stored TIMESTAMP NOT NULL DEFAULT current_timestamp(),
+ PRIMARY KEY (rate_from, rate_to),
+ KEY rate_stored (rate_stored)
+ ) ENGINE=InnoDB COLLATE="utf8mb4_bin"
+ ');
+ }
+}
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 0000000..c044636
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,47 @@
+
+
+
+
+ Cannot find page
+
+
+
+
+
+
+ The page cannot be found
+
+
+ The page you are looking for might have been removed, had its
+ name changed, or is temporarily unavailable.
+
+
+
+ Please try the following:
+
+
+ -
+ If you typed the page address in the Address bar, make
+ sure that it is spelled correctly.
+
+ -
+ Open the flashii.net
+ home page, and then look for links to the information you want.
+
+ -
+ Click the
Back
+ button to try another link.
+
+ -
+ Click
Search
+ to look for information on the Internet.
+
+
+
+ HTTP 404 - File not found
+
+ Internet Explorer
+
+
+
+
diff --git a/public/booru.php b/public/booru.php
deleted file mode 100644
index 5268bb7..0000000
--- a/public/booru.php
+++ /dev/null
@@ -1,186 +0,0 @@
- [
- 'title' => 'Danbooru',
- 'type' => 'danbooru',
- 'randomUrlFormat' => 'https://danbooru.donmai.us/posts.json?limit=20&tags=order:random+limit:20+%s',
- 'postUrlFormat' => 'https://danbooru.donmai.us/posts/%s',
- 'implicitTags' => [
- 'misaka_mikoto' => ['-shokuhou_misaki'],
- 'shokuhou_misaki' => ['-misaka_mikoto'],
- 'kagari_(rewrite)' => ['-screencap'],
- 'kasuga_ayumu' => ['-rating:explicit'],
- 'osaka_(azumanga_daioh)' => ['-rating:explicit'],
- 'yazawa_nico' => ['-nishikino_maki'],
- 'nishikino_maki' => ['-yazawa_nico'],
- 'heanna_sumire' => ['-tang_keke'],
- 'tang_keke' => ['-heanna_sumire'],
- ],
- ],
- 'gelbooru' => [
- 'title' => 'Gelbooru',
- 'type' => 'gelbooru',
- 'randomUrlFormat' => 'https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&limit=20&tags=sort:random+%s',
- 'postUrlFormat' => 'https://gelbooru.com/index.php?page=post&s=view&id=%s',
- 'implicitTags' => [
- 'misaka_mikoto' => ['-shokuhou_misaki'],
- 'shokuhou_misaki' => ['-misaka_mikoto'],
- 'kagari_(rewrite)' => ['-screencap'],
- 'kasuga_ayumu' => ['-rating:explicit'],
- 'yazawa_nico' => ['-nishikino_maki'],
- 'nishikino_maki' => ['-yazawa_nico'],
- 'heanna_sumire' => ['-tang_keke'],
- 'tang_keke' => ['-heanna_sumire'],
- ],
- ],
- 'yandere' => [
- 'title' => 'Yande.re',
- 'type' => 'yandere',
- 'randomUrlFormat' => 'https://yande.re/post.json?api_version=2&limit=20&tags=order:random+%s',
- 'postUrlFormat' => 'https://yande.re/post/show/%s',
- 'implicitTags' => [
- 'misaka_mikoto' => ['-shokuhou_misaki'],
- 'shokuhou_misaki' => ['-misaka_mikoto'],
- 'kagari_(rewrite)' => ['-cap'],
- 'kasuga_ayumu' => ['-rating:explicit'],
- 'yazawa_nico' => ['-nishikino_maki'],
- 'nishikino_maki' => ['-yazawa_nico'],
- 'heanna_sumire' => ['-tang_keke'],
- 'tang_keke' => ['-heanna_sumire'],
- ],
- ],
-]);
-
-$config = parse_ini_file(__DIR__ . '/../config/flashii.ini');
-
-header('Content-Type: application/json; charset=utf-8');
-
-$booru = (string)filter_input(INPUT_GET, 'b');
-$tags = (string)filter_input(INPUT_GET, 't');
-$tags = array_filter(explode(' ', trim($tags)));
-
-$randomSource = empty($booru);
-
-if($randomSource)
- $booru = BOORUS[0];
-elseif(!in_array($booru, BOORUS))
- die('{"error":"booru"}');
-
-if(!array_key_exists($booru, BOORU_INFOS))
- die('{"error":"booru-info"}');
-
-$booruInfo = BOORU_INFOS[$booru];
-
-if(empty($booruInfo['randomUrlFormat']))
- die('{"error":"random"}');
-if(empty($booruInfo['postUrlFormat']))
- die('{"error":"post"}');
-if(empty($booruInfo['type']))
- die('{"error":"type"}');
-
-$isDanbooru = $booruInfo['type'] === 'danbooru';
-$isGelbooru = $booruInfo['type'] === 'gelbooru';
-$isYandere = $booruInfo['type'] === 'yandere';
-
-if(!empty($booruInfo['implicitTags'])) {
- $originalTags = $tags;
- foreach($booruInfo['implicitTags'] as $targetTag => $addTags) {
- if(!in_array($targetTag, $originalTags))
- continue;
-
- foreach($addTags as $tag)
- if(!in_array($tag, $tags))
- $tags[] = $tag;
- }
-}
-
-$tagString = implode('+', array_map(fn($x) => urlencode($x), $tags));
-
-$curl = curl_init(sprintf($booruInfo['randomUrlFormat'], $tagString));
-
-$headers = ['Accept: application/json'];
-if($isDanbooru)
- $headers[] = 'Authorization: Basic ' . base64_encode($config['danbooru-token']);
-
-curl_setopt_array($curl, [
- CURLOPT_AUTOREFERER => true,
- CURLOPT_CERTINFO => false,
- CURLOPT_FAILONERROR => false,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 5,
- CURLOPT_PATH_AS_IS => true,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TCP_FASTOPEN => true,
- CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_CONNECTTIMEOUT => 2,
- CURLOPT_TIMEOUT => 5,
- CURLOPT_USERAGENT => 'Satori/20230202 (+https://fii.moe/satori)',
- CURLOPT_HTTPHEADER => $headers,
-]);
-$response = json_decode(curl_exec($curl));
-curl_close($curl);
-
-$posts = [];
-
-// gelbooru and yandere block remote embedding, so not even going to bother with a file_url for those
-
-if($isDanbooru) {
- if(!empty($response) && is_array($response)) {
- foreach($response as $raw) {
- $posts[] = $post = new stdClass;
- $post->id = $raw->id;
- $post->rating = $raw->rating;
- if(!empty($raw->file_url))
- $post->file_url = $raw->file_url;
- $post->post_url = sprintf($booruInfo['postUrlFormat'], $raw->id);
- $post->tags = explode(' ', $raw->tag_string);
- $post->tags_general = explode(' ', $raw->tag_string_general);
- $post->tags_character = explode(' ', $raw->tag_string_character);
- $post->tags_copyright = explode(' ', $raw->tag_string_copyright);
- $post->tags_artist = explode(' ', $raw->tag_string_artist);
- $post->tags_meta = explode(' ', $raw->tag_string_meta);
- }
- }
-} elseif($isGelbooru) {
- if(!empty($response) && !empty($response->post) && is_array($response->post)) {
- foreach($response->post as $raw) {
- $posts[] = $post = new stdClass;
- $post->id = $raw->id;
- $post->rating = $raw->rating[0];
- //if(!empty($raw->file_url))
- // $post->file_url = $raw->file_url;
- $post->post_url = sprintf($booruInfo['postUrlFormat'], $raw->id);
- $post->tags = explode(' ', $raw->tags);
- }
- }
-} elseif($isYandere) {
- if(!empty($response) && !empty($response->posts) && is_array($response->posts)) {
- foreach($response->posts as $raw) {
- $posts[] = $post = new stdClass;
- $post->id = $raw->id;
- $post->rating = $raw->rating;
- //if(!empty($raw->file_url))
- // $post->file_url = $raw->file_url;
- $post->post_url = sprintf($booruInfo['postUrlFormat'], $raw->id);
- $post->tags = explode(' ', $raw->tags);
- }
- }
-} else
- die('{"error":"support"}');
-
-echo json_encode([
- 'source' => [
- 'name' => $booru,
- 'type' => $booruInfo['type'],
- 'title' => $booruInfo['title'] ?? $booru,
- ],
- 'tags' => $tags,
- 'posts' => $posts,
-]);
diff --git a/public/exrate.php b/public/exrate.php
deleted file mode 100644
index 36a9d02..0000000
--- a/public/exrate.php
+++ /dev/null
@@ -1,93 +0,0 @@
-execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';');
-} catch(Exception $ex) {
- die((string)$ex);
-}
-
-define('EXRATE_INTER', 'EUR');
-define('EXRATE_COMMON', [
- 'EUR', 'AUD', 'GBP', 'CAD', 'USD', 'JPY', 'PLN', 'SGD', 'RUB', 'ILS',
-]);
-
-$from = strtoupper((string)filter_input(INPUT_GET, 'from'));
-$to = strtoupper((string)filter_input(INPUT_GET, 'to'));
-$amount = (string)(filter_input(INPUT_GET, 'amount', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION) ?? '1');
-
-if((!empty($to) && strlen($to) !== 3) || strlen($from) !== 3) {
- http_response_code(400);
- die('Invalid currency specified.');
-}
-
-$needsRefresh = $db->query('SELECT MAX(rate_stored) > NOW() - INTERVAL 1 DAY FROM `exchange-rates` LIMIT 1');
-$needsRefresh->next();
-$needsRefresh = $needsRefresh->isNull(0) || $needsRefresh->getInteger(0) < 1;
-
-if($needsRefresh) {
- $data = json_decode(file_get_contents('https://api.exchangerate.host/latest?base=' . EXRATE_INTER), true);
- if($data !== null) {
- $db->execute('TRUNCATE `exchange-rates`');
- $insertCurrency = $db->prepare('INSERT INTO `exchange-rates` (rate_from, rate_to, rate_value) VALUES (?, ?, ?)');
- foreach($data['rates'] as $currency => $rate) {
- $insertCurrency->reset();
- $insertCurrency->addParameter(1, $data['base']);
- $insertCurrency->addParameter(2, $currency);
- $insertCurrency->addParameter(3, $rate);
- $insertCurrency->execute();
- }
- }
-}
-
-$result = new stdClass;
-$result->from = $from;
-$result->to = $to;
-$result->amount = (float)$amount;
-
-if($from === $to) {
- $result->result = $result->amount;
-} else {
- $convertCurrency = $db->prepare(sprintf(
- 'SELECT (SELECT (? / rate_value) FROM `exchange-rates` WHERE rate_from = "%1$s" AND rate_to = ?) * rate_value FROM `exchange-rates` WHERE rate_from = "%1$s" AND rate_to = ?',
- EXRATE_INTER
- ));
-
- if(empty($to)) {
- $result->results = [];
- foreach(EXRATE_COMMON as $commonCurrency) {
- if($commonCurrency === $from)
- continue;
-
- $result->results[] = $current = new stdClass;
- $current->to = $commonCurrency;
-
- $convertCurrency->reset();
- $convertCurrency->addParameter(1, $amount);
- $convertCurrency->addParameter(2, $from);
- $convertCurrency->addParameter(3, $commonCurrency);
- $convertCurrency->execute();
-
- $convertResult = $convertCurrency->getResult();
- $convertResult->next();
-
- $current->result = $convertResult->getFloat(0);
- }
- } else {
- $convertCurrency->addParameter(1, $amount);
- $convertCurrency->addParameter(2, $from);
- $convertCurrency->addParameter(3, $to);
- $convertCurrency->execute();
-
- $convertResult = $convertCurrency->getResult();
- $convertResult->next();
-
- $result->result = $convertResult->getFloat(0);
- }
-}
-
-http_response_code(200);
-header('Content-Type: application/json');
-echo json_encode($result);
diff --git a/public/git-broadcast.php b/public/git-broadcast.php
deleted file mode 100644
index 11319ff..0000000
--- a/public/git-broadcast.php
+++ /dev/null
@@ -1,167 +0,0 @@
- $repoToken) {
- if(hash_equals(hash_hmac($sigParts[0], $rawData, $repoToken), $sigParts[1])) {
- $repoAuthenticated = true;
- break;
- }
-}
-
-if(!$repoAuthenticated)
- die('signature check failed');
-
-$data = json_decode($_SERVER['CONTENT_TYPE'] === 'application/x-www-form-urlencoded'
- ? $_POST['payload']
- : $rawData);
-
-if(empty($data))
- die('body is corrupt');
-
-if(empty($data->repository->full_name))
- die('body is corrupt');
-
-if($data->repository->full_name !== $repoName)
- die('invalid repository token');
-
-$message = '';
-
-switch($_SERVER['HTTP_X_GITHUB_EVENT']) {
- case 'ping':
- $message = "ping received from {$data->repository->full_name}!";
- break;
-
- case 'create':
- switch ($data->ref_type) {
- case 'tag':
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url][/b]: [url={$data->sender->html_url}]{$data->sender->login}[/url] created tag [url={$data->repository->html_url}/releases/tag/{$data->ref}]{$data->ref}[/url]";
- break;
-
- case 'branch':
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url][/b]: [url={$data->sender->html_url}]{$data->sender->login}[/url] created branch [url={$data->repository->html_url}/tree/{$data->ref}]{$data->ref}[/url]";
- break;
-
- case 'repository':
- // doubt we'll ever get here
- // TODO: test organisation level webhooks?
- break;
- }
- break;
-
- case 'delete':
- switch($data->ref_type) {
- case 'tag':
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url][/b]: [url={$data->sender->html_url}]{$data->sender->login}[/url] deleted tag {$data->ref}";
- break;
-
- case 'branch':
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url][/b]: [url={$data->sender->html_url}]{$data->sender->login}[/url] deleted branch {$data->ref}";
- break;
-
- case 'repository':
- $message = "[b]{$data->repository->full_name}[/b]: [url={$data->sender->html_url}]{$data->sender->login}[/url] deleted the repository :crying:";
- break;
- }
- break;
-
- case 'push':
- $commitCount = count($data->commits);
- if($commitCount < 1)
- break;
-
- $message .= "[b]{$data->repository->full_name}:" . substr($data->ref, strrpos($data->ref, '/') + 1) . "[/b] {$commitCount} new commit" . ($commitCount === 1 ? '' : 's') . "\r\n";
-
- foreach($data->commits as $commit) {
- $timestamp = strtotime($commit->timestamp);
- $commit->message = trim($commit->message);
- $message .= "[b][url={$commit->url}][code]" . substr($commit->id, 0, 7) . "[/code][/url][/b] {$commit->message} - [url=" . ($isGitea ? 'https://git.flash.moe/' : 'https://github.com/') . "{$commit->author->username}]{$commit->author->username}[/url]\r\n";
- }
- break;
-
- case 'status':
- $status_colour['failure'] = '#cb2431';
- //$status_colour['pending'] = '#b5ab86';
- $status_colour['success'] = '#28a745';
- $status_colour['error'] = '#586069';
-
- $status_emotes['failure'] = [':angry:', ':angrier:', ':angriest:', ':sad:', ':ouch:', ':dizzy:', ':sweat:', ':fat:', ':jew:'];
- $status_emotes['success'] = [':happy:', ':lol:', ':yay:'];
- $status_emotes['error'] = [':blank:'];
-
- $status_name['continuous-integration/travis-ci/push'] = 'Unit Tests';
- $status_name['continuous-integration/styleci/push'] = 'Style Check';
-
- if (!array_key_exists($data->context, $status_name) || !array_key_exists($data->state, $status_colour)) {
- //$message = "/msg flash {$data->context}: {$data->state}";
- break;
- }
-
- $message = "[b][url={$data->commit->html_url}]{$data->repository->full_name}[/url] [url={$data->target_url}]" . $status_name[$data->context] . '[/url][/b]: [b][i][color=' . $status_colour[$data->state] . ']' . ucfirst($data->state) . ' ' . $status_emotes[$data->state][array_rand($status_emotes[$data->state])] . '[/color][/i][/b]';
- break;
-
- case 'watch':
- switch($data->action) {
- case 'started':
- $message = "[url={$data->sender->html_url}]{$data->sender->login}[/url] starred [url={$data->repository->html_url}]{$data->repository->full_name}[/url] :love:";
- break;
- }
- break;
-
- case 'fork':
- $message = "[url={$data->forkee->html_url}]{$data->forkee->owner->login}[/url] forked [url={$data->repository->html_url}]{$data->repository->full_name}[/url] :omg:";
- break;
-
- case 'issues':
- if(!in_array($data->action, ['opened', /*'edited',*/ 'closed', 'reopened']))
- break;
-
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url] [url={$data->sender->html_url}]{$data->sender->login}[/url] {$data->action} issue [/b][url={$data->issue->html_url}][b]#{$data->issue->number}[/b]: {$data->issue->title}[/url]";
- break;
-
- case 'pull_request':
- if(!in_array($data->action, ['opened', /*'edited',*/ 'closed', 'reopened']))
- break;
-
- $message = "[b][url={$data->repository->html_url}]{$data->repository->full_name}[/url]: [url={$data->sender->html_url}]{$data->sender->login}[/url] {$data->action} pull request [/b][url={$data->pull_request->html_url}][b]#{$data->pull_request->number}[/b]: {$data->pull_request->title}[/url]";
- break;
-}
-
-if(!empty($message)) {
- // the
- var_dump($message);
- $sock = fsockopen($config['boat']['host'], $config['boat']['port'], $errno, $errstr, 5);
- $message = chr(1) . '/msg flash ' . $message;
- $message = hash_hmac('sha256', $message, $config['boat']['secret'], true) . $message;
- fwrite($sock, $message);
-}
diff --git a/public/index.php b/public/index.php
index 5678434..60af270 100644
--- a/public/index.php
+++ b/public/index.php
@@ -1,50 +1,21 @@
-
-
-
-
- Cannot find page
-
-
-
-
-
-
- The page cannot be found
-
-
- The page you are looking for might have been removed, had its
- name changed, or is temporarily unavailable.
-
-
-
- Please try the following:
-
-
- -
- If you typed the page address in the Address bar, make
- sure that it is spelled correctly.
-
- -
- Open the flashii.net
- home page, and then look for links to the information you want.
-
- -
- Click the
Back
- button to try another link.
-
- -
- Click
Search
- to look for information on the Internet.
-
-
-
- HTTP 404 - File not found
-
- Internet Explorer
-
-
-
-
\ No newline at end of file
+namespace Satori;
+
+require_once __DIR__ . '/../satori.php';
+
+set_exception_handler(function(\Throwable $ex) {
+ http_response_code(500);
+ ob_clean();
+
+ if(SAT_DEBUG) {
+ header('Content-Type: text/plain; charset=utf-8');
+ echo (string)$ex;
+ exit;
+ }
+
+ header('Content-Type: text/html; charset=utf-8');
+ echo file_get_contents(SAT_DIR_PUBLIC . '/404.html');
+ exit;
+});
+
+$sat->createRouting()->dispatch();
diff --git a/public/splatoon.php b/public/splatoon.php
deleted file mode 100644
index 53a2f3a..0000000
--- a/public/splatoon.php
+++ /dev/null
@@ -1,1018 +0,0 @@
- 'eu', 'JP' => 'jp', 'US' => 'na',
-]);
-define('SP2_FEST_REGIONS_ALIAS_REVERSE', [
- 'eu' => 'EU', 'jp' => 'JP', 'na' => 'US',
-]);
-define('SP2_LOCALES_ALIAS', [
- 'en-US' => 'en', 'en-GB' => 'en',
- 'es-ES' => 'es', 'es-MX' => 'es',
- 'fr-FR' => 'fr', 'fr-CA' => 'fr',
- 'de-DE' => 'de', 'nl-NL' => 'nl',
- 'it-IT' => 'it', 'ru-RU' => 'ru',
- 'ja-JP' => 'ja',
-]);
-
-define('SP_GAME_SP3', 's3');
-define('SP_GAME_SP2', 's2');
-define('SP_GAMES', [SP_GAME_SP3, SP_GAME_SP2]);
-
-define('SP_GAME_INFOS', [
- SP_GAME_SP3 => [
- 'name' => 'Splatoon 3',
- 'colour' => 0xFFEFFE65,
- 'variant' => SP_GAME_SP3,
- 'types' => SP3_TYPES,
- 'locales' => SP3_LOCALES,
- 'locale' => SP3_DEFAULT_LOCALE,
- 'localeUrlFormat' => SP3_LOCALE,
- 'schedUrls' => ['all' => SP3_SCHED],
- 'schedFilters' => SP3_SCHED_FILTER,
- 'festUrl' => SP3_FESTS,
- 'festRegions' => SP3_FEST_REGIONS,
- ],
- SP_GAME_SP2 => [
- 'name' => 'Splatoon 2',
- 'colour' => 0xFFFF4506,
- 'variant' => SP_GAME_SP2,
- 'types' => SP2_TYPES,
- 'locales' => SP2_LOCALES,
- 'localeAliases' => SP2_LOCALES_ALIAS,
- 'locale' => SP2_DEFAULT_LOCALE,
- 'localeUrlFormat' => SP2_LOCALE,
- 'schedUrls' => ['vs' => SP2_SCHED, 'co' => SP2_COOP_SCHED],
- 'schedFilters' => SP2_SCHED_FILTER,
- 'festUrl' => SP2_FESTS,
- 'festRegions' => SP2_FEST_REGIONS,
- 'festRegionAliases' => SP2_FEST_REGIONS_ALIAS,
- 'festRegionAliasesReverse' => SP2_FEST_REGIONS_ALIAS_REVERSE,
- ],
-]);
-
-function httpRequest(string $url): mixed {
- $curl = curl_init($url);
- curl_setopt_array($curl, [
- CURLOPT_AUTOREFERER => true,
- CURLOPT_CERTINFO => false,
- CURLOPT_FAILONERROR => false,
- CURLOPT_FOLLOWLOCATION => true,
- CURLOPT_MAXREDIRS => 5,
- CURLOPT_PATH_AS_IS => true,
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_TCP_FASTOPEN => true,
- CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
- CURLOPT_CONNECTTIMEOUT => 2,
- CURLOPT_TIMEOUT => 5,
- CURLOPT_USERAGENT => 'Satori/20230203 (+https://fii.moe/satori)',
- CURLOPT_HTTPHEADER => ['Accept: application/json'],
- ]);
- $response = json_decode(curl_exec($curl));
- curl_close($curl);
-
- return $response;
-}
-
-function cached(string $name, callable $match, callable $create): mixed {
- $fileName = sys_get_temp_dir() . '/satori-splatoon-' . $name . '.tmp';
-
- if(is_file($fileName) && $match(filemtime($fileName), $fileName))
- return unserialize(file_get_contents($fileName));
-
- $output = $create();
- file_put_contents($fileName, serialize($output));
- return $output;
-}
-
-function cache_lifetime_15min($mtime): bool {
- return is_int($mtime)
- && floor($mtime / 900) >= floor(time() / 900);
-}
-
-function splatoon2_format_date(int $time): string {
- return str_replace('+00:00', 'Z', date(DATE_ISO8601_EXPANDED, $time));
-}
-
-function splatoon2_translate_ruleset(string $id): string {
- if($id === 'tower_control')
- return 'LOFT';
- if($id === 'splat_zones')
- return 'AREA';
- if($id === 'clam_blitz')
- return 'CLAM';
- if($id === 'rainmaker')
- return 'GOAL';
- if($id === 'turf_war')
- return 'TURF_WAR';
- return $id;
-}
-
-function splatoon2_get_mode_colour(string $id): int {
- if($id === 'regular')
- return 0xFF19D719;
- if($id === 'gachi')
- return 0xFFF54910;
- if($id === 'league')
- return 0xFFF02D7D;
- if($id === 'coop')
- return 0xFFFF5600;
- return 0xFFFFFFFF;
-}
-
-function splatoon2_ruleset_short(string $id): string {
- if($id === 'tower_control')
- return 'TC';
- if($id === 'splat_zones')
- return 'SZ';
- if($id === 'clam_blitz')
- return 'CB';
- if($id === 'rainmaker')
- return 'RM';
- if($id === 'turf_war')
- return 'TW';
- if($id === 'coop')
- return 'SR';
- return $id;
-}
-
-function splatoon3_ruleset_short(string $id): string {
- if($id === 'LOFT')
- return 'TC';
- if($id === 'AREA')
- return 'SZ';
- if($id === 'CLAM')
- return 'CB';
- if($id === 'GOAL')
- return 'RM';
- if($id === 'TURF_WAR')
- return 'TW';
- if($id === 'TEAM_CONTEST')
- return 'EW';
- if($id === 'coop')
- return 'SR';
- if($id === 'bigrun')
- return 'BR';
- return $id;
-}
-date_default_timezone_set('utc');
-header('Content-Type: application/json; charset=utf-8');
-
-$gameId = (string)filter_input(INPUT_GET, 'g');
-if(!in_array($gameId, SP_GAMES))
- die('{"error":"game:id"}');
-
-$gameInfo = SP_GAME_INFOS[$gameId] ?? null;
-if(!isset($gameInfo))
- die('{"error":"game:info"}');
-
-$typeId = (string)filter_input(INPUT_GET, 't');
-if(empty($typeId) || !in_array($typeId, $gameInfo['types']))
- die('{"error":"type:id"}');
-
-$localeId = (string)filter_input(INPUT_GET, 'l');
-if(empty($localeId))
- $localeId = $gameInfo['locale'];
-elseif(!in_array($localeId, $gameInfo['locales'])) {
- if(isset($gameInfo['localeAliases']) && array_key_exists($localeId, $gameInfo['localeAliases']))
- $localeId = $gameInfo['localeAliases'][$localeId];
- else die('{"error":"locale:id"}');
-}
-
-$localeInfo = cached(sprintf('%s-locale-%s', $gameId, $localeId), 'cache_lifetime_15min', function() use($gameInfo, $localeId) {
- if(empty($gameInfo['localeUrlFormat']))
- die('{"error":"locale:url"}');
-
- $localeInfo = httpRequest(sprintf($gameInfo['localeUrlFormat'], $localeId));
- if(!is_object($localeInfo))
- die('{"error":"locale:info"}');
-
- return $localeInfo;
-});
-
-if($typeId === SP_TYPE_SCHED) { // SCHEDULE SHIT
- $filters = array_filter(explode(',', (string)filter_input(INPUT_GET, 'sf')));
- $filterAll = empty($filters);
- foreach($filters as $filter)
- if(!in_array($filter, $gameInfo['schedFilters']))
- die('{"error":"schedules:filter"}');
-
- $schedRaw = (object)cached(sprintf('%s-sched', $gameId), 'cache_lifetime_15min', function() use($gameInfo) {
- if(empty($gameInfo['schedUrls']))
- die('{"error":"schedules:url"}');
-
- $output = [];
- foreach($gameInfo['schedUrls'] as $name => $url)
- $output[$name] = httpRequest($url);
-
- return $output;
- });
-
- $gameModes = [];
- $schedules = [];
- $currentFest = null;
-
- if($gameInfo['variant'] === SP_GAME_SP3) {
- $data = $schedRaw->all->data;
-
- $includeRegular = $filterAll || in_array('regular', $filters);
- $includeSeries = $filterAll || in_array('series', $filters);
- $includeOpen = $filterAll || in_array('open', $filters);
- $includeRanked = $filterAll || in_array('bankara', $filters);
- $includeX = $filterAll || in_array('x', $filters);
- $includeLeague = $filterAll || in_array('league', $filters);
- $includeSalmon = $filterAll || in_array('coop', $filters);
- $includeEggstra = $filterAll || in_array('eggstra', $filters);
- $includeBigRun = $filterAll || in_array('bigrun', $filters);
- $includeFest = $filterAll || in_array('fest', $filters);
- $includeSeries = $includeSeries || $includeRanked;
- $includeOpen = $includeOpen || $includeRanked;
- $includeRanked = $includeSeries || $includeOpen;
-
- function splatoon3_schedule_gen($target, $node): void {
- $target->periods = [[
- 'start' => $node->startTime,
- 'end' => $node->endTime,
- ]];
- }
-
- function splatoon3_schedule_vs($target, $settings): void {
- global $localeInfo;
-
- $target->variant = 'vs';
- $target->flags = [];
-
- $target->ruleset = [
- 'id' => $settings->vsRule->rule,
- 'name' => $localeInfo->rules->{$settings->vsRule->id}?->name ?? $settings->vsRule->name,
- 'short' => splatoon3_ruleset_short($settings->vsRule->rule),
- ];
-
- $target->stages = [];
- foreach($settings->vsStages as $stage)
- $target->stages[] = [
- 'id' => (string)$stage->vsStageId,
- 'name' => $localeInfo->stages->{$stage->id}?->name ?? $stage->name,
- 'image' => $stage->image->url,
- ];
- }
-
- function splatoon3_schedule_periods($target, $periods): void {
- $target->periods = [];
- foreach($periods as $period)
- $target->periods[] = [
- 'start' => $period->startTime,
- 'end' => $period->endTime,
- ];
- }
-
- if($includeRegular && !empty($data->regularSchedules->nodes)) {
- $schedule = [];
-
- foreach($data->regularSchedules->nodes as $node) {
- if(empty($node->regularMatchSetting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- splatoon3_schedule_vs($item, $node->regularMatchSetting);
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'regular',
- 'name' => 'Regular Battle',
- 'colour' => 0xFF19D719,
- ];
-
- $schedules[] = [
- 'mode' => 'regular',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeRanked && !empty($data->bankaraSchedules->nodes)) {
- $series = [];
- $open = [];
-
- foreach($data->bankaraSchedules->nodes as $node) {
- if(empty($node->bankaraMatchSettings))
- continue;
-
- foreach($node->bankaraMatchSettings as $settings) {
- if($settings->mode === 'CHALLENGE') {
- $series[] = $item = new stdClass;
- } elseif($settings->mode === 'OPEN') {
- $open[] = $item = new stdClass;
- } else continue;
-
- splatoon3_schedule_gen($item, $node);
- splatoon3_schedule_vs($item, $settings);
- }
- }
-
- if($includeSeries && !empty($series)) {
- if(empty($gameModes['bankara']))
- $gameModes['bankara'] = [
- 'id' => 'bankara',
- 'name' => 'Anarchy Battle',
- 'colour' => 0xFFF54910,
- 'variants' => [],
- ];
- $gameModes['bankara']['variants'][] = [
- 'id' => 'CHALLENGE',
- 'name' => 'Series',
- 'colour' => 0xFF603BFF,
- ];
-
- $schedules[] = [
- 'mode' => 'bankara',
- 'variant' => 'CHALLENGE',
- 'schedule' => $series,
- ];
- }
-
- if($includeOpen && !empty($open)) {
- if(empty($gameModes['bankara']))
- $gameModes['bankara'] = [
- 'id' => 'bankara',
- 'name' => 'Anarchy Battle',
- 'colour' => 0xFFF54910,
- 'variants' => [],
- ];
- $gameModes['bankara']['variants'][] = [
- 'id' => 'OPEN',
- 'name' => 'Open',
- 'colour' => 0xFF603BFF,
- ];
-
- $schedules[] = [
- 'mode' => 'bankara',
- 'variant' => 'OPEN',
- 'schedule' => $open,
- ];
- }
- }
-
- if($includeX && !empty($data->xSchedules->nodes)) {
- $schedule = [];
-
- foreach($data->xSchedules->nodes as $node) {
- if(empty($node->xMatchSetting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- splatoon3_schedule_vs($item, $node->xMatchSetting);
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'x',
- 'name' => 'X Battle',
- 'colour' => 0xFF0FDB9B,
- ];
-
- $schedules[] = [
- 'mode' => 'x',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeLeague && !empty($data->eventSchedules->nodes)) {
- $schedule = [];
-
- foreach($data->eventSchedules->nodes as $node) {
- if(empty($node->leagueMatchSetting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_periods($item, $node->timePeriods);
- splatoon3_schedule_vs($item, $node->leagueMatchSetting);
- $item->flags[] = 'league';
-
- if(!empty($node->leagueMatchSetting->leagueMatchEvent)) {
- $event = $node->leagueMatchSetting->leagueMatchEvent;
- $eventLocale = $localeInfo->events->{$event->id};
-
- $item->league = [
- 'id' => $event->leagueMatchEventId,
- 'name' => $eventLocale->name ?? $event->name,
- 'desc' => $eventLocale->desc ?? $event->desc,
- 'rules' => [
- 'lines' => explode('
', $eventLocale->regulation ?? $event->regulation),
- 'url' => $event->regulationUrl,
- ],
- ];
- }
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'league',
- 'name' => 'Challenge Battle',
- 'colour' => 0xFFF02D7D,
- ];
-
- $schedules[] = [
- 'mode' => 'league',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeFest && !empty($data->festSchedules->nodes)) {
- $schedule = [];
-
- foreach($data->festSchedules->nodes as $node) {
- if(empty($node->festMatchSetting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- splatoon3_schedule_vs($item, $node->festMatchSetting);
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'fest',
- 'name' => 'Splatfest Battle',
- 'colour' => 0xFF71717A,
- ];
-
- $schedules[] = [
- 'mode' => 'fest',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeSalmon && !empty($data->coopGroupingSchedule->regularSchedules->nodes)) {
- $schedule = [];
-
- $nodes = $data->coopGroupingSchedule->regularSchedules->nodes;
- foreach($nodes as $node) {
- if(empty($node->setting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- $item->variant = 'coop';
-
- $item->stages = [
- [
- 'name' => $node->setting->coopStage->name,
- 'image' => $node->setting->coopStage->image->url,
- ],
- ];
-
- $item->weapons = [];
- foreach($node->setting->weapons as $weapon)
- $item->weapons[] = [
- 'name' => $localeInfo->weapons->{$weapon->__splatoon3ink_id}?->name ?? $weapon->name,
- 'image' => $weapon->image->url,
- ];
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'coop',
- 'name' => 'Salmon Run',
- 'colour' => 0xFFFF5600,
- ];
-
- $schedules[] = [
- 'mode' => 'coop',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeEggstra && !empty($data->coopGroupingSchedule->teamContestSchedules->nodes)) {
- $schedule = [];
-
- $nodes = $data->coopGroupingSchedule->teamContestSchedules->nodes;
- foreach($nodes as $node) {
- if(empty($node->setting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- $item->variant = 'coop';
-
- $item->stages = [
- [
- 'name' => $node->setting->coopStage->name,
- 'image' => $node->setting->coopStage->image->url,
- ],
- ];
-
- $item->weapons = [];
- foreach($node->setting->weapons as $weapon)
- $item->weapons[] = [
- 'name' => $localeInfo->weapons->{$weapon->__splatoon3ink_id}?->name ?? $weapon->name,
- 'image' => $weapon->image->url,
- ];
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'eggstra',
- 'name' => 'Eggstra Work',
- 'colour' => 0xFFBE8800,
- ];
-
- $schedules[] = [
- 'mode' => 'eggstra',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if($includeBigRun && !empty($data->coopGroupingSchedule->bigRunSchedules->nodes)) {
- $schedule = [];
-
- $nodes = $data->coopGroupingSchedule->bigRunSchedules->nodes;
- foreach($nodes as $node) {
- if(empty($node->setting))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon3_schedule_gen($item, $node);
- $item->variant = 'coop';
-
- $item->stages = [
- [
- 'name' => $node->setting->coopStage->name,
- 'image' => $node->setting->coopStage->image->url,
- ],
- ];
-
- $item->weapons = [];
- foreach($node->setting->weapons as $weapon)
- $item->weapons[] = [
- 'name' => $localeInfo->weapons->{$weapon->__splatoon3ink_id}?->name ?? $weapon->name,
- 'image' => $weapon->image->url,
- ];
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'bigrun',
- 'name' => 'Big Run',
- 'colour' => 0xFFB322FF,
- ];
-
- $schedules[] = [
- 'mode' => 'bigrun',
- 'schedule' => $schedule,
- ];
- }
- }
-
- if(!empty($data->currentFest->id)) {
- $currentFest = new stdClass;
- $currentFest->id = $data->currentFest->id;
- $currentFest->title = $data->currentFest->title;
- $currentFest->start = $data->currentFest->startTime;
- $currentFest->end = $data->currentFest->endTime;
- $currentFest->midterm = $data->currentFest->midtermTime;
- $currentFest->state = $data->currentFest->state;
- $currentFest->teams = [];
-
- if($includeFest && !empty($data->currentFest->tricolorStage)) {
- $gameModes[] = [
- 'id' => 'tricolor',
- 'name' => 'Tricolor Battle',
- 'colour' => 0xFF71717A,
- ];
-
- $schedules[] = [
- 'mode' => 'tricolor',
- 'schedule' => [
- [
- 'start' => splatoon2_format_date(strtotime('today')),
- 'end' => splatoon2_format_date(strtotime('tomorrow')),
- 'variant' => 'vs',
- 'ruleset' => [
- 'id' => 'TRICOLOR_TURF_WAR',
- 'name' => 'Tricolor Turf War',
- 'short' => 'TTW',
- ],
- 'stages' => [
- [
- 'id' => $data->currentFest->tricolorStage->id,
- 'name' => $data->currentFest->tricolorStage->name,
- 'image' => $data->currentFest->tricolorStage->image->url,
- ],
- ],
- ],
- ],
- ];
- }
-
- foreach($data->currentFest->teams as $rawTeam) {
- $currentFest->teams[] = $teamInfo = new stdClass;
- $teamInfo->id = $rawTeam->id;
- $teamInfo->colour = (round($rawTeam->color->a * 0xFF) << 24)
- | (round($rawTeam->color->r * 0xFF) << 16)
- | (round($rawTeam->color->g * 0xFF) << 8)
- | round($rawTeam->color->b * 0xFF);
- }
- }
- } elseif($gameInfo['variant'] === SP_GAME_SP2) {
- $vs = $schedRaw->vs;
- $coop = $schedRaw->co;
-
- $includeRegular = $filterAll || in_array('regular', $filters);
- $includeRanked = $filterAll || in_array('gachi', $filters);
- $includeLeague = $filterAll || in_array('league', $filters);
- $includeSalmon = $filterAll || in_array('coop', $filters);
-
- function splatoon2_schedule_gen($target, $node): void {
- $target->periods = [[
- 'start' => splatoon2_format_date($node->start_time),
- 'end' => splatoon2_format_date($node->end_time),
- ]];
- }
-
- function splatoon2_schedule_vs($target, $node): void {
- global $localeInfo, $gameModes;
-
- if(!array_key_exists($node->game_mode->key, $gameModes))
- $gameModes[$node->game_mode->key] = [
- 'id' => $node->game_mode->key,
- 'name' => $localeInfo->game_modes->{$node->game_mode->key}?->name ?? $node->game_mode->name,
- 'colour' => splatoon2_get_mode_colour($node->game_mode->key),
- ];
-
- $target->variant = 'vs';
-
- $target->ruleset = [
- 'id' => splatoon2_translate_ruleset($node->rule->key),
- 'name' => $localeInfo->rules->{$node->rule->key}?->name ?? $node->rule->name,
- 'short' => splatoon2_ruleset_short($node->rule->key),
- ];
-
- $target->stages = [];
- foreach(['stage_a', 'stage_b', 'stage_c'] as $stage) {
- $stage = $node->{$stage} ?? null;
- if(empty($stage))
- continue;
-
- $target->stages[] = [
- 'id' => $stage->id,
- 'name' => $localeInfo->stages->{$stage->id}?->name ?? $stage->name,
- 'image' => sprintf(SP2_IMAGE_FORMAT, $stage->image),
- ];
- }
- }
-
- if($includeRegular && !empty($vs->regular)) {
- $schedule = [];
-
- foreach($vs->regular as $node) {
- if(empty($node->rule))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon2_schedule_gen($item, $node);
- splatoon2_schedule_vs($item, $node);
- }
-
- if(!empty($schedule))
- $schedules[] = [
- 'mode' => 'regular',
- 'schedule' => $schedule,
- ];
- }
-
- if($includeRanked && !empty($vs->gachi)) {
- $schedule = [];
-
- foreach($vs->gachi as $node) {
- if(empty($node->rule))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon2_schedule_gen($item, $node);
- splatoon2_schedule_vs($item, $node);
- }
-
- if(!empty($schedule))
- $schedules[] = [
- 'mode' => 'gachi',
- 'schedule' => $schedule,
- ];
- }
-
- if($includeLeague && !empty($vs->league)) {
- $schedule = [];
-
- foreach($vs->league as $node) {
- if(empty($node->rule))
- continue;
-
- $schedule[] = $item = new stdClass;
- splatoon2_schedule_gen($item, $node);
- splatoon2_schedule_vs($item, $node);
- }
-
- if(!empty($schedule))
- $schedules[] = [
- 'mode' => 'league',
- 'schedule' => $schedule,
- ];
- }
-
- if($includeSalmon && !empty($coop->schedules)) {
- $schedule = [];
-
- foreach($coop->schedules as $key => $node) {
- $schedule[] = $item = new stdClass;
- splatoon2_schedule_gen($item, $node);
- $item->variant = 'coop';
-
- $details = $coop->details[$key] ?? null;
- if(!empty($details)) {
- $item->stages = [
- [
- 'name' => $details->stage->name,
- 'image' => sprintf(SP2_IMAGE_FORMAT, $details->stage->image),
- ],
- ];
-
-
- $item->weapons = [];
- foreach($details->weapons as $weapon) {
- if($weapon->id === '-1') {
- $weaponImage = $weapon->coop_special_weapon->image;
- $weaponName = $localeInfo->coop_special_weapons->{$weaponImage}?->name ?? $weapon->coop_special_weapon->name;
- } else {
- $weaponName = $localeInfo->weapons->{$weapon->weapon->id}?->name ?? $weapon->weapon->name;
- $weaponImage = $weapon->weapon->image;
- }
-
- $item->weapons[] = [
- 'id' => $weapon->id,
- 'name' => $weaponName,
- 'image' => sprintf(SP2_IMAGE_FORMAT, $weaponImage),
- ];
- }
- }
- }
-
- if(!empty($schedule)) {
- $gameModes[] = [
- 'id' => 'coop',
- 'name' => 'Salmon Run',
- 'colour' => splatoon2_get_mode_colour('coop'),
- ];
-
- $schedules[] = [
- 'mode' => 'coop',
- 'schedule' => $schedule,
- ];
- }
- }
- } else die('{"error":"schedules:variant"}');
-
- echo json_encode([
- 'game' => [
- 'id' => $gameId,
- 'name' => $gameInfo['name'],
- 'colour' => $gameInfo['colour'],
- 'variant' => $gameInfo['variant'],
- ],
- 'fest' => $currentFest,
- 'modes' => array_values($gameModes),
- 'schedules' => $schedules,
- ]);
- exit;
-}
-
-if($typeId === SP_TYPE_FESTS) { // SPLATFEST SHIT
- $regions = array_filter(explode(',', (string)filter_input(INPUT_GET, 'fr')));
- $regionsAll = empty($regions);
- foreach($regions as $key => $region) {
- if(!empty($gameInfo['festRegionAliases']) && array_key_exists($region, $gameInfo['festRegionAliases']))
- $region = $gameInfo['festRegionAliases'][$region];
- if(!in_array($region, $gameInfo['festRegions']))
- die('{"error":"fests:region"}');
- $regions[$key] = $region;
- }
-
- if($regionsAll)
- $regions = $gameInfo['festRegions'];
-
- $festsRaw = (object)cached(sprintf('%s-fests', $gameId), 'cache_lifetime_15min', function() use($gameInfo) {
- if(empty($gameInfo['festUrl']))
- die('{"error":"fests:url"}');
-
- return httpRequest($gameInfo['festUrl']);
- });
-
- $fests = [];
-
- if($gameInfo['variant'] === SP_GAME_SP3) {
- foreach($regions as $region) {
- if(empty($festsRaw->{$region}->data->festRecords->nodes))
- continue;
- $nodes = $festsRaw->{$region}->data->festRecords->nodes;
-
- if(!empty($gameInfo['festRegionAliasesReverse']) && array_key_exists($region, $gameInfo['festRegionAliasesReverse']))
- $region = $gameInfo['festRegionAliasesReverse'][$region];
-
- foreach($nodes as $node) {
- $festId = $node->image->url;
- if(empty($fests[$festId]))
- $fests[$festId] = [
- 'regions' => [],
- ];
-
- $fests[$festId]['regions'][] = $festInfo = new stdClass;
- $festInfo->id = $node->id;
- $festInfo->region = $region;
- $festInfo->state = $node->state;
- $festInfo->start = $node->startTime;
- $festInfo->end = $node->endTime;
- $festInfo->title = $node->title;
- $festInfo->image = $node->image->url;
- $festInfo->can_vote = $node->isVotable;
- $festInfo->teams = [];
-
- foreach($node->teams as $rawTeam) {
- $festInfo->teams[] = $team = new stdClass;
- $team->id = $rawTeam->id;
- $team->name = $rawTeam->teamName;
- $team->image = $rawTeam->image->url;
- $team->colour = (round($rawTeam->color->a * 0xFF) << 24)
- | (round($rawTeam->color->r * 0xFF) << 16)
- | (round($rawTeam->color->g * 0xFF) << 8)
- | round($rawTeam->color->b * 0xFF);
-
- $team->has_results = !empty($rawTeam->result);
- if(!$team->has_results)
- continue;
-
- $team->is_winner = $rawTeam->result->isWinner;
- $team->results = [];
-
- foreach(['horagai', 'vote', 'regularContribution', 'challengeContribution', 'tricolorContribution'] as $rateType) {
- if(!isset($rawTeam->result->{$rateType . 'Ratio'}))
- continue;
-
- $rateCat = $rateType;
- if(str_ends_with($rateCat, 'Contribution'))
- $rateCat = substr($rateCat, 0, -12);
-
- $team->results[] = [
- 'category' => $rateCat,
- 'ratio' => $rawTeam->result->{$rateType . 'Ratio'},
- 'is_top' => $rawTeam->result->{'is' . ucfirst($rateType) . 'RatioTop'},
- ];
- }
- }
- }
- }
- } elseif($gameInfo['variant'] === SP_GAME_SP2) {
- foreach($regions as $region) {
- if(empty($festsRaw->{$region}->festivals))
- continue;
- $festivals = $festsRaw->{$region}->festivals;
- $results = $festsRaw->{$region}->results;
-
- if(!empty($gameInfo['festRegionAliasesReverse']) && array_key_exists($region, $gameInfo['festRegionAliasesReverse']))
- $region = $gameInfo['festRegionAliasesReverse'][$region];
-
- foreach($festivals as $festId => $festRaw) {
- if(empty($fests[$festId]))
- $fests[$festId] = [
- 'regions' => [],
- ];
-
- $festNames = $localeInfo->festivals->{$festRaw->festival_id}->names ?? null;
- if(empty($festNames))
- continue;
-
- foreach($results as $findResultRaw)
- if($findResultRaw->festival_id === $festRaw->festival_id) {
- $resultsRaw = $findResultRaw;
- break;
- }
-
- $time = time();
- $state = '';
- if($time > $festRaw->times->end)
- $state = 'CLOSED';
- elseif($time > $festRaw->times->start)
- $state = 'OPEN'; // this is an assumption
- else
- $state = 'SCHEDULED';
-
- $fests[$festId]['regions'][] = $festInfo = new stdClass;
- $festInfo->id = base64_encode(sprintf('FestTeam-%s:JEA-%05d', $region, $festRaw->festival_id));
- $festInfo->region = $region;
- $festInfo->state = $state;
- $festInfo->start = splatoon2_format_date($festRaw->times->start);
- $festInfo->end = splatoon2_format_date($festRaw->times->end);
- $festInfo->title = sprintf('%s vs %s', $festNames->alpha_short, $festNames->bravo_short);
- $festInfo->image = sprintf(SP2_IMAGE_FORMAT, $festRaw->images->panel);
- $festInfo->can_vote = $state !== 'CLOSED';
- $festInfo->teams = [];
- $festInfo->special_stages = [
- [
- 'id' => $festRaw->special_stage->id,
- 'name' => $localeInfo->stages->{$festRaw->special_stage->id}?->name ?? $festRaw->special_stage->name,
- 'image' => sprintf(SP2_IMAGE_FORMAT, $festRaw->special_stage->image),
- ],
- ];
-
- $tmpResults = [];
-
- foreach(['alpha', 'bravo'] as $teamId => $teamName) {
- $festInfo->teams[] = $team = new stdClass;
- $team->id = base64_encode(sprintf('FestTeam-%s:JEA-%05d:%s', $region, $festInfo->id, ucfirst($teamName)));
- $team->name = $festNames->{$teamName . '_long'};
- $team->image = sprintf(SP2_IMAGE_FORMAT, $festRaw->images->{$teamName});
-
- $rawColour = $festRaw->colors->{$teamName};
- $team->colour = (round($rawColour->a * 0xFF) << 24)
- | (round($rawColour->r * 0xFF) << 16)
- | (round($rawColour->g * 0xFF) << 8)
- | round($rawColour->b * 0xFF);
-
- $team->has_results = !empty($resultsRaw);
- if(!$team->has_results)
- continue;
-
- $team->is_winner = $resultsRaw->summary->total === $teamId;
- $team->results = [];
- foreach(['challenge', 'vote', 'regular', 'solo', 'team'] as $rateType) {
- $rawRate = $resultsRaw->rates->{$rateType}->{$teamName} ?? null;
- if(empty($rawRate))
- continue;
-
- if(!isset($tmpResults[$rateType]))
- $tmpResults[$rateType] = $rawRate > $resultsRaw->rates->{$rateType}->{'bravo'} ?? 0;
-
- $team->results[] = [
- 'category' => $rateType,
- 'ratio' => $rawRate / 10000,
- 'is_top' => $tmpResults[$rateType],
- ];
-
- $tmpResults[$rateType] = !$tmpResults[$rateType];
- }
- }
-
- unset($tmpResults);
- }
- }
- } else die('{"error":"fests:variant"}');
-
- echo json_encode([
- 'game' => [
- 'id' => $gameId,
- 'name' => $gameInfo['name'],
- 'colour' => $gameInfo['colour'],
- 'variant' => $gameInfo['variant'],
- ],
- 'fests' => array_values($fests),
- ]);
- exit;
-}
diff --git a/public/translate.php b/public/translate.php
deleted file mode 100644
index a2f6d90..0000000
--- a/public/translate.php
+++ /dev/null
@@ -1,150 +0,0 @@
-\r\n";
-
- switch ($mode) {
- case 'list':
- $type = get_class($arr[0]);
- $output .= "";
- foreach ($arr as $part) {
- $output .= "<{$type} ";
-
- foreach (get_object_vars($part) as $name => $value) {
- $output .= "{$name}=\"{$value}\" ";
- }
-
- $output .= '/>';
- }
- $output .= "";
- break;
-
- case 'do':
- $output .= " $value) {
- $value = htmlspecialchars($value);
- $output .= "{$name}=\"{$value}\" ";
- }
-
- $output .= '/>';
- break;
- }
- break;
-
- case 'json':
- default:
- $mimeType = 'application/json';
- $output = json_encode($arr, JSON_ARGS);
- break;
- }
-
- header("Content-Type: {$mimeType}; charset=utf-8");
- die($output);
-}
-
-define('TRANSLATE_URL', 'https://translate.google.com/translate_a/single?ie=UTF-8&oe=UTF-8&multires=1&client=gtx&sl=%s&tl=%s&dt=t&q=%s');
-define('CURL_USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/109.0');
-define('MAX_TRANSL_LENGTH', 630);
-
-function curl_req($url) {
- $curl = curl_init($url);
-
- curl_setopt_array($curl, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_SSL_VERIFYPEER => false,
- CURLOPT_SSL_VERIFYHOST => false,
-
- CURLOPT_USERAGENT => CURL_USER_AGENT,
- ]);
-
- return curl_exec($curl);
-}
-
-class Language {
- public $code;
- public $name;
-
- public function __construct($code, $name) {
- $this->code = $code;
- $this->name = $name;
- }
-
- public static function fromCode($code) {
- return new Language($code, locale_get_display_name($code));
- }
-}
-
-$languages = [
- 'af', 'sq', 'ar', 'hy', 'az', 'eu', 'be', 'bn', 'bs',
- 'bg', 'ca', 'ny', 'co', 'hr', 'cs', 'da', 'nl', 'en',
- 'eo', 'tl', 'fi', 'fr', 'fy', 'gl', 'ka', 'de', 'el',
- 'gu', 'ht', 'ha', 'iw', 'hi', 'hu', 'is', 'ig', 'id',
- 'ga', 'ja', 'jw', 'kn', 'kk', 'km', 'ko', 'ku', 'ky',
- 'lo', 'la', 'lv', 'lt', 'lb', 'mk', 'mg', 'ms', 'ml',
- 'mt', 'mi', 'mr', 'mn', 'my', 'ne', 'no', 'ps', 'fa',
- 'pl', 'pt', 'ma', 'ro', 'ru', 'sm', 'gd', 'sr', 'st',
- 'sn', 'sd', 'si', 'sk', 'sl', 'so', 'es', 'su', 'sw',
- 'sv', 'tg', 'ta', 'te', 'th', 'tr', 'uk', 'ur', 'uz',
- 'vi', 'cy', 'xh', 'yi', 'yo', 'zu', 'it',
- 'ceb', 'haw', 'hmn',
- 'zh-cn', 'zh-tw',
-];
-
-$mode = $_GET['m'] ?? null;
-
-switch ($mode) {
- case 'list':
- $out = [];
-
- foreach ($languages as $lang)
- $out[] = Language::fromCode($lang);
-
- out($out, $mode);
-
- case 'do':
- $from = strtolower($_GET['f'] ?? 'auto');
- $to = strtolower($_GET['t'] ?? 'en');
-
- if ($from !== 'auto' && !in_array($from, $languages))
- out(['error' => 'The given origin language is invalid.'], $mode);
-
- if (!in_array($to, $languages))
- out(['error' => 'The given destination language is invalid.'], $mode);
-
- $text = isset($_GET['z']) ? $_GET['z'] : file_get_contents('php://input');
- $t_len = strlen($text);
-
- if ($t_len < 1)
- out(['error' => 'Nothing to translate.'], $mode);
-
- if ($t_len > MAX_TRANSL_LENGTH)
- out(['error' => 'Too much text, please split some up.'], $mode);
-
- $google_txt = curl_req(sprintf(TRANSLATE_URL, $from, $to, rawurlencode($text)));
-
- $google = json_decode($google_txt);
-
- if (strlen($google_txt) < 1 || !$google)
- out(['error' => 'Something happened.'], $mode);
-
- $result = $google[0][0][0];
- $original = $google[0][0][1];
- $from = $google[2];
- $from_text = locale_get_display_name($from);
- $to_text = locale_get_display_name($to);
-
- out(compact('from', 'from_text', 'to', 'to_text', 'original', 'result'), $mode);
-}
diff --git a/public/word-define.php b/public/word-define.php
deleted file mode 100644
index a336938..0000000
--- a/public/word-define.php
+++ /dev/null
@@ -1,39 +0,0 @@
-text))
- continue;
-
- $defs[] = [
- 'word' => $def->word ?? '',
- 'pos' => $def->partOfSpeech ?? '',
- 'attr' => $def->attributionText ?? '',
- 'attr_url' => $def->attributionUrl ?? '',
- 'dict' => $def->sourceDictionary ?? '',
- 'text' => strtr($def->text, [
- '' => '[i]',
- '' => '[/i]',
- ]),
- 'url' => $def->wordnikUrl ?? '',
- ];
-}
-
-echo json_encode($defs);
diff --git a/satori.php b/satori.php
new file mode 100644
index 0000000..8180b6e
--- /dev/null
+++ b/satori.php
@@ -0,0 +1,25 @@
+getString('database:dsn', 'null'));
+$dbc->execute('SET SESSION time_zone = \'+00:00\', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\';');
+
+$sat = new SatoriContext($cfg, $dbc);
diff --git a/src/Booru/BooruContext.php b/src/Booru/BooruContext.php
new file mode 100644
index 0000000..a4b5157
--- /dev/null
+++ b/src/Booru/BooruContext.php
@@ -0,0 +1,30 @@
+sources[] = new Danbooru\DanbooruSource($config->scopeTo('danbooru'));
+ $this->sources[] = new Gelbooru\GelbooruSource($config->scopeTo('gelbooru'));
+ $this->sources[] = new Konachan\KonachanSource($config->scopeTo('konachan'));
+ $this->sources[] = new Yandere\YandereSource($config->scopeTo('yandere'));
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function getSources(): array {
+ return $this->sources;
+ }
+
+ public function getSourceByName(string $name): ?IBooruSource {
+ foreach($this->sources as $source)
+ if($source->getInfo()->getName() === $name)
+ return $source;
+ return null;
+ }
+}
diff --git a/src/Booru/BooruErrorResponse.php b/src/Booru/BooruErrorResponse.php
new file mode 100644
index 0000000..882bea6
--- /dev/null
+++ b/src/Booru/BooruErrorResponse.php
@@ -0,0 +1,16 @@
+code;
+ }
+
+ public function jsonSerialize(): mixed {
+ return ['error' => $this->code];
+ }
+}
diff --git a/src/Booru/BooruQueryResponse.php b/src/Booru/BooruQueryResponse.php
new file mode 100644
index 0000000..42120ec
--- /dev/null
+++ b/src/Booru/BooruQueryResponse.php
@@ -0,0 +1,24 @@
+source;
+ }
+
+ public function getPosts(): array {
+ return $this->posts;
+ }
+
+ public function jsonSerialize(): mixed {
+ return [
+ 'source' => $this->source,
+ 'posts' => $this->posts,
+ ];
+ }
+}
diff --git a/src/Booru/BooruRoutes.php b/src/Booru/BooruRoutes.php
new file mode 100644
index 0000000..b6c87c1
--- /dev/null
+++ b/src/Booru/BooruRoutes.php
@@ -0,0 +1,60 @@
+id = (int)$post->getId();
+ $output->rating = $post->getRating();
+ if($post->hasFileUrl())
+ $output->file_url = $post->getFileUrl();
+ $output->post_url = $post->getPostUrl();
+ $output->tags = $post->getTags();
+
+ if($post instanceof IBooruPostWithSpecificTags) {
+ $output->tags_general = $post->getGeneralTags();
+ $output->tags_character = $post->getCharacterTags();
+ $output->tags_copyright = $post->getCopyrightTags();
+ $output->tags_artist = $post->getArtistTags();
+ $output->tags_meta = $post->getMetaTags();
+ }
+ }
+
+ return $output;
+ });
+ }
+
+ #[Route('GET', '/booru.php')]
+ public function getPHP($response, $request) {
+ $sourceName = (string)$request->getParam('b');
+ $tags = (string)$request->getParam('t');
+
+ if($sourceName === '') {
+ $sources = $this->context->getSources();
+ if(empty($sources))
+ return new BooruErrorResponse('none');
+ $source = $sources[0];
+ } else
+ $source = $this->context->getSourceByName($sourceName);
+
+ if($source === null)
+ return new BooruErrorResponse('booru');
+
+ return new BooruQueryResponse(
+ $source->getInfo(),
+ self::makePostsJsonReady($source->getPosts($tags))
+ );
+ }
+}
diff --git a/src/Booru/BooruSourceInfo.php b/src/Booru/BooruSourceInfo.php
new file mode 100644
index 0000000..9f8cfdb
--- /dev/null
+++ b/src/Booru/BooruSourceInfo.php
@@ -0,0 +1,25 @@
+name;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function jsonSerialize(): mixed {
+ return [
+ 'name' => $this->name,
+ 'type' => $this->name,
+ 'title' => $this->title,
+ ];
+ }
+}
diff --git a/src/Booru/Danbooru/DanbooruPost.php b/src/Booru/Danbooru/DanbooruPost.php
new file mode 100644
index 0000000..86704e9
--- /dev/null
+++ b/src/Booru/Danbooru/DanbooruPost.php
@@ -0,0 +1,52 @@
+info['id'];
+ }
+
+ public function getRating(): string {
+ return $this->info['rating'];
+ }
+
+ public function getPostUrl(): string {
+ return sprintf('https://danbooru.donmai.us/posts/%d', $this->info['id']);
+ }
+
+ public function getTags(): array {
+ return explode(' ', $this->info['tag_string']);
+ }
+
+ public function hasFileUrl(): bool {
+ return array_key_exists('file_url', $this->info);
+ }
+
+ public function getFileUrl(): string {
+ return $this->info['file_url'];
+ }
+
+ public function getGeneralTags(): array {
+ return explode(' ', $this->info['tag_string_general']);
+ }
+
+ public function getCharacterTags(): array {
+ return explode(' ', $this->info['tag_string_character']);
+ }
+
+ public function getCopyrightTags(): array {
+ return explode(' ', $this->info['tag_string_copyright']);
+ }
+
+ public function getArtistTags(): array {
+ return explode(' ', $this->info['tag_string_artist']);
+ }
+
+ public function getMetaTags(): array {
+ return explode(' ', $this->info['tag_string_meta']);
+ }
+}
diff --git a/src/Booru/Danbooru/DanbooruSource.php b/src/Booru/Danbooru/DanbooruSource.php
new file mode 100644
index 0000000..e34d236
--- /dev/null
+++ b/src/Booru/Danbooru/DanbooruSource.php
@@ -0,0 +1,62 @@
+info = new BooruSourceInfo('danbooru', 'Danbooru');
+ }
+
+ public function getInfo(): BooruSourceInfo {
+ return $this->info;
+ }
+
+ public function getPosts(string $tags, bool $random = true, int $limit = 20): array {
+ $params = [];
+ if($tags !== '')
+ $params['tags'] = $tags;
+ if($limit > 0)
+ $params['limit'] = $limit;
+ if($random)
+ $params['random'] = '1';
+
+ $url = self::POSTS_URL;
+ if(!empty($params))
+ $url .= '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+
+ $curl = curl_init($url);
+
+ $headers = ['Accept: application/json'];
+ if($this->config->hasValues('token'))
+ $headers[] = sprintf('Authorization: Basic %s', base64_encode($this->config->getString('token')));
+
+ curl_setopt_array($curl, [
+ CURLOPT_AUTOREFERER => true,
+ CURLOPT_CERTINFO => false,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PATH_AS_IS => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TCP_FASTOPEN => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => sprintf('Satori/%s (+https://fii.moe/satori)', SAT_VERSION),
+ CURLOPT_HTTPHEADER => $headers,
+ ]);
+ $response = json_decode(curl_exec($curl), true);
+ curl_close($curl);
+
+ return empty($response) ? [] : XArray::select($response, fn($post) => new DanbooruPost($post));
+ }
+}
diff --git a/src/Booru/Gelbooru/GelbooruPost.php b/src/Booru/Gelbooru/GelbooruPost.php
new file mode 100644
index 0000000..2267afe
--- /dev/null
+++ b/src/Booru/Gelbooru/GelbooruPost.php
@@ -0,0 +1,32 @@
+info['id'];
+ }
+
+ public function getRating(): string {
+ return $this->info['rating'][0];
+ }
+
+ public function getPostUrl(): string {
+ return sprintf('https://gelbooru.com/index.php?page=post&s=view&id=%d', $this->info['id']);
+ }
+
+ public function getTags(): array {
+ return explode(' ', $this->info['tags']);
+ }
+
+ public function hasFileUrl(): bool {
+ return false; // remote embedding is blocked
+ }
+
+ public function getFileUrl(): string {
+ return $this->info['file_url'];
+ }
+}
diff --git a/src/Booru/Gelbooru/GelbooruSource.php b/src/Booru/Gelbooru/GelbooruSource.php
new file mode 100644
index 0000000..fdb3410
--- /dev/null
+++ b/src/Booru/Gelbooru/GelbooruSource.php
@@ -0,0 +1,59 @@
+info = new BooruSourceInfo('gelbooru', 'Gelbooru');
+ }
+
+ public function getInfo(): BooruSourceInfo {
+ return $this->info;
+ }
+
+ public function getPosts(string $tags, bool $random = true, int $limit = 20): array {
+ if($random)
+ $tags .= ' sort:random';
+
+ $params = [];
+ if($tags !== '')
+ $params['tags'] = $tags;
+ if($limit > 0)
+ $params['limit'] = $limit;
+
+ $url = self::POSTS_URL;
+ if(!empty($params))
+ $url .= '&' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+
+ $curl = curl_init($url);
+ curl_setopt_array($curl, [
+ CURLOPT_AUTOREFERER => true,
+ CURLOPT_CERTINFO => false,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PATH_AS_IS => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TCP_FASTOPEN => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => sprintf('Satori/%s (+https://fii.moe/satori)', SAT_VERSION),
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ ]);
+ $response = json_decode(curl_exec($curl), true);
+ curl_close($curl);
+
+ return empty($response) || empty($response['post']) || !is_array($response['post'])
+ ? [] : XArray::select($response['post'], fn($post) => new GelbooruPost($post));
+ }
+}
diff --git a/src/Booru/IBooruPost.php b/src/Booru/IBooruPost.php
new file mode 100644
index 0000000..74b5be1
--- /dev/null
+++ b/src/Booru/IBooruPost.php
@@ -0,0 +1,12 @@
+info['id'];
+ }
+
+ public function getRating(): string {
+ return $this->info['rating'];
+ }
+
+ public function isSafe(): bool {
+ return $this->info['rating'] === 'g'
+ || $this->info['rating'] === 's';
+ }
+
+ public function getPostUrl(): string {
+ return sprintf('https://konachan.%s/post/show/%d', $this->isSafe() ? 'net' : 'com', $this->info['id']);
+ }
+
+ public function getTags(): array {
+ return explode(' ', $this->info['tags']);
+ }
+
+ public function hasFileUrl(): bool {
+ return false; // remote embedding is blocked
+ }
+
+ public function getFileUrl(): string {
+ return $this->info['file_url'];
+ }
+}
diff --git a/src/Booru/Konachan/KonachanSource.php b/src/Booru/Konachan/KonachanSource.php
new file mode 100644
index 0000000..6a3b429
--- /dev/null
+++ b/src/Booru/Konachan/KonachanSource.php
@@ -0,0 +1,59 @@
+info = new BooruSourceInfo('konachan', 'Konachan');
+ }
+
+ public function getInfo(): BooruSourceInfo {
+ return $this->info;
+ }
+
+ public function getPosts(string $tags, bool $random = true, int $limit = 20): array {
+ if($random)
+ $tags .= ' order:random';
+
+ $params = [];
+ if($tags !== '')
+ $params['tags'] = $tags;
+ if($limit > 0)
+ $params['limit'] = $limit;
+
+ $url = self::POSTS_URL;
+ if(!empty($params))
+ $url .= '&' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+
+ $curl = curl_init($url);
+ curl_setopt_array($curl, [
+ CURLOPT_AUTOREFERER => true,
+ CURLOPT_CERTINFO => false,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PATH_AS_IS => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TCP_FASTOPEN => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => sprintf('Satori/%s (+https://fii.moe/satori)', SAT_VERSION),
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ ]);
+ $response = json_decode(curl_exec($curl), true);
+ curl_close($curl);
+
+ return empty($response) || empty($response['posts']) || !is_array($response['posts'])
+ ? [] : XArray::select($response['posts'], fn($post) => new KonachanPost($post));
+ }
+}
diff --git a/src/Booru/Yandere/YanderePost.php b/src/Booru/Yandere/YanderePost.php
new file mode 100644
index 0000000..7f488ae
--- /dev/null
+++ b/src/Booru/Yandere/YanderePost.php
@@ -0,0 +1,32 @@
+info['id'];
+ }
+
+ public function getRating(): string {
+ return $this->info['rating'];
+ }
+
+ public function getPostUrl(): string {
+ return sprintf('https://yande.re/post/show/%d', $this->info['id']);
+ }
+
+ public function getTags(): array {
+ return explode(' ', $this->info['tags']);
+ }
+
+ public function hasFileUrl(): bool {
+ return false; // remote embedding is blocked
+ }
+
+ public function getFileUrl(): string {
+ return $this->info['file_url'];
+ }
+}
diff --git a/src/Booru/Yandere/YandereSource.php b/src/Booru/Yandere/YandereSource.php
new file mode 100644
index 0000000..4d17df1
--- /dev/null
+++ b/src/Booru/Yandere/YandereSource.php
@@ -0,0 +1,78 @@
+info = new BooruSourceInfo('yandere', 'Yandere');
+ }
+
+ public function getInfo(): BooruSourceInfo {
+ return $this->info;
+ }
+
+ public function getPosts(string $tags, bool $random = true, int $limit = 20): array {
+ $localRandom = false;
+ $localLimit = $limit;
+
+ if($random) {
+ $localRandom = trim($tags) !== '';
+
+ if($localRandom)
+ $limit = max($limit, 200);
+ else
+ $tags = 'order:random';
+ }
+
+ $params = [];
+ if($tags !== '')
+ $params['tags'] = $tags;
+ if($limit > 0)
+ $params['limit'] = $limit;
+
+ $url = self::POSTS_URL;
+ if(!empty($params))
+ $url .= '&' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+
+ $curl = curl_init($url);
+ curl_setopt_array($curl, [
+ CURLOPT_AUTOREFERER => true,
+ CURLOPT_CERTINFO => false,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PATH_AS_IS => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TCP_FASTOPEN => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => sprintf('Satori/%s (+https://fii.moe/satori)', SAT_VERSION),
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ ]);
+ $response = json_decode(curl_exec($curl), true);
+ curl_close($curl);
+
+ if(empty($response) || empty($response['posts']) || !is_array($response['posts']))
+ return [];
+
+ $posts = $response['posts'];
+
+ if($localRandom) {
+ shuffle($posts);
+ if($localLimit > 0)
+ $posts = array_slice($posts, 0, $localLimit);
+ }
+
+ return XArray::select($posts, fn($post) => new YanderePost($post));
+ }
+}
diff --git a/src/DatabaseContext.php b/src/DatabaseContext.php
new file mode 100644
index 0000000..b6113e2
--- /dev/null
+++ b/src/DatabaseContext.php
@@ -0,0 +1,30 @@
+connection;
+ }
+
+ public function getQueryCount(): int {
+ $result = $this->connection->query('SHOW SESSION STATUS LIKE "Questions"');
+ return $result->next() ? $result->getInteger(1) : 0;
+ }
+
+ public function createMigrationManager(): DbMigrationManager {
+ return new DbMigrationManager($this->connection);
+ }
+
+ public function createMigrationRepo(): IDbMigrationRepo {
+ return new FsDbMigrationRepo(SAT_DIR_MIGRATIONS);
+ }
+}
diff --git a/src/Dictionary/DictionaryContext.php b/src/Dictionary/DictionaryContext.php
new file mode 100644
index 0000000..1ac45e6
--- /dev/null
+++ b/src/Dictionary/DictionaryContext.php
@@ -0,0 +1,34 @@
+config = $config;
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function defineWord(string $word): array {
+ $data = json_decode(file_get_contents(sprintf(
+ 'https://api.wordnik.com/v4/word.json/%s/definitions?limit=5&includeRelated=false&sourceDictionaries=ahd-5%%2Cwordnet&useCanonical=true&includeTags=false&api_key=%s',
+ rawurlencode($word),
+ $this->config->getString('token')
+ )), true);
+
+ if(empty($data))
+ return [];
+
+ $results = [];
+
+ foreach($data as $result)
+ $results[] = new DictionaryResult($result);
+
+ return $results;
+ }
+}
diff --git a/src/Dictionary/DictionaryResult.php b/src/Dictionary/DictionaryResult.php
new file mode 100644
index 0000000..e0272cf
--- /dev/null
+++ b/src/Dictionary/DictionaryResult.php
@@ -0,0 +1,42 @@
+info['word'] ?? '';
+ }
+
+ public function getPartOfSpeech(): string {
+ return $this->info['partOfSpeech'] ?? '';
+ }
+
+ public function getAttributionText(): string {
+ return $this->info['attributionText'] ?? '';
+ }
+
+ public function getAttributionUrl(): string {
+ return $this->info['attributionUrl'] ?? '';
+ }
+
+ public function getSourceDictionary(): string {
+ return $this->info['sourceDictionary'] ?? '';
+ }
+
+ public function getWordnikUrl(): string {
+ return $this->info['wordnikUrl'] ?? '';
+ }
+
+ public function hasText(): bool {
+ return array_key_exists('text', $this->info)
+ && $this->info['text'] !== '';
+ }
+
+ public function getText(): string {
+ return strtr($this->info['text'] ?? '', [
+ '' => '[i]',
+ '' => '[/i]',
+ ]);
+ }
+}
diff --git a/src/Dictionary/DictionaryRoutes.php b/src/Dictionary/DictionaryRoutes.php
new file mode 100644
index 0000000..20556e0
--- /dev/null
+++ b/src/Dictionary/DictionaryRoutes.php
@@ -0,0 +1,65 @@
+getParam('word'));
+ if($word === '')
+ return ['error' => 'word'];
+
+ $results = $this->context->defineWord($word);
+ $response = [];
+
+ foreach($results as $result) {
+ if(!$result->hasText())
+ continue;
+
+ $response[] = [
+ 'word' => $result->getWord(),
+ 'part_of_speech' => $result->getPartOfSpeech(),
+ 'attribution_text' => $result->getAttributionText(),
+ 'attribution_url' => $result->getAttributionUrl(),
+ 'source_dictionary' => $result->getSourceDictionary(),
+ 'text' => $result->getText(),
+ 'wordnik_url' => $result->getWordnikUrl(),
+ ];
+ }
+
+ return $response;
+ }
+
+ #[Route('GET', '/word-define.php')]
+ public function getPHP($response, $request) {
+ $word = trim((string)$request->getParam('word'));
+ if($word === '')
+ return ['error' => 'word'];
+
+ $results = $this->context->defineWord($word);
+ $response = [];
+
+ foreach($results as $result) {
+ if(!$result->hasText())
+ continue;
+
+ $response[] = [
+ 'word' => $result->getWord(),
+ 'pos' => $result->getPartOfSpeech(),
+ 'attr' => $result->getAttributionText(),
+ 'attr_url' => $result->getAttributionUrl(),
+ 'dict' => $result->getSourceDictionary(),
+ 'text' => $result->getText(),
+ 'url' => $result->getWordnikUrl(),
+ ];
+ }
+
+ return $response;
+ }
+}
diff --git a/src/ExRate/ExRateContext.php b/src/ExRate/ExRateContext.php
new file mode 100644
index 0000000..3b64ee5
--- /dev/null
+++ b/src/ExRate/ExRateContext.php
@@ -0,0 +1,68 @@
+config = $config;
+ $this->database = new ExRateDatabase(self::BASE_CURRENCY, $dbConn);
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function getDatabase(): ExRateDatabase {
+ return $this->database;
+ }
+
+ public function getBaseCurrency(): string {
+ return self::BASE_CURRENCY;
+ }
+
+ public function getCommonCurrencies(): array {
+ return self::COMMON_CURRENCIES;
+ }
+
+ public function ratesNeedRefresh(): bool {
+ return $this->database->ratesNeedRefresh();
+ }
+
+ public function refreshRates(): void {
+ // HTTPS is a paid feature?????????
+ $data = json_decode(file_get_contents(sprintf(
+ 'http://api.exchangerate.host/live?source=%s&access_key=%s',
+ self::BASE_CURRENCY,
+ $this->config->getString('token')
+ )), true);
+
+ if(empty($data) || !$data['success'])
+ return;
+
+ $this->database->clearRates();
+ $this->database->insertRate(self::BASE_CURRENCY, 1.0);
+
+ foreach($data['quotes'] as $names => $value) {
+ $to = substr($names, 3, 3);
+ if(strlen($to) !== 3)
+ continue;
+
+ $this->database->insertRate($to, $value);
+ }
+ }
+
+ public function convertRate(string $from, string $to, float $value): float {
+ return $this->database->convertRate($from, $to, $value);
+ }
+}
diff --git a/src/ExRate/ExRateDatabase.php b/src/ExRate/ExRateDatabase.php
new file mode 100644
index 0000000..9dd3ee1
--- /dev/null
+++ b/src/ExRate/ExRateDatabase.php
@@ -0,0 +1,49 @@
+cache = new DbStatementCache($dbConn);
+ }
+
+ public function ratesNeedRefresh(): bool {
+ $result = $this->dbConn->query('SELECT MAX(rate_stored) > NOW() - INTERVAL 1 DAY FROM `exchange-rates` LIMIT 1');
+ return !$result->next() || $result->isNull(0) || $result->getInteger(0) < 1;
+ }
+
+ public function clearRates(): void {
+ $this->dbConn->execute('TRUNCATE `exchange-rates`');
+ }
+
+ public function insertRate(string $to, float $rate): void {
+ $stmt = $this->cache->get(sprintf(
+ 'INSERT INTO `exchange-rates` (rate_from, rate_to, rate_value) VALUES ("%s", ?, ?)',
+ $this->baseCurrency
+ ));
+ $stmt->addParameter(1, $to);
+ $stmt->addParameter(2, $rate);
+ $stmt->execute();
+ }
+
+ public function convertRate(string $from, string $to, float $value): float {
+ $stmt = $this->cache->get(sprintf(
+ 'SELECT (SELECT (? / rate_value) FROM `exchange-rates` WHERE rate_from = "%1$s" AND rate_to = ?) * rate_value FROM `exchange-rates` WHERE rate_from = "%1$s" AND rate_to = ?',
+ $this->baseCurrency
+ ));
+ $stmt->addParameter(1, $value);
+ $stmt->addParameter(2, $from);
+ $stmt->addParameter(3, $to);
+ $stmt->execute();
+
+ $result = $stmt->getResult();
+ return $result->next() ? $result->getFloat(0) : 0;
+ }
+}
diff --git a/src/ExRate/ExRateRoutes.php b/src/ExRate/ExRateRoutes.php
new file mode 100644
index 0000000..daa64e4
--- /dev/null
+++ b/src/ExRate/ExRateRoutes.php
@@ -0,0 +1,93 @@
+getParam('from'));
+ $targets = explode(',', strtoupper((string)$request->getParam('to')));
+ $amount = (string)($request->getParam('amount', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION) ?? '1');
+
+ if(strlen($from) !== 3)
+ return 400;
+
+ if(empty($targets) || empty($targets[0]))
+ $targets = $this->context->getCommonCurrencies();
+ else
+ foreach($targets as $target)
+ if(strlen($target) !== 3)
+ return 400;
+
+ if($this->context->ratesNeedRefresh())
+ $this->context->refreshRates();
+
+ $response = new stdClass;
+ $response->from = $from;
+ $response->amount = (float)$amount;
+ $response->results = [];
+
+ foreach($targets as $target) {
+ if($target === $from)
+ continue;
+
+ $response->results[] = [
+ 'to' => $target,
+ 'result' => $this->context->convertRate($from, $target, $amount),
+ ];
+ }
+
+ return $response;
+ }
+
+ #[Route('GET', '/exrate.php')]
+ public function getPHP($response, $request) {
+ $from = strtoupper((string)$request->getParam('from'));
+ $to = strtoupper((string)$request->getParam('to'));
+ $amount = (string)($request->getParam('amount', FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION) ?? '1');
+
+ if((!empty($to) && strlen($to) !== 3) || strlen($from) !== 3) {
+ $response->setStatusCode(404);
+ return 'Invalid currency specified.';
+ }
+
+ if($this->context->ratesNeedRefresh())
+ $this->context->refreshRates();
+
+
+ $response = new stdClass;
+ $response->from = $from;
+ $response->to = $to;
+ $response->amount = (float)$amount;
+
+ if($from === $to) {
+ $response->result = $response->amount;
+ return $response;
+ }
+
+ $singleResult = !empty($to);
+ $targets = $singleResult ? [$to] : $this->context->getCommonCurrencies();
+
+ $results = [];
+
+ foreach($targets as $to)
+ $results[] = [
+ 'to' => $to,
+ 'result' => $this->context->convertRate($from, $to, $amount),
+ ];
+
+ if($singleResult)
+ $response->result = $results[0]['result'];
+ else
+ $response->results = $results;
+
+ return $response;
+ }
+}
diff --git a/src/RoutingContext.php b/src/RoutingContext.php
new file mode 100644
index 0000000..415e631
--- /dev/null
+++ b/src/RoutingContext.php
@@ -0,0 +1,36 @@
+router = new HttpFx;
+ $this->router->use('/', fn($resp) => $resp->setPoweredBy('Satori'));
+ }
+
+ public function getRouter(): IRouter {
+ return $this->router;
+ }
+
+ public function registerDefaultErrorPages(): void {
+ $this->router->addErrorHandler(401, fn($resp) => $resp->setContent(file_get_contents(SAT_DIR_PUBLIC . '/404.html')));
+ $this->router->addErrorHandler(403, fn($resp) => $resp->setContent(file_get_contents(SAT_DIR_PUBLIC . '/404.html')));
+ $this->router->addErrorHandler(404, fn($resp) => $resp->setContent(file_get_contents(SAT_DIR_PUBLIC . '/404.html')));
+ $this->router->addErrorHandler(500, fn($resp) => $resp->setContent(file_get_contents(SAT_DIR_PUBLIC . '/404.html')));
+ $this->router->addErrorHandler(503, fn($resp) => $resp->setContent(file_get_contents(SAT_DIR_PUBLIC . '/404.html')));
+ }
+
+ public function register(IRouteHandler $handler): void {
+ $this->router->register($handler);
+ }
+
+ public function dispatch(?HttpRequest $request = null): void {
+ $this->router->dispatch($request);
+ }
+}
diff --git a/src/SFileCache.php b/src/SFileCache.php
new file mode 100644
index 0000000..945e2df
--- /dev/null
+++ b/src/SFileCache.php
@@ -0,0 +1,23 @@
+ is_int($mtime) && floor($mtime / 900) >= floor(time() / 900), $create);
+ }
+}
diff --git a/src/SHttp.php b/src/SHttp.php
new file mode 100644
index 0000000..10ec289
--- /dev/null
+++ b/src/SHttp.php
@@ -0,0 +1,32 @@
+ true,
+ CURLOPT_CERTINFO => false,
+ CURLOPT_FAILONERROR => false,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_PATH_AS_IS => true,
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_TCP_FASTOPEN => true,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ CURLOPT_USERAGENT => sprintf('Satori/%s (+https://fii.moe/satori)', SAT_VERSION),
+ CURLOPT_HTTPHEADER => ['Accept: application/json'],
+ ]);
+ $response = json_decode(curl_exec($curl), true);
+ curl_close($curl);
+
+ return $response;
+ }
+
+ public static function getJsonCached(string $url): mixed {
+ return SFileCache::for15mins($url, fn() => self::getJson($url));
+ }
+}
diff --git a/src/SatoriContext.php b/src/SatoriContext.php
new file mode 100644
index 0000000..602c853
--- /dev/null
+++ b/src/SatoriContext.php
@@ -0,0 +1,67 @@
+config = $config;
+ $this->dbCtx = new DatabaseContext($dbConn);
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function getDatabase(): DatabaseContext {
+ return $this->dbCtx;
+ }
+
+ public function createExRates(): ExRate\ExRateContext {
+ return new ExRate\ExRateContext(
+ $this->config->scopeTo('exrate'),
+ $this->dbCtx->getConnection()
+ );
+ }
+
+ public function createDictionary(): Dictionary\DictionaryContext {
+ return new Dictionary\DictionaryContext(
+ $this->config->scopeTo('dictionary')
+ );
+ }
+
+ public function createTranslation(): Translation\TranslateContext {
+ return new Translation\TranslateContext(
+ $this->config->scopeTo('translate')
+ );
+ }
+
+ public function createBooru(): Booru\BooruContext {
+ return new Booru\BooruContext(
+ $this->config->scopeTo('booru')
+ );
+ }
+
+ public function createSplatoon(): Splatoon\SplatoonContext {
+ return new Splatoon\SplatoonContext(
+ $this->config->scopeTo('splatoon')
+ );
+ }
+
+ public function createRouting(): RoutingContext {
+ $routingCtx = new RoutingContext;
+ $routingCtx->registerDefaultErrorPages();
+
+ $routingCtx->register(new ExRate\ExRateRoutes($this->createExRates()));
+ $routingCtx->register(new Dictionary\DictionaryRoutes($this->createDictionary()));
+ $routingCtx->register(new Translation\TranslateRoutes($this->createTranslation()));
+ $routingCtx->register(new Booru\BooruRoutes($this->createBooru()));
+ $routingCtx->register(new Splatoon\SplatoonRoutes($this->createSplatoon()));
+
+ return $routingCtx;
+ }
+}
diff --git a/src/Splatoon/ISplatoonFestival.php b/src/Splatoon/ISplatoonFestival.php
new file mode 100644
index 0000000..ae39484
--- /dev/null
+++ b/src/Splatoon/ISplatoonFestival.php
@@ -0,0 +1,20 @@
+stageInfo['image'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('stages:%s:name', $this->stageInfo['image']),
+ $this->stageInfo['name']
+ );
+ }
+
+ public function getImage(): string {
+ return Splatoon2Game::asset($this->stageInfo['image']);
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2Festival.php b/src/Splatoon/Splatoon2/Splatoon2Festival.php
new file mode 100644
index 0000000..0eb43d7
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2Festival.php
@@ -0,0 +1,122 @@
+festInfo['festival_id'];
+ }
+
+ public function getLocalId(): string {
+ return base64_encode(sprintf('Fest-%s:JEA-%05d', $this->region, $this->festInfo['festival_id']));
+ }
+
+ public function getTitle(): string {
+ $prefix = sprintf('festivals:%s:', $this->festInfo['festival_id']);
+
+ return sprintf(
+ '%s vs %s',
+ $this->locale->getString($prefix . 'alpha_short', 'ALPHA'),
+ $this->locale->getString($prefix . 'bravo_short', 'BRAVO')
+ );
+ }
+
+ public function getImage(): string {
+ return Splatoon2Game::asset($this->festInfo['images']['panel']);
+ }
+
+ public function getRegion(): string {
+ return $this->region;
+ }
+
+ public function getStartTime(): int {
+ return $this->festInfo['times']['start'];
+ }
+
+ public function getEndTime(): int {
+ return $this->festInfo['times']['end'];
+ }
+
+ public function getState(): string {
+ $time = time();
+ if($time > $this->getEndTime())
+ return 'CLOSED';
+ if($time > $this->getStartTime())
+ return 'OPEN'; // this is an assumption
+
+ return 'SCHEDULED';
+ }
+
+ public function canVote(): bool {
+ return $this->getEndTime() > time();
+ }
+
+ public function getTeams(): array {
+ $teams = [];
+ $tmpResults = [];
+
+ foreach(['alpha', 'bravo'] as $teamNo => $teamId) {
+ $rawColour = $this->festInfo['colors'][$teamId];
+ $teamColour = (round($rawColour['a'] * 0xFF) << 24)
+ | (round($rawColour['r'] * 0xFF) << 16)
+ | (round($rawColour['g'] * 0xFF) << 8)
+ | round($rawColour['b'] * 0xFF);
+
+ $results = [];
+ $isWinner = false;
+
+ // god i hope this works
+ if(!empty($this->results)) {
+ $isWinner = $this->results['summary']['total'] == $teamNo;
+
+ foreach(['challenge', 'vote', 'regular', 'solo', 'team'] as $rateType) {
+ if(!array_key_exists($rateType, $this->results['rates']))
+ continue;
+
+ $rate = $this->results['rates'][$rateType][$teamId];
+
+ if(!isset($tmpResults[$rateType]))
+ $tmpResults[$rateType] = $rate > $this->results['rates'][$rateType]['bravo'];
+
+ $results[] = new Splatoon2FestivalTeamResult(
+ $rateType,
+ $rate / 10000,
+ $tmpResults[$rateType],
+ );
+
+ $tmpResults[$rateType] = !$tmpResults[$rateType];
+ }
+ }
+
+ $teams[] = new Splatoon2FestivalTeam(
+ $this->locale,
+ $this->region,
+ $this->festInfo['festival_id'],
+ $teamId,
+ $this->festInfo['names'][$teamId . '_long'],
+ $this->festInfo['images'][$teamId],
+ $teamColour,
+ $isWinner,
+ $results
+ );
+ }
+
+ return $teams;
+ }
+
+ public function hasSpecialStages(): bool {
+ return array_key_exists('special_stage', $this->festInfo);
+ }
+
+ public function getSpecialStages(): array {
+ return [new Splatoon2Stage($this->locale, $this->festInfo['special_stage'])];
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2FestivalTeam.php b/src/Splatoon/Splatoon2/Splatoon2FestivalTeam.php
new file mode 100644
index 0000000..aac8400
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2FestivalTeam.php
@@ -0,0 +1,58 @@
+teamId;
+ }
+
+ public function getLocalId(): string {
+ return base64_encode(sprintf(
+ 'FestTeam-%s:JEA-%05d:%s',
+ $this->region,
+ $this->festId,
+ ucfirst($this->teamId)
+ ));
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('festivals:%s:names:%s_long', $this->festId, $this->teamId),
+ $this->teamName
+ );
+ }
+
+ public function getImage(): string {
+ return Splatoon2Game::asset($this->teamImage);
+ }
+
+ public function getColour(): int {
+ return $this->teamColour;
+ }
+
+ public function hasResults(): bool {
+ return !empty($this->results);
+ }
+
+ public function isWinner(): bool {
+ return $this->isWinner;
+ }
+
+ public function getResults(): array {
+ return $this->results;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2FestivalTeamResult.php b/src/Splatoon/Splatoon2/Splatoon2FestivalTeamResult.php
new file mode 100644
index 0000000..98b4463
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2FestivalTeamResult.php
@@ -0,0 +1,24 @@
+category;
+ }
+
+ public function getRatio(): float {
+ return $this->ratio;
+ }
+
+ public function isTop(): bool {
+ return $this->isTop;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2Game.php b/src/Splatoon/Splatoon2/Splatoon2Game.php
new file mode 100644
index 0000000..115c0ef
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2Game.php
@@ -0,0 +1,243 @@
+ 'en', 'en-GB' => 'en',
+ 'es-ES' => 'es', 'es-MX' => 'es',
+ 'fr-FR' => 'fr', 'fr-CA' => 'fr',
+ 'de-DE' => 'de', 'nl-NL' => 'nl',
+ 'it-IT' => 'it', 'ru-RU' => 'ru',
+ 'ja-JP' => 'ja',
+ ];
+
+ public function getDefaultLocale(): string {
+ return self::DEFAULT_LOCALE;
+ }
+
+ public function isSupportedLocale(string $locale): bool {
+ return in_array($locale, self::LOCALES);
+ }
+
+ public function getLocales(): array {
+ return self::LOCALES;
+ }
+
+ public function getLocaleAlias(string $locale): string {
+ if($locale === '')
+ return self::DEFAULT_LOCALE;
+
+ if(array_key_exists($locale, self::LOCALE_ALIASES))
+ return self::LOCALE_ALIASES[$locale];
+
+ return $locale;
+ }
+
+ public function createLocale(string $locale): ISplatoonLocale {
+ return new Splatoon2Locale(
+ SHttp::getJsonCached(sprintf(self::LOCALE_URL, $locale))
+ );
+ }
+
+ private const FEST_URL = self::URL . '/data/festivals.json';
+ private const FEST_REGIONS = ['eu', 'jp', 'na'];
+ private const FEST_REGIONS_ALIAS = [
+ 'EU' => 'eu', 'JP' => 'jp', 'US' => 'na',
+ ];
+
+ public function isSupportedFestivalRegion(string $region): bool {
+ return in_array($region, self::FEST_REGIONS);
+ }
+
+ public function getFestivalRegions(): array {
+ return self::FEST_REGIONS;
+ }
+
+ public function getFestivalRegionAlias(string $region): string {
+ if(array_key_exists($region, self::FEST_REGIONS_ALIAS))
+ return self::FEST_REGIONS_ALIAS[$region];
+
+ return $region;
+ }
+
+ public function getFestivals(ISplatoonLocale $locale, array $regions = []): array {
+ if(empty($regions))
+ $regions = self::FEST_REGIONS;
+
+ $raw = SHttp::getJsonCached(self::FEST_URL);
+ $fests = [];
+ $regionsReverse = array_flip(self::FEST_REGIONS_ALIAS);
+
+ foreach($regions as $region) {
+ if(!array_key_exists($region, $raw) || !array_key_exists('festivals', $raw[$region]))
+ continue;
+
+ $festivals = $raw[$region]['festivals'];
+ $resultsRaw = $raw[$region]['results'];
+ $results = [];
+
+ if(array_key_exists($region, $regionsReverse))
+ $region = $regionsReverse[$region];
+
+ foreach($resultsRaw as $result)
+ $results[$result['festival_id']] = $result;
+
+ foreach($festivals as $festId => $festRaw)
+ $fests[] = new Splatoon2Festival($locale, $region, $festRaw, $results[$festRaw['festival_id']]);
+ }
+
+ return $fests;
+ }
+
+ private const SCHED_URL = self::URL . '/data/schedules.json';
+ private const SCHED_COOP_URL = self::URL . '/data/coop-schedules.json';
+ private const SCHED_FILTERS = [
+ 'regular' => 'regular',
+ 'turf' => 'regular',
+ 'series' => 'gachi',
+ 'open' => 'gachi',
+ 'ranked' => 'gachi',
+ 'bankara' => 'gachi',
+ 'gachi' => 'gachi',
+ 'x' => 'gachi',
+ 'challenge' => 'league',
+ 'league' => 'league',
+ 'salmon' => 'coop',
+ 'coop' => 'coop',
+ 'fest' => 'fest',
+ 'splatfest' => 'fest',
+ 'festival' => 'fest',
+ ];
+
+ public function isValidScheduleFilter(string $filter): bool {
+ return array_key_exists($filter, self::SCHED_FILTERS);
+ }
+
+ public function getScheduleFilters(): array {
+ return array_unique(array_values(self::SCHED_FILTERS));
+ }
+
+ public function getScheduleFilterAlias(string $filter): string {
+ if(array_key_exists($filter, self::SCHED_FILTERS))
+ return self::SCHED_FILTERS[$filter];
+
+ return $filter;
+ }
+
+ private ?array $rawSchedule = null;
+ private function getRawScheduleData(): array {
+ if($this->rawSchedule === null)
+ $this->rawSchedule = SHttp::getJsonCached(self::SCHED_URL);
+
+ return $this->rawSchedule;
+ }
+
+ private ?array $rawCoopSchedule = null;
+ private function getRawCoopScheduleData(): array {
+ if($this->rawCoopSchedule === null)
+ $this->rawCoopSchedule = SHttp::getJsonCached(self::SCHED_COOP_URL);
+
+ return $this->rawCoopSchedule;
+ }
+
+ public function getScheduleModes(ISplatoonLocale $locale, array $filters): array {
+ $modes = [];
+
+ $includeRegular = in_array('regular', $filters);
+ $includeGachi = in_array('gachi', $filters);
+ $includeLeague = in_array('league', $filters);
+ $includeCoop = in_array('coop', $filters);
+
+ if($includeRegular || $includeGachi || $includeLeague) {
+ $regularSchedule = $this->getRawScheduleData();
+
+ if($includeRegular && !empty($regularSchedule['regular']))
+ $modes[] = new Splatoon2GameMode($locale, 'regular', 'Regular Battle', 0xFF19D719);
+
+ if($includeGachi && !empty($regularSchedule['gachi']))
+ $modes[] = new Splatoon2GameMode($locale, 'gachi', 'Ranked Battle', 0xFFF54910);
+
+ if($includeLeague && !empty($regularSchedule['league']))
+ $modes[] = new Splatoon2GameMode($locale, 'league', 'League Battle', 0xFFF02D7D);
+ }
+
+ if($includeCoop) {
+ $coopSchedule = $this->getRawCoopScheduleData();
+ if(!empty($coopSchedule['details']))
+ $modes[] = new Splatoon2GameMode($locale, 'coop', 'Salmon Run', 0xFFFF5600);
+ }
+
+ return $modes;
+ }
+
+ public function getScheduleFestival(): ?ISplatoonScheduleFestival {
+ return null;
+ }
+
+ public function getSchedules(ISplatoonLocale $locale, array $filters): array {
+ $schedules = [];
+
+ $includeRegular = in_array('regular', $filters);
+ $includeGachi = in_array('gachi', $filters);
+ $includeLeague = in_array('league', $filters);
+ $includeCoop = in_array('coop', $filters);
+
+ if($includeRegular || $includeGachi || $includeLeague) {
+ $regularSchedule = $this->getRawScheduleData();
+
+ if($includeRegular && !empty($regularSchedule['regular']))
+ foreach($regularSchedule['regular'] as $entryInfo)
+ $schedules[] = new Splatoon2ScheduleEntryVs($locale, $entryInfo);
+
+ if($includeGachi && !empty($regularSchedule['gachi']))
+ foreach($regularSchedule['gachi'] as $entryInfo)
+ $schedules[] = new Splatoon2ScheduleEntryVs($locale, $entryInfo);
+
+ if($includeLeague && !empty($regularSchedule['league']))
+ foreach($regularSchedule['league'] as $entryInfo)
+ $schedules[] = new Splatoon2ScheduleEntryVs($locale, $entryInfo);
+ }
+
+ if($includeCoop) {
+ $coopSchedule = $this->getRawCoopScheduleData();
+ if(!empty($coopSchedule['schedules']))
+ foreach($coopSchedule['schedules'] as $key => $schedule) {
+ if(array_key_exists($key, $coopSchedule['details']))
+ $schedule = $coopSchedule['details'][$key];
+
+ $schedules[] = new Splatoon2ScheduleEntryCoop($locale, $schedule);
+ }
+ }
+
+ return $schedules;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2GameMode.php b/src/Splatoon/Splatoon2/Splatoon2GameMode.php
new file mode 100644
index 0000000..d05e205
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2GameMode.php
@@ -0,0 +1,36 @@
+name;
+ }
+
+ public function getTitle(): string {
+ return $this->locale->getString(
+ sprintf('game_modes:%s:name', $this->name),
+ $this->title
+ );
+ }
+
+ public function getColour(): int {
+ return $this->colour;
+ }
+
+ public function hasVariants(): bool {
+ return false;
+ }
+
+ public function getVariants(): array {
+ return [];
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2Locale.php b/src/Splatoon/Splatoon2/Splatoon2Locale.php
new file mode 100644
index 0000000..eeb63d7
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2Locale.php
@@ -0,0 +1,42 @@
+ 1 && array_key_exists($name[0], $this->strings) && array_key_exists($name[1], $this->strings[$name[0]])) {
+ $stringInfo = $this->strings[$name[0]][$name[1]];
+
+ if($nameCount > 2) {
+ if(array_key_exists($name[2], $stringInfo)) {
+ if(is_array($stringInfo[$name[2]]) && $nameCount > 3) {
+ if(array_key_exists($name[3], $stringInfo[$name[2]]))
+ return $stringInfo[$name[2]][$name[3]];
+
+ return $fallback;
+ }
+
+ return $stringInfo[$name[2]];
+ }
+
+ if(array_key_exists('names', $stringInfo) && array_key_exists($name[2], $stringInfo['names']))
+ return $stringInfo['names'][$name[2]];
+ }
+
+ if(array_key_exists('name', $stringInfo))
+ return $stringInfo['name'];
+
+ if(!empty($stringInfo))
+ return $stringInfo[array_key_first($stringInfo)];
+ }
+
+ return $fallback;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryCoop.php b/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryCoop.php
new file mode 100644
index 0000000..7f28782
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryCoop.php
@@ -0,0 +1,42 @@
+entryInfo['start_time'], $this->entryInfo['end_time']),
+ ];
+ }
+
+ public function getStages(): array {
+ if(!array_key_exists('stage', $this->entryInfo))
+ return [];
+
+ return [
+ new Splatoon2CoopStage($this->locale, $this->entryInfo['stage']),
+ ];
+ }
+
+ public function getWeapons(): array {
+ if(!array_key_exists('weapons', $this->entryInfo))
+ return [];
+
+ return XArray::select($this->entryInfo['weapons'], fn($weapon) => new Splatoon2Weapon($this->locale, $weapon));
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryVs.php b/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryVs.php
new file mode 100644
index 0000000..b5f88ca
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2ScheduleEntryVs.php
@@ -0,0 +1,43 @@
+entryInfo['game_mode']['key'];
+ }
+
+ public function getGameModeVariant(): string {
+ return '';
+ }
+
+ public function getTimePeriods(): array {
+ return [
+ new Splatoon2TimePeriod($this->entryInfo['start_time'], $this->entryInfo['end_time']),
+ ];
+ }
+
+ public function getStages(): array {
+ $stages = [];
+
+ foreach(['stage_a', 'stage_b', 'stage_c'] as $stage) {
+ if(!array_key_exists($stage, $this->entryInfo))
+ continue;
+
+ $stages[] = new Splatoon2Stage($this->locale, $this->entryInfo[$stage]);
+ }
+
+ return $stages;
+ }
+
+ public function getRuleset(): ISplatoonVsRuleset {
+ return new Splatoon2VsRuleset($this->locale, $this->entryInfo['rule']);
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2Stage.php b/src/Splatoon/Splatoon2/Splatoon2Stage.php
new file mode 100644
index 0000000..beab9ce
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2Stage.php
@@ -0,0 +1,26 @@
+stageInfo['id'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('stages:%s:name', $this->stageInfo['id']),
+ $this->stageInfo['name']
+ );
+ }
+
+ public function getImage(): string {
+ return Splatoon2Game::asset($this->stageInfo['image']);
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2TimePeriod.php b/src/Splatoon/Splatoon2/Splatoon2TimePeriod.php
new file mode 100644
index 0000000..f92b189
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2TimePeriod.php
@@ -0,0 +1,19 @@
+startTime;
+ }
+
+ public function getEndTime(): int {
+ return $this->endTime;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2VsRuleset.php b/src/Splatoon/Splatoon2/Splatoon2VsRuleset.php
new file mode 100644
index 0000000..e69b781
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2VsRuleset.php
@@ -0,0 +1,48 @@
+rulesetInfo['key'];
+ if($id === 'tower_control')
+ return 'LOFT';
+ if($id === 'splat_zones')
+ return 'AREA';
+ if($id === 'clam_blitz')
+ return 'CLAM';
+ if($id === 'rainmaker')
+ return 'GOAL';
+ if($id === 'turf_war')
+ return 'TURF_WAR';
+ return $id;
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('rules:%s:name', $this->rulesetInfo['key']),
+ $this->rulesetInfo['name']
+ );
+ }
+
+ public function getShortName(): string {
+ $id = $this->rulesetInfo['key'];
+ if($id === 'tower_control')
+ return 'TC';
+ if($id === 'splat_zones')
+ return 'SZ';
+ if($id === 'clam_blitz')
+ return 'CB';
+ if($id === 'rainmaker')
+ return 'RM';
+ if($id === 'turf_war')
+ return 'TW';
+ return $id;
+ }
+}
diff --git a/src/Splatoon/Splatoon2/Splatoon2Weapon.php b/src/Splatoon/Splatoon2/Splatoon2Weapon.php
new file mode 100644
index 0000000..954e24b
--- /dev/null
+++ b/src/Splatoon/Splatoon2/Splatoon2Weapon.php
@@ -0,0 +1,26 @@
+weaponInfo['id'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('weapons:%s:name', $this->weaponInfo['id']),
+ $this->weaponInfo['weapon']['name']
+ );
+ }
+
+ public function getImage(): string {
+ return $this->weaponInfo['weapon']['image'];
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3Festival.php b/src/Splatoon/Splatoon3/Splatoon3Festival.php
new file mode 100644
index 0000000..a3bc973
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3Festival.php
@@ -0,0 +1,76 @@
+festInfo['__splatoon3ink_id'];
+ }
+
+ public function getLocalId(): string {
+ return $this->festInfo['id'];
+ }
+
+ public function getTitle(): string {
+ return $this->locale->getString(
+ sprintf('festivals:%s:title', $this->festInfo['__splatoon3ink_id']),
+ $this->festInfo['title']
+ );
+ }
+
+ public function getImage(): string {
+ return $this->festInfo['image']['url'];
+ }
+
+ public function getRegion(): string {
+ return $this->region;
+ }
+
+ public function getStartTime(): int {
+ return strtotime($this->festInfo['startTime']);
+ }
+
+ public function getEndTime(): int {
+ return strtotime($this->festInfo['endTime']);
+ }
+
+ public function getState(): string {
+ return $this->festInfo['state'];
+ }
+
+ public function canVote(): bool {
+ return $this->festInfo['isVotable'];
+ }
+
+ public function getTeams(): array {
+ $teamNo = -1;
+ $teams = [];
+
+ foreach($this->festInfo['teams'] as $teamInfo)
+ $teams[] = new Splatoon3FestivalTeam(
+ $this->locale,
+ $this->region,
+ $this->festInfo['__splatoon3ink_id'],
+ ++$teamNo,
+ $teamInfo
+ );
+
+ return $teams;
+ }
+
+ public function hasSpecialStages(): bool {
+ return false;
+ }
+
+ public function getSpecialStages(): array {
+ return [];
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3FestivalTeam.php b/src/Splatoon/Splatoon3/Splatoon3FestivalTeam.php
new file mode 100644
index 0000000..219264b
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3FestivalTeam.php
@@ -0,0 +1,71 @@
+teamInfo['id'];
+ }
+
+ public function getLocalId(): string {
+ return $this->teamInfo['id'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('festivals:%s:teams:%s', $this->festId, $this->teamNo),
+ $this->teamInfo['teamName']
+ );
+ }
+
+ public function getImage(): string {
+ return $this->teamInfo['image']['url'];
+ }
+
+ public function getColour(): int {
+ $colourInfo = $this->teamInfo['color'];
+ return (round($colourInfo['a'] * 0xFF) << 24)
+ | (round($colourInfo['r'] * 0xFF) << 16)
+ | (round($colourInfo['g'] * 0xFF) << 8)
+ | round($colourInfo['b'] * 0xFF);
+ }
+
+ public function hasResults(): bool {
+ return array_key_exists('result', $this->teamInfo);
+ }
+
+ public function isWinner(): bool {
+ return $this->teamInfo['result']['isWinner'];
+ }
+
+ public function getResults(): array {
+ $results = [];
+
+ foreach(['horagai', 'vote', 'regularContribution', 'challengeContribution', 'tricolorContribution'] as $rateType) {
+ $rateName = $rateType . 'Ratio';
+ if(!array_key_exists($rateName, $this->teamInfo['result'])
+ || $this->teamInfo['result'][$rateName] === null)
+ continue;
+
+ if(str_ends_with($rateType, 'Contribution'))
+ $rateType = substr($rateType, 0, -12);
+
+ $results[] = new Splatoon3FestivalTeamResult(
+ $rateType,
+ $this->teamInfo['result'][$rateName],
+ $this->teamInfo['result']['is' . ucfirst($rateName) . 'Top']
+ );
+ }
+
+ return $results;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3FestivalTeamResult.php b/src/Splatoon/Splatoon3/Splatoon3FestivalTeamResult.php
new file mode 100644
index 0000000..373146d
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3FestivalTeamResult.php
@@ -0,0 +1,24 @@
+category;
+ }
+
+ public function getRatio(): float {
+ return $this->ratio;
+ }
+
+ public function isTop(): bool {
+ return $this->isTop;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3Game.php b/src/Splatoon/Splatoon3/Splatoon3Game.php
new file mode 100644
index 0000000..72130a7
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3Game.php
@@ -0,0 +1,342 @@
+ 'regular',
+ 'turf' => 'regular',
+ 'series' => 'series',
+ 'open' => 'open',
+ 'ranked' => 'bankara',
+ 'bankara' => 'bankara',
+ 'gachi' => 'bankara',
+ 'x' => 'x',
+ 'challenge' => 'league',
+ 'league' => 'league',
+ 'salmon' => 'coop',
+ 'coop' => 'coop',
+ 'eggstra' => 'eggstra',
+ 'bigrun' => 'bigrun',
+ 'fest' => 'fest',
+ 'splatfest' => 'fest',
+ 'festival' => 'fest',
+ ];
+
+ public function isValidScheduleFilter(string $filter): bool {
+ return array_key_exists($filter, self::SCHED_FILTERS);
+ }
+
+ public function getScheduleFilters(): array {
+ return array_unique(array_values(self::SCHED_FILTERS));
+ }
+
+ public function getScheduleFilterAlias(string $filter): string {
+ if(array_key_exists($filter, self::SCHED_FILTERS))
+ return self::SCHED_FILTERS[$filter];
+
+ return $filter;
+ }
+
+ private ?array $rawSchedule = null;
+ private function getRawScheduleData(): array {
+ if($this->rawSchedule === null)
+ $this->rawSchedule = SHttp::getJsonCached(self::SCHED_URL);
+
+ return $this->rawSchedule;
+ }
+
+ public function getScheduleModes(ISplatoonLocale $locale, array $filters): array {
+ $raw = $this->getRawScheduleData();
+ if(!array_key_exists('data', $raw))
+ return [];
+
+ $raw = $raw['data'];
+ $modes = [];
+
+ if(in_array('regular', $filters) && array_key_exists('regularSchedules', $raw) && !empty($raw['regularSchedules']['nodes']))
+ foreach($raw['regularSchedules']['nodes'] as $info) {
+ if(!empty($info['regularMatchSetting'])) {
+ $modes[] = new Splatoon3GameMode('regular', 'Regular Battle', 0xFF19D719);
+ break;
+ }
+ }
+
+ $includeRankedSeries = $includeRankedOpen = $includeRanked = in_array('bankara', $filters);
+ if(!$includeRanked) {
+ $includeRankedSeries = in_array('series', $filters);
+ $includeRankedOpen = in_array('open', $filters);
+ $includeRanked = $includeRankedSeries || $includeRankedOpen;
+ }
+
+ if($includeRanked && array_key_exists('bankaraSchedules', $raw) && !empty($raw['bankaraSchedules']['nodes'])) {
+ $variants = [];
+ foreach($raw['bankaraSchedules']['nodes'] as $info) {
+ if(empty($info['bankaraMatchSettings']))
+ continue;
+
+ if($includeRankedSeries && !empty($info['bankaraMatchSettings'][0])) {
+ $includeRankedSeries = false;
+ $variants[] = new Splatoon3GameModeVariant('CHALLENGE', 'Series', 0xFF603BFF);
+ }
+
+ if($includeRankedOpen && !empty($info['bankaraMatchSettings'][1])) {
+ $includeRankedOpen = false;
+ $variants[] = new Splatoon3GameModeVariant('OPEN', 'Open', 0xFF603BFF);
+ }
+
+ if(!$includeRankedSeries && !$includeRankedOpen)
+ break;
+ }
+
+ if(!empty($variants))
+ $modes[] = new Splatoon3GameMode('bankara', 'Anarchy Battle', 0xFFF54910, $variants);
+ }
+
+ if(in_array('x', $filters) && array_key_exists('xSchedules', $raw) && !empty($raw['xSchedules']['nodes']))
+ foreach($raw['xSchedules']['nodes'] as $info) {
+ if(!empty($info['xMatchSetting'])) {
+ $modes[] = new Splatoon3GameMode('x', 'X Battle', 0xFF0FDB9B);
+ break;
+ }
+ }
+
+ if(in_array('league', $filters) && array_key_exists('eventSchedules', $raw) && !empty($raw['eventSchedules']['nodes']))
+ foreach($raw['eventSchedules']['nodes'] as $info) {
+ if(!empty($info['leagueMatchSetting'])) {
+ $modes[] = new Splatoon3GameMode('league', 'Challenge Battle', 0xFFF02D7D);
+ break;
+ }
+ }
+
+ if(in_array('fest', $filters) && array_key_exists('festSchedules', $raw) && !empty($raw['festSchedules']['nodes'])) {
+ foreach($raw['festSchedules']['nodes'] as $info) {
+ if(!empty($info['festMatchSettings'])) {
+ $modes[] = new Splatoon3GameMode('fest', 'Splatfest Battle', 0xFF71717A);
+ break;
+ }
+ }
+
+ if(!empty($raw['currentFest']['tricolorStage']))
+ $modes[] = new Splatoon3GameMode('tricolor', 'Tricolor Battle', 0xFF71717A);
+ }
+
+ if(array_key_exists('coopGroupingSchedule', $raw)) {
+ $coop = $raw['coopGroupingSchedule'];
+
+ if(in_array('coop', $filters) && array_key_exists('regularSchedules', $coop) && !empty($coop['regularSchedules']['nodes']))
+ foreach($coop['regularSchedules']['nodes'] as $info) {
+ if(!empty($info['setting'])) {
+ $modes[] = new Splatoon3GameMode('coop', 'Salmon Run', 0xFFFF5600);
+ break;
+ }
+ }
+
+ if(in_array('eggstra', $filters) && array_key_exists('teamContestSchedules', $coop) && !empty($coop['teamContestSchedules']['nodes']))
+ foreach($coop['teamContestSchedules']['nodes'] as $info) {
+ if(!empty($info['setting'])) {
+ $modes[] = new Splatoon3GameMode('eggstra', 'Eggstra Work', 0xFFBE8800);
+ break;
+ }
+ }
+
+ if(in_array('bigrun', $filters) && array_key_exists('bigRunSchedules', $coop) && !empty($coop['bigRunSchedules']['nodes']))
+ foreach($coop['bigRunSchedules']['nodes'] as $info) {
+ if(!empty($info['setting'])) {
+ $modes[] = new Splatoon3GameMode('bigrun', 'Big Run', 0xFFB322FF);
+ break;
+ }
+ }
+ }
+
+ return $modes;
+ }
+
+ public function getScheduleFestival(): ?ISplatoonScheduleFestival {
+ $raw = $this->getRawScheduleData();
+ if(!array_key_exists('data', $raw) || empty($raw['data']['currentFest']))
+ return null;
+
+ return new Splatoon3ScheduleFestival($this->locale, $raw['data']['currentFest']);
+ }
+
+ public function getSchedules(ISplatoonLocale $locale, array $filters): array {
+ $raw = $this->getRawScheduleData();
+ if(!array_key_exists('data', $raw))
+ return [];
+
+ $raw = $raw['data'];
+ $schedules = [];
+
+ if(in_array('regular', $filters) && array_key_exists('regularSchedules', $raw) && !empty($raw['regularSchedules']['nodes']))
+ foreach($raw['regularSchedules']['nodes'] as $info) {
+ if(empty($info['regularMatchSetting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryVs($locale, 'regular', '', $info, $info['regularMatchSetting']);
+ }
+
+ $includeRankedSeries = $includeRankedOpen = $includeRanked = in_array('bankara', $filters);
+ if(!$includeRanked) {
+ $includeRankedSeries = in_array('series', $filters);
+ $includeRankedOpen = in_array('open', $filters);
+ $includeRanked = $includeRankedSeries || $includeRankedOpen;
+ }
+
+ if($includeRanked && array_key_exists('bankaraSchedules', $raw) && !empty($raw['bankaraSchedules']['nodes']))
+ foreach($raw['bankaraSchedules']['nodes'] as $info) {
+ if(empty($info['bankaraMatchSettings']))
+ continue;
+
+ if($includeRankedSeries && !empty($info['bankaraMatchSettings'][0]))
+ $schedules[] = new Splatoon3ScheduleEntryVs($locale, 'bankara', 'CHALLENGE', $info, $info['bankaraMatchSettings'][0]);
+
+ if($includeRankedOpen && !empty($info['bankaraMatchSettings'][1]))
+ $schedules[] = new Splatoon3ScheduleEntryVs($locale, 'bankara', 'OPEN', $info, $info['bankaraMatchSettings'][1]);
+ }
+
+ if(in_array('x', $filters) && array_key_exists('xSchedules', $raw) && !empty($raw['xSchedules']['nodes']))
+ foreach($raw['xSchedules']['nodes'] as $info) {
+ if(empty($info['xMatchSetting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryVs($locale, 'x', '', $info, $info['xMatchSetting']);
+ }
+
+ if(in_array('league', $filters) && array_key_exists('eventSchedules', $raw) && !empty($raw['eventSchedules']['nodes']))
+ foreach($raw['eventSchedules']['nodes'] as $info) {
+ if(empty($info['leagueMatchSetting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryVsLeague($locale, $info);
+ }
+
+ if(in_array('fest', $filters) && array_key_exists('festSchedules', $raw) && !empty($raw['festSchedules']['nodes'])) {
+ foreach($raw['festSchedules']['nodes'] as $info) {
+ if(empty($info['festMatchSettings']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryVs($locale, 'fest', '', $info, $info['festMatchSettings']);
+ }
+
+ if(!empty($raw['currentFest']['tricolorStage']))
+ $schedules[] = new Splatoon3ScheduleEntryVsTricolor($locale, $raw['currentFest']['tricolorStage']);
+ }
+
+ if(array_key_exists('coopGroupingSchedule', $raw)) {
+ $coop = $raw['coopGroupingSchedule'];
+
+ if(in_array('coop', $filters) && array_key_exists('regularSchedules', $coop) && !empty($coop['regularSchedules']['nodes']))
+ foreach($coop['regularSchedules']['nodes'] as $info) {
+ if(empty($info['setting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryCoop($locale, 'coop', $info);
+ }
+
+ if(in_array('eggstra', $filters) && array_key_exists('teamContestSchedules', $coop) && !empty($coop['teamContestSchedules']['nodes']))
+ foreach($coop['teamContestSchedules']['nodes'] as $info) {
+ if(empty($info['setting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryCoop($locale, 'eggstra', $info);
+ }
+
+ if(in_array('bigrun', $filters) && array_key_exists('bigRunSchedules', $coop) && !empty($coop['bigRunSchedules']['nodes']))
+ foreach($coop['bigRunSchedules']['nodes'] as $info) {
+ if(empty($info['setting']))
+ continue;
+
+ $schedules[] = new Splatoon3ScheduleEntryCoop($locale, 'bigrun', $info);
+ }
+ }
+
+ return $schedules;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3GameMode.php b/src/Splatoon/Splatoon3/Splatoon3GameMode.php
new file mode 100644
index 0000000..d3d5c78
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3GameMode.php
@@ -0,0 +1,33 @@
+name;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function getColour(): int {
+ return $this->colour;
+ }
+
+ public function hasVariants(): bool {
+ return !empty($this->variants);
+ }
+
+ public function getVariants(): array {
+ return $this->variants;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3GameModeVariant.php b/src/Splatoon/Splatoon3/Splatoon3GameModeVariant.php
new file mode 100644
index 0000000..57b48f7
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3GameModeVariant.php
@@ -0,0 +1,24 @@
+name;
+ }
+
+ public function getTitle(): string {
+ return $this->title;
+ }
+
+ public function getColour(): int {
+ return $this->colour;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3League.php b/src/Splatoon/Splatoon3/Splatoon3League.php
new file mode 100644
index 0000000..85a6a64
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3League.php
@@ -0,0 +1,40 @@
+eventInfo['leagueMatchEventId'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('events:%s:name', $this->eventInfo['id']),
+ $this->eventInfo['name']
+ );
+ }
+
+ public function getDescription(): string {
+ return str_replace('
', "\n", $this->locale->getString(
+ sprintf('events:%s:desc', $this->eventInfo['id']),
+ $this->eventInfo['desc']
+ ));
+ }
+
+ public function getRulesLines(): array {
+ return explode("\n", str_replace('
', "\n", $this->locale->getString(
+ sprintf('events:%s:regulation', $this->eventInfo['id']),
+ $this->eventInfo['regulation']
+ )));
+ }
+
+ public function getRulesUrl(): string {
+ return $this->eventInfo['regulationUrl'] ?? '';
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3Locale.php b/src/Splatoon/Splatoon3/Splatoon3Locale.php
new file mode 100644
index 0000000..73d17aa
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3Locale.php
@@ -0,0 +1,64 @@
+ 1 && array_key_exists($name[0], $this->strings) && array_key_exists($name[1], $this->strings[$name[0]])) {
+ $stringInfo = $this->strings[$name[0]][$name[1]];
+
+ if($nameCount > 2) {
+ if(array_key_exists($name[2], $stringInfo)) {
+ $stringDeepInfo = $stringInfo[$name[2]];
+
+ if(is_string($stringDeepInfo))
+ return str_replace('
', "\n", $stringInfo[$name[2]]);
+
+ if(is_array($stringDeepInfo) && $nameCount > 3) {
+ if(array_key_exists($name[3], $stringDeepInfo)) {
+ $stringDeepInfo = $stringDeepInfo[$name[3]];
+
+ if(is_string($stringDeepInfo))
+ return $stringDeepInfo;
+
+ if(is_array($stringDeepInfo)) {
+ if($nameCount > 4) {
+ if(array_key_exists($name[4], $stringDeepInfo)) {
+ $stringDeepInfo = $stringDeepInfo[$name[4]];
+
+ if(is_string($stringDeepInfo))
+ return $stringDeepInfo;
+ }
+
+ return $fallback;
+ }
+
+ return $stringDeepInfo[array_key_first($stringDeepInfo)];
+ }
+ }
+ }
+
+ return $fallback;
+ }
+
+ if(array_key_exists('names', $stringInfo) && array_key_exists($name[2], $stringInfo['names']))
+ return $stringInfo['names'][$name[2]];
+ }
+
+ if(array_key_exists('name', $stringInfo))
+ return $stringInfo['name'];
+
+ if(!empty($stringInfo))
+ return $stringInfo[array_key_first($stringInfo)];
+ }
+
+ return $fallback;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryCoop.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryCoop.php
new file mode 100644
index 0000000..acd662d
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryCoop.php
@@ -0,0 +1,37 @@
+gameMode;
+ }
+
+ public function getGameModeVariant(): string {
+ return '';
+ }
+
+ public function getTimePeriods(): array {
+ return [
+ new Splatoon3TimePeriod($this->entryInfo['startTime'], $this->entryInfo['endTime']),
+ ];
+ }
+
+ public function getStages(): array {
+ return [
+ new Splatoon3Stage($this->locale, $this->entryInfo['setting']['coopStage']),
+ ];
+ }
+
+ public function getWeapons(): array {
+ return XArray::select($this->entryInfo['setting']['weapons'], fn($weapon) => new Splatoon3Weapon($this->locale, $weapon));
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVs.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVs.php
new file mode 100644
index 0000000..3b36ea0
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVs.php
@@ -0,0 +1,38 @@
+gameMode;
+ }
+
+ public function getGameModeVariant(): string {
+ return $this->gameModeVariant;
+ }
+
+ public function getTimePeriods(): array {
+ return [
+ new Splatoon3TimePeriod($this->entryInfo['startTime'], $this->entryInfo['endTime']),
+ ];
+ }
+
+ public function getStages(): array {
+ return XArray::select($this->matchSetting['vsStages'], fn($stage) => new Splatoon3Stage($this->locale, $stage));
+ }
+
+ public function getRuleset(): ISplatoonVsRuleset {
+ return new Splatoon3VsRuleset($this->locale, $this->matchSetting['vsRule']);
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsLeague.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsLeague.php
new file mode 100644
index 0000000..8c09a01
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsLeague.php
@@ -0,0 +1,38 @@
+entryInfo['timePeriods'], fn($time) => new Splatoon3TimePeriod($time['startTime'], $time['endTime']));
+ }
+
+ public function getStages(): array {
+ return XArray::select($this->entryInfo['leagueMatchSetting']['vsStages'], fn($stage) => new Splatoon3Stage($this->locale, $stage));
+ }
+
+ public function getRuleset(): ISplatoonVsRuleset {
+ return new Splatoon3VsRuleset($this->locale, $this->entryInfo['leagueMatchSetting']['vsRule']);
+ }
+
+ public function getLeagueInfo(): ISplatoonLeague {
+ return new Splatoon3League($this->locale, $this->entryInfo['leagueMatchSetting']['leagueMatchEvent']);
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsTricolor.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsTricolor.php
new file mode 100644
index 0000000..1f4eed3
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleEntryVsTricolor.php
@@ -0,0 +1,36 @@
+locale, $this->tricolorStage),
+ ];
+ }
+
+ public function getRuleset(): ISplatoonVsRuleset {
+ return new Splatoon3VsRulesetTricolor;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleFestival.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleFestival.php
new file mode 100644
index 0000000..6ed3c17
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleFestival.php
@@ -0,0 +1,40 @@
+festInfo['id'];
+ }
+
+ public function getTitle(): string {
+ return $this->festInfo['title'];
+ }
+
+ public function getStartTime(): int {
+ return $this->festInfo['startTime'];
+ }
+
+ public function getMidTermTime(): int {
+ return $this->festInfo['endTime'];
+ }
+
+ public function getEndTime(): int {
+ return $this->festInfo['midtermTime'];
+ }
+
+ public function getState(): string {
+ return $this->festInfo['state'];
+ }
+
+ public function getTeams(): array {
+ return XArray::select($this->festInfo['teams'], fn($team) => new Splatoon3ScheduleFestivalTeam($team));
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3ScheduleFestivalTeam.php b/src/Splatoon/Splatoon3/Splatoon3ScheduleFestivalTeam.php
new file mode 100644
index 0000000..05186a5
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3ScheduleFestivalTeam.php
@@ -0,0 +1,21 @@
+teamInfo['id'];
+ }
+
+ public function getColour(): int {
+ return (round($this->teamInfo['color']['a'] * 0xFF) << 24)
+ | (round($this->teamInfo['color']['r'] * 0xFF) << 16)
+ | (round($this->teamInfo['color']['g'] * 0xFF) << 8)
+ | round($this->teamInfo['color']['b'] * 0xFF);
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3Stage.php b/src/Splatoon/Splatoon3/Splatoon3Stage.php
new file mode 100644
index 0000000..07c2b60
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3Stage.php
@@ -0,0 +1,27 @@
+stageInfo['vsStageId']
+ ?? $this->stageInfo['id'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('stages:%s:name', $this->stageInfo['id']),
+ $this->stageInfo['name']
+ );
+ }
+
+ public function getImage(): string {
+ return $this->stageInfo['image']['url'];
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3TimePeriod.php b/src/Splatoon/Splatoon3/Splatoon3TimePeriod.php
new file mode 100644
index 0000000..6d52675
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3TimePeriod.php
@@ -0,0 +1,22 @@
+startTime = strtotime($startTime);
+ $this->endTime = strtotime($endTime);
+ }
+
+ public function getStartTime(): int {
+ return $this->startTime;
+ }
+
+ public function getEndTime(): int {
+ return $this->endTime;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3VsRuleset.php b/src/Splatoon/Splatoon3/Splatoon3VsRuleset.php
new file mode 100644
index 0000000..58d30de
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3VsRuleset.php
@@ -0,0 +1,39 @@
+rulesetInfo['rule'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('rules:%s:name', $this->rulesetInfo['id']),
+ $this->rulesetInfo['name']
+ );
+ }
+
+ public function getShortName(): string {
+ $id = $this->rulesetInfo['rule'];
+ if($id === 'LOFT')
+ return 'TC';
+ if($id === 'AREA')
+ return 'SZ';
+ if($id === 'CLAM')
+ return 'CB';
+ if($id === 'GOAL')
+ return 'RM';
+ if($id === 'TURF_WAR')
+ return 'TW';
+ if($id === 'TEAM_CONTEST')
+ return 'EW';
+ return $id;
+ }
+}
diff --git a/src/Splatoon/Splatoon3/Splatoon3VsRulesetTricolor.php b/src/Splatoon/Splatoon3/Splatoon3VsRulesetTricolor.php
new file mode 100644
index 0000000..e9b428b
--- /dev/null
+++ b/src/Splatoon/Splatoon3/Splatoon3VsRulesetTricolor.php
@@ -0,0 +1,18 @@
+weaponInfo['__splatoon3ink_id'];
+ }
+
+ public function getName(): string {
+ return $this->locale->getString(
+ sprintf('weapons:%s:name', $this->weaponInfo['__splatoon3ink_id']),
+ $this->weaponInfo['name']
+ );
+ }
+
+ public function getImage(): string {
+ return $this->weaponInfo['image']['url'];
+ }
+}
diff --git a/src/Splatoon/SplatoonContext.php b/src/Splatoon/SplatoonContext.php
new file mode 100644
index 0000000..8c76e3e
--- /dev/null
+++ b/src/Splatoon/SplatoonContext.php
@@ -0,0 +1,28 @@
+games[] = new Splatoon3\Splatoon3Game;
+ $this->games[] = new Splatoon2\Splatoon2Game;
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function getGames(): array {
+ return $this->games;
+ }
+
+ public function getGameByName(string $name): ?ISplatoonGame {
+ foreach($this->games as $game)
+ if($game->getName() === $name)
+ return $game;
+ return null;
+ }
+}
diff --git a/src/Splatoon/SplatoonErrorResponse.php b/src/Splatoon/SplatoonErrorResponse.php
new file mode 100644
index 0000000..336615b
--- /dev/null
+++ b/src/Splatoon/SplatoonErrorResponse.php
@@ -0,0 +1,16 @@
+code;
+ }
+
+ public function jsonSerialize(): mixed {
+ return ['error' => $this->code];
+ }
+}
diff --git a/src/Splatoon/SplatoonRoutes.php b/src/Splatoon/SplatoonRoutes.php
new file mode 100644
index 0000000..810a026
--- /dev/null
+++ b/src/Splatoon/SplatoonRoutes.php
@@ -0,0 +1,241 @@
+ $gameInfo->getName(),
+ 'name' => $gameInfo->getTitle(),
+ 'colour' => $gameInfo->getColour(),
+ 'variant' => $gameInfo->getName(),
+ ];
+ }
+
+ private static function encodeStageInfo(ISplatoonStage $stageInfo): array {
+ return [
+ 'id' => $stageInfo->getId(),
+ 'name' => $stageInfo->getName(),
+ 'image' => $stageInfo->getImage(),
+ ];
+ }
+
+ #[Route('GET', '/splatoon.php')]
+ public function getPHP($response, $request) {
+ $gameInfo = $this->context->getGameByName((string)$request->getParam('g'));
+ if($gameInfo === null)
+ return new SplatoonErrorResponse('game:id');
+
+ $localeId = $gameInfo->getLocaleAlias((string)$request->getParam('l'));
+ if(!$gameInfo->isSupportedLocale($localeId))
+ return new SplatoonErrorResponse('locale:id');
+
+ $localeInfo = $gameInfo->createLocale($localeId);
+ $typeId = (string)$request->getParam('t');
+
+ if($typeId === 'schedules' && $gameInfo instanceof ISplatoonHasSchedules) {
+ $filters = array_filter(explode(',', (string)$request->getParam('sf')));
+ $filterAll = empty($filters);
+
+ if($filterAll)
+ $filters = $gameInfo->getScheduleFilters();
+ else
+ foreach($filters as &$filter) {
+ $filter = $gameInfo->getScheduleFilterAlias($filter);
+ if(!$gameInfo->isValidScheduleFilter($filter))
+ return new SplatoonErrorResponse('schedules:filter');
+ }
+
+ // can't really test this while there's no splatfest going on, test it later
+ $fest = null;
+ $festInfo = $gameInfo->getScheduleFestival();
+ if($festInfo !== null) {
+ $fest = new stdClass;
+ $fest->id = $festInfo->getId();
+ $fest->title = $festInfo->getTitle();
+ $fest->start = $festInfo->getStartTime();
+ $fest->midterm = $festInfo->getMidTermTime();
+ $fest->end = $festInfo->getEndTime();
+ $fest->state = $festInfo->getState();
+ $fest->teams = XArray::select($festInfo->getTeams(), function($teamInfo) {
+ $team = new stdClass;
+ $team->id = $teamInfo->getId();
+ $team->colour = $teamInfo->getColour();
+
+ return $team;
+ });
+ }
+
+ $modes = XArray::select($gameInfo->getScheduleModes($localeInfo, $filters), function($modeInfo) {
+ $mode = new stdClass;
+ $mode->id = $modeInfo->getName();
+ $mode->name = $modeInfo->getTitle();
+ $mode->colour = $modeInfo->getColour();
+
+ if($modeInfo->hasVariants())
+ $mode->variants = XArray::select($modeInfo->getVariants(), function($variantInfo) {
+ $variant = new stdClass;
+ $variant->id = $variantInfo->getName();
+ $variant->name = $variantInfo->getTitle();
+ $variant->colour = $variantInfo->getColour();
+
+ return $variant;
+ });
+
+ return $mode;
+ });
+
+ $schedules = [];
+ $scheduleInfos = $gameInfo->getSchedules($localeInfo, $filters);
+ foreach($scheduleInfos as $scheduleInfo) {
+ if($scheduleInfo instanceof ISplatoonScheduleEntry) {
+ $gameMode = $scheduleInfo->getGameMode() . ':' . $scheduleInfo->getGameModeVariant();
+
+ if(!array_key_exists($gameMode, $schedules)) {
+ $schedules[$gameMode] = $scheduleMode = new stdClass;
+ $scheduleMode->mode = $scheduleInfo->getGameMode();
+
+ $variant = $scheduleInfo->getGameModeVariant();
+ if($variant !== '')
+ $scheduleMode->variant = $variant;
+
+ $scheduleMode->schedule = [];
+ }
+
+ $schedules[$gameMode]->schedule[] = $schedule = new stdClass;
+ $schedule->periods = XArray::select($scheduleInfo->getTimePeriods(), function($periodInfo) {
+ $period = new stdClass;
+ $period->start = str_replace('+00:00', 'Z', date(\DateTime::ATOM, $periodInfo->getStartTime()));
+ $period->end = str_replace('+00:00', 'Z', date(\DateTime::ATOM, $periodInfo->getEndTime()));
+
+ return $period;
+ });
+
+ $stagesInfo = $scheduleInfo->getStages();
+ if(!empty($stagesInfo))
+ $schedule->stages = XArray::select($stagesInfo, fn($item) => self::encodeStageInfo($item));
+ $schedule->flags = [];
+
+ if($scheduleInfo instanceof ISplatoonScheduleEntryVs) {
+ $schedule->variant = 'vs';
+
+ $rulesetInfo = $scheduleInfo->getRuleset();
+ $schedule->ruleset = new stdClass;
+ $schedule->ruleset->id = $rulesetInfo->getId();
+ $schedule->ruleset->name = $rulesetInfo->getName();
+ $schedule->ruleset->short = $rulesetInfo->getShortName();
+
+ if($scheduleInfo instanceof ISplatoonScheduleEntryVsLeague) {
+ $schedule->flags[] = 'league';
+
+ $leagueInfo = $scheduleInfo->getLeagueInfo();
+ $schedule->league = new stdClass;
+ $schedule->league->id = $leagueInfo->getId();
+ $schedule->league->name = $leagueInfo->getName();
+ $schedule->league->desc = $leagueInfo->getDescription();
+ $schedule->league->rules = new stdClass;
+ $schedule->league->rules->lines = $leagueInfo->getRulesLines();
+ $schedule->league->rules->url = $leagueInfo->getRulesUrl();
+ }
+ } elseif($scheduleInfo instanceof ISplatoonScheduleEntryCoop) {
+ $schedule->variant = 'coop';
+
+ $weaponsInfo = $scheduleInfo->getWeapons();
+ if(!empty($weaponsInfo))
+ $schedule->weapons = XArray::select($weaponsInfo, function($weaponInfo) {
+ $weapon = new stdClass;
+ $weapon->id = $weaponInfo->getId();
+ $weapon->name = $weaponInfo->getName();
+ $weapon->image = $weaponInfo->getImage();
+
+ return $weapon;
+ });
+ }
+ }
+ }
+
+ return [
+ 'game' => self::encodeGameInfo($gameInfo),
+ 'fest' => $fest,
+ 'modes' => $modes,
+ 'schedules' => array_values($schedules),
+ ];
+ }
+
+ if($typeId === 'fests' && $gameInfo instanceof ISplatoonHasFestivals) {
+ $regions = array_filter(explode(',', (string)$request->getParam('fr')));
+ $regionsAll = empty($regions);
+
+ if($regionsAll)
+ $regions = $gameInfo->getFestivalRegions();
+ else
+ foreach($regions as &$region) {
+ $region = $gameInfo->getFestivalRegionAlias($region);
+ if(!$gameInfo->isSupportedFestivalRegion($region))
+ return new SplatoonErrorResponse('fests:region');
+ }
+
+ $fests = [];
+ $festInfos = $gameInfo->getFestivals($localeInfo, $regions);
+
+ foreach($festInfos as $festInfo) {
+ if(array_key_exists($festInfo->getId(), $fests)) {
+ $group = $fests[$festInfo->getId()];
+ } else {
+ $fests[$festInfo->getId()] = $group = new stdClass;
+ $group->regions = [];
+ }
+
+ $group->regions[] = $fest = new stdClass;
+ $fest->id = $festInfo->getLocalId();
+ $fest->region = $festInfo->getRegion();
+ $fest->state = $festInfo->getState();
+ $fest->start = str_replace('+00:00', 'Z', date(\DateTime::ATOM, $festInfo->getStartTime()));
+ $fest->end = str_replace('+00:00', 'Z', date(\DateTime::ATOM, $festInfo->getEndTime()));
+ $fest->title = $festInfo->getTitle();
+ $fest->image = $festInfo->getImage();
+ $fest->can_vote = $festInfo->canVote();
+ $fest->teams = XArray::select($festInfo->getTeams(), function($teamInfo) {
+ $team = new stdClass;
+ $team->id = $teamInfo->getLocalId();
+ $team->name = $teamInfo->getName();
+ $team->image = $teamInfo->getImage();
+ $team->colour = $teamInfo->getColour();
+ $team->has_results = $teamInfo->hasResults();
+
+ if($team->has_results) {
+ $team->is_winner = $teamInfo->isWinner();
+ $team->results = XArray::select($teamInfo->getResults(), function($resultInfo) {
+ $result = new stdClass;
+ $result->category = $resultInfo->getCategory();
+ $result->ratio = $resultInfo->getRatio();
+ $result->is_top = $resultInfo->isTop();
+
+ return $result;
+ });
+ }
+
+ return $team;
+ });
+
+ if($festInfo->hasSpecialStages())
+ $fest->special_stages = XArray::select($festInfo->getSpecialStages(), fn($item) => self::encodeStageInfo($item));
+ }
+
+ return [
+ 'game' => self::encodeGameInfo($gameInfo),
+ 'fests' => array_values($fests),
+ ];
+ }
+
+ return new SplatoonErrorResponse('type:id');
+ }
+}
diff --git a/src/Translation/TranslateContext.php b/src/Translation/TranslateContext.php
new file mode 100644
index 0000000..4cda628
--- /dev/null
+++ b/src/Translation/TranslateContext.php
@@ -0,0 +1,85 @@
+config = $config;
+ }
+
+ public function getConfig(): IConfig {
+ return $this->config;
+ }
+
+ public function getGoogleLanguageCodes(): array {
+ return self::GOOGLE_LANGS;
+ }
+
+ public function getGoogleLanguages(): array {
+ $langs = [];
+
+ foreach(self::GOOGLE_LANGS as $langId)
+ $langs[] = [
+ 'code' => $langId,
+ 'name' => locale_get_display_name($langId),
+ ];
+
+ return $langs;
+ }
+
+ public function googleTranslate(string $from, string $to, string $text): ?object {
+ $curl = curl_init(sprintf(
+ self::GOOGLE_TRANSLATE,
+ rawurlencode($from),
+ rawurlencode($to),
+ rawurlencode($text)
+ ));
+ curl_setopt_array($curl, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => false,
+ CURLOPT_USERAGENT => self::GOOGLE_USERAGENT,
+ ]);
+ $response = curl_exec($curl);
+ curl_close($curl);
+
+ if(empty($response))
+ return null;
+
+ $response = json_decode($response);
+ if(empty($response))
+ return null;
+
+ $result = new stdClass;
+ $result->translatedText = $response[0][0][0] ?? '';
+ $result->originalText = $response[0][0][1] ?? '';
+ $result->fromLangCode = $response[2] ?? '';
+ $result->fromLangName = locale_get_display_name($result->fromLangCode);
+ $result->toLangCode = $to;
+ $result->toLangName = locale_get_display_name($to);
+
+ return $result;
+ }
+}
diff --git a/src/Translation/TranslateRoutes.php b/src/Translation/TranslateRoutes.php
new file mode 100644
index 0000000..2c48167
--- /dev/null
+++ b/src/Translation/TranslateRoutes.php
@@ -0,0 +1,99 @@
+getParam('fmt') ?? 'json');
+ if($format !== 'json' && $format !== 'xml')
+ return '';
+
+ $mode = (string)$request->getParam('m');
+
+ if($mode === 'list') {
+ $langs = $this->context->getGoogleLanguages();
+
+ if($format === 'xml') {
+ $document = new DOMDocument('1.0', 'UTF-8');
+
+ $array = $document->createElement('ArrayOfLanguage');
+ $document->appendChild($array);
+
+ foreach($langs as $langInfo) {
+ $lang = $document->createElement('Language');
+
+ foreach($langInfo as $name => $value)
+ $lang->setAttribute($name, $value);
+
+ $array->appendChild($lang);
+ }
+
+ return $document->saveXML();
+ }
+
+ return $langs;
+ }
+
+ if($mode === 'do') {
+ $from = (string)($request->getParam('f') ?? 'auto');
+ $to = (string)($request->getParam('t') ?? 'en');
+ $langs = $this->context->getGoogleLanguageCodes();
+
+ $encodeOutput = function(array $info) use ($format) {
+ if($format === 'json')
+ return $info;
+
+ if($format === 'xml') {
+ $document = new DOMDocument('1.0', 'UTF-8');
+ $translation = $document->createElement('Translation');
+
+ foreach($info as $name => $value)
+ $translation->setAttribute($name, $value);
+
+ $document->appendChild($translation);
+
+ return $document->saveXML();
+ }
+
+ return '';
+ };
+
+ if($from !== 'auto' && !in_array($from, $langs))
+ return $encodeOutput(['error' => 'Source language is not supported.']);
+ if(!in_array($to, $langs))
+ return $encodeOutput(['error' => 'Target language is not supported.']);
+
+ $text = '';
+ if($request->hasParam('z'))
+ $text = trim((string)$request->getParam('z'));
+ elseif($request->hasContent())
+ $text = trim((string)$request->getContent());
+
+ if($text === '')
+ return $encodeOutput(['error' => 'Nothing to translate.']);
+
+ $result = $this->context->googleTranslate($from, $to, $text);
+ if($result === null)
+ return $encodeOutput(['error' => 'Translation failed.']);
+
+ return $encodeOutput([
+ 'from' => $result->fromLangCode,
+ 'from_text' => $result->fromLangName,
+ 'to' => $result->toLangCode,
+ 'to_text' => $result->toLangName,
+ 'original' => $result->originalText,
+ 'result' => $result->translatedText,
+ ]);
+ }
+
+ return '';
+ }
+}
diff --git a/tools/migrate b/tools/migrate
new file mode 100755
index 0000000..09eaacf
--- /dev/null
+++ b/tools/migrate
@@ -0,0 +1,34 @@
+#!/usr/bin/env php
+getDatabase();
+
+ echo 'Creating migration manager...' . PHP_EOL;
+ $manager = $db->createMigrationManager();
+
+ echo 'Preparing to run migrations...' . PHP_EOL;
+ $manager->init();
+
+ echo 'Creating migration repository...' . PHP_EOL;
+ $repo = $db->createMigrationRepo();
+
+ echo 'Running migrations...' . PHP_EOL;
+ $completed = $manager->processMigrations($repo);
+
+ if(empty($completed)) {
+ echo 'There were no migrations to run!' . PHP_EOL;
+ } else {
+ echo 'The following migrations have been completed:' . PHP_EOL;
+ foreach($completed as $migration)
+ echo ' - ' . $migration . PHP_EOL;
+ }
+
+ echo PHP_EOL;
+} finally {
+ unlink(SAT_ROOT . '/.migrating');
+}
diff --git a/tools/new-migration b/tools/new-migration
new file mode 100755
index 0000000..c30ca8d
--- /dev/null
+++ b/tools/new-migration
@@ -0,0 +1,26 @@
+#!/usr/bin/env php
+getDatabase();
+$repo = $db->createMigrationRepo();
+if(!($repo instanceof FsDbMigrationRepo)) {
+ echo 'Migration repository type does not support creation of templates.' . PHP_EOL;
+ return;
+}
+
+$baseName = implode(' ', array_slice($argv, 1));
+$manager = $db->createMigrationManager();
+
+try {
+ $names = $manager->createNames($baseName);
+} catch(InvalidArgumentException $ex) {
+ echo $ex->getMessage() . PHP_EOL;
+ return;
+}
+
+$repo->saveMigrationTemplate($names->name, $manager->template($names->className));
+
+echo "Template for '{$names->className}' has been saved to {$names->name}.php." . PHP_EOL;