Merge remote-tracking branch 'origin/develop'
timw4mail/HummingBirdAnimeClient/pipeline/head This commit looks good Details

This commit is contained in:
Timothy Warren 2023-06-27 21:18:27 -04:00
commit 73bbc569a7
131 changed files with 3489 additions and 2076 deletions

View File

@ -5,22 +5,12 @@ use PhpCsFixer\{Config, Finder};
$finder = Finder::create()
->in([
__DIR__,
__DIR__ . '/app',
__DIR__ . '/src',
__DIR__ . '/tests',
__DIR__ . '/tools',
])
->exclude([
'apidocs',
'build',
'coverage',
'frontEndSrc',
'phinx',
'public',
'tools',
'tmp',
'vendor',
'views',
'templates',
]);
return (new Config())
@ -45,7 +35,7 @@ return (new Config())
'blank_line_after_opening_tag' => false,
'blank_line_before_statement' => [
'statements' => [
'case',
// 'case',
'continue',
'declare',
'default',
@ -128,12 +118,12 @@ return (new Config())
'noise_remaining_usages_exclude' => [],
],
'escape_implicit_backslashes' => [
'double_quoted' => true,
'heredoc_syntax' => true,
'double_quoted' => false,
'heredoc_syntax' => false,
'single_quoted' => false,
],
'explicit_indirect_variable' => true,
'explicit_string_variable' => true,
'explicit_indirect_variable' => false,
'explicit_string_variable' => false,
'final_class' => false,
'final_internal_class' => [
'annotation_exclude' => ['@no-final'],
@ -167,7 +157,7 @@ return (new Config())
],
'group_import' => true,
'header_comment' => false, // false by default
'heredoc_indentation' => ['indentation' => 'start_plus_one'],
// 'heredoc_indentation' => ['indentation' => 'start_plus_one'],
'heredoc_to_nowdoc' => true,
'implode_call' => true,
'include' => true,
@ -232,8 +222,7 @@ return (new Config())
'allow_unused_params' => true,
'remove_inheritdoc' => false,
],
'no_trailing_comma_in_list_call' => true,
'no_trailing_comma_in_singleline_array' => true,
'no_trailing_comma_in_singleline' => true,
'no_trailing_whitespace' => true,
'no_trailing_whitespace_in_comment' => true,
'no_trailing_whitespace_in_string' => true,
@ -270,9 +259,16 @@ return (new Config())
'ordered_class_elements' => [
'order' => [
'use_trait',
'constant',
'property',
'method',
'case',
'constant_public',
'constant_protected',
'constant_private',
'property_public',
'property_protected',
'property_private',
'construct',
'destruct',
'magic',
],
'sort_algorithm' => 'none',
],

View File

@ -1,8 +1,9 @@
# Changelog
## Version 5.2
* Updated PHP requirement to 8
* Updated to support PHP 8.1
* Updated PHP requirement to 8.1
* Updated to support PHP 8.2
* Improve Anilist <-> Kitsu mappings to be more reliable
## Version 5.1
* Added session check, so when coming back to a page, if the session is expired, the page will refresh.

0
app/logs/.gitkeep Normal file → Executable file
View File

View File

@ -1,6 +1,6 @@
<?php
use Aviat\AnimeClient\Kitsu;
use function Aviat\AnimeClient\friendlyTime;
?>
<main class="details fixed">
@ -38,14 +38,14 @@ use Aviat\AnimeClient\Kitsu;
<?php if (( ! empty($data['episode_length'])) && $data['episode_count'] !== 1): ?>
<tr>
<td>Episode Length</td>
<td><?= Kitsu::friendlyTime($data['episode_length']) ?></td>
<td><?= friendlyTime($data['episode_length']) ?></td>
</tr>
<?php endif ?>
<?php if (isset($data['total_length'], $data['episode_count']) && $data['total_length'] > 0): ?>
<tr>
<td>Total Length</td>
<td><?= Kitsu::friendlyTime($data['total_length']) ?></td>
<td><?= friendlyTime($data['total_length']) ?></td>
</tr>
<?php endif ?>

View File

@ -26,7 +26,7 @@ use Aviat\AnimeClient\Kitsu;
<br />
<hr />
<div class="description">
<p><?= str_replace("\n", '</p><p>', $data['description']) ?></p>
<p><?= nl2br($data['description']) ?></p>
</div>
</div>
</section>

View File

@ -8,6 +8,9 @@
<?php foreach ($data['names'] as $name): ?>
<h3><?= $name ?></h3>
<?php endforeach ?>
<?php if ( ! empty($data['birthday'])): ?>
<h4><?= $data['birthday'] ?></h4>
<?php endif ?>
<br />
<hr />
<div class="description">

View File

@ -3,40 +3,58 @@ use Aviat\AnimeClient\Kitsu;
?>
<main class="user-page details">
<h2 class="toph">
About
<?= $helper->a(
"https://kitsu.io/users/{$data['slug']}",
$data['name'], [
'title' => 'View profile on Kitsu'
"https://kitsu.io/users/{$data['slug']}",
$data['name'], [
'title' => 'View profile on Kitsu'
])
?>
</h2>
<p><?= $escape->html($data['about']) ?></p>
<section class="flex flex-no-wrap">
<aside class="info">
<center>
<?= $helper->img($data['avatar'], ['alt' => '']); ?>
</center>
<table class="media-details invisible">
<tr>
<?php if($data['avatar'] !== null): ?>
<td><?= $helper->img($data['avatar'], ['alt' => '', 'width' => '225']); ?></td>
<?php endif ?>
<td><?= $escape->html($data['about']) ?></td>
</tr>
</table>
<br />
<table class="media-details">
<?php foreach ([
'joinDate' => 'Joined',
'birthday' => 'Birthday',
'gender' => 'Gender',
'location' => 'Location'
] as $key => $label): ?>
<?php if ($data[$key] !== null): ?>
<tr>
<td>Location</td>
<td><?= $data['location'] ?></td>
<td><?= $label ?></td>
<td><?= $data[$key] ?></td>
</tr>
<?php endif ?>
<?php endforeach; ?>
<?php if ($data['website'] !== null): ?>
<tr>
<td>Website</td>
<td><?= $helper->a($data['website'], $data['website']) ?></td>
</tr>
<?php if ( ! empty($data['waifu'])): ?>
<?php endif ?>
<?php if ($data['waifu']['character'] !== null): ?>
<tr>
<td><?= $escape->html($data['waifu']['label']) ?></td>
<td>
<?php
$character = $data['waifu']['character'];
echo $helper->a(
$url->generate('character', ['slug' => $character['slug']]),
$character['names']['canonical']
echo $component->character(
$character['names']['canonical'],
$url->generate('character', ['slug' => $character['slug']]),
$helper->img(Kitsu::getImage($character))
);
?>
</td>
@ -75,7 +93,7 @@ use Aviat\AnimeClient\Kitsu;
$rendered[] = $component->character(
$item['names']['canonical'],
$url->generate('character', ['slug' => $item['slug']]),
$helper->img($item['image']['original']['url'])
$helper->img(Kitsu::getImage($item))
);
}
else

View File

@ -1,9 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" stopOnFailure="false" bootstrap="../tests/bootstrap.php" beStrictAboutTestsThatDoNotTestAnything="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" colors="true" stopOnFailure="false" bootstrap="../tests/bootstrap.php" beStrictAboutTestsThatDoNotTestAnything="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.1/phpunit.xsd">
<coverage>
<include>
<directory suffix=".php">../src</directory>
</include>
<report>
<clover outputFile="logs/clover.xml"/>
<html outputDirectory="../coverage"/>
@ -14,12 +11,12 @@
<directory>../tests/AnimeClient</directory>
</testsuite>
<testsuite name="Ion">
<directory>../tests/Ion</directory>
</testsuite>
<directory>../tests/Ion</directory>
</testsuite>
</testsuites>
<logging>
<junit outputFile="logs/junit.xml"/>
</logging>
<logging>
<junit outputFile="logs/junit.xml"/>
</logging>
<php>
<server name="HTTP_USER_AGENT" value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:38.0) Gecko/20100101 Firefox/38.0"/>
<server name="HTTP_HOST" value="localhost"/>
@ -27,4 +24,9 @@
<server name="REQUEST_URI" value="/"/>
<server name="REQUEST_METHOD" value="GET"/>
</php>
<source>
<include>
<directory suffix=".php">../src</directory>
</include>
</source>
</phpunit>

View File

@ -30,21 +30,20 @@
"lock": false
},
"require": {
"amphp/amp": "^2.5.0",
"amphp/http-client": "^4.5.0",
"aura/html": "^2.5.0",
"aura/router": "^3.1.0",
"aura/session": "^2.1.0",
"aviat/banker": "^4.1.2",
"aviat/query": "^4.0.0",
"aviat/query": "^4.1.0",
"ext-dom": "*",
"ext-gd": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"laminas/laminas-diactoros": "^2.5.0",
"laminas/laminas-httphandlerrunner": "^2.1.0",
"laminas/laminas-diactoros": "^3.0.0",
"laminas/laminas-httphandlerrunner": "^2.6.1",
"maximebf/consolekit": "^1.0.3",
"monolog/monolog": "^3.0.0",
"php": ">= 8.1.0",
@ -56,9 +55,9 @@
},
"require-dev": {
"phpstan/phpstan": "^1.2.0",
"phpunit/phpunit": "^9.5.0",
"phpunit/phpunit": "^10.0.0",
"roave/security-advisories": "dev-master",
"spatie/phpunit-snapshot-assertions": "^4.1.0"
"spatie/phpunit-snapshot-assertions": "^5.0.1"
},
"scripts": {
"build:css": "cd public && npm run build:css && cd ..",

View File

@ -333,7 +333,8 @@ td.danger, td.danger:hover, td.danger:active {
.borderless th,
.invisible tr,
.invisible td,
.invisible th {
.invisible th,
table.invisible {
box-shadow: none;
border: 0;
}
@ -836,19 +837,11 @@ aside.info {
max-width: 390px;
}
/* .fixed aside.info + article {
max-width: inherit;
} */
aside picture, aside img {
display: block;
margin: 0 auto;
}
/* aside.info + article {
max-width: 66%;
} */
/* ----------------------------------------------------------------------------
User page styles
-----------------------------------------------------------------------------*/

View File

@ -1,5 +1,6 @@
import _ from './anime-client.js'
import { renderSearchResults } from './template-helpers.js'
import { getNestedProperty, hasNestedProperty } from "./fns";
const search = (query, isCollection = false) => {
// Show the loader
@ -70,6 +71,14 @@ _.on('body.anime.list', 'click', '.plus-one', (e) => {
}
};
const displayMessage = (type, message) => {
_.hide('#loading-shadow');
_.showMessage(type, `${message} ${title}`);
_.scrollToTop();
}
const showError = () => displayMessage('error', 'Failed to update');
// If the episode count is 0, and incremented,
// change status to currently watching
if (isNaN(watchedCount) || watchedCount === 0) {
@ -89,36 +98,31 @@ _.on('body.anime.list', 'click', '.plus-one', (e) => {
dataType: 'json',
type: 'POST',
success: (res) => {
const resData = JSON.parse(res);
try {
const resData = JSON.parse(res);
if (resData.error) {
_.hide('#loading-shadow');
_.showMessage('error', `Failed to update ${title}. `);
_.scrollToTop();
// Do a rough sanity check for weird errors
let updatedProgress = getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.progress');
if (hasNestedProperty(resData, 'error') || updatedProgress !== data.data.progress) {
showError();
return;
}
return;
// We've completed the series
if (getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.status') === 'COMPLETED') {
_.hide(parentSel);
displayMessage('success', 'Completed')
return;
}
// Just a normal update
_.$('.completed_number', parentSel)[ 0 ].textContent = ++watchedCount;
displayMessage('success', 'Updated');
} catch (_) {
showError();
}
// We've completed the series
if (resData.data.libraryEntry.update.libraryEntry.status === 'COMPLETED') {
_.hide(parentSel);
_.hide('#loading-shadow');
_.showMessage('success', `Successfully completed ${title}`);
_.scrollToTop();
return;
}
_.hide('#loading-shadow');
_.showMessage('success', `Successfully updated ${title}`);
_.$('.completed_number', parentSel)[ 0 ].textContent = ++watchedCount;
_.scrollToTop();
},
error: () => {
_.hide('#loading-shadow');
_.showMessage('error', `Failed to update ${title}. `);
_.scrollToTop();
}
error: showError,
});
});

103
frontEndSrc/js/fns.js Normal file
View File

@ -0,0 +1,103 @@
/**
* Make sure properties are in an easily splittable format
*
* @private
* @param {String} props
* @param {String} [sep='.'] The default separator
* @return {String}
*/
function _normalizeProperty(props, sep = '.') {
// Since we split by period, and property lookup
// is the same by dot or [], replace bracket lookups
// with periods
return props.replace(/\[(.*?)]/g, sep + '$1');
}
/**
* Tell if a nested object has a given property (or array a given index)
* given an object such as a.b.c.d = 5, hasNestedProperty(a, 'b.c.d') will return true.
*
* @param {Object} object the object to get the property from
* @param {String} property the path to the property as a string
* @returns {boolean} true when property in object, false otherwise
*/
export function hasNestedProperty(object, property) {
if (object && typeof object === 'object') {
if (typeof property === 'string' && property !== '') {
property = _normalizeProperty(property);
let split = property.split('.');
return split.reduce((obj, prop, idx, array) => {
if (idx === array.length - 1) {
return !!(obj && obj.hasOwnProperty(prop));
}
return obj && obj[prop];
}, object);
} else if (typeof property === 'number') {
return property in object;
}
}
return false;
}
/**
* Get the value of a deeply nested property in an object
*
* @param {Object} object the object to get the property
* @param {string} property the path to the property as a string
* @param {string} [sep='.'] The default separator to split on
* @return {*} the value of the property
*/
export function getNestedProperty(object, property, sep = '.') {
if (isType('string', property) && property !== '') {
// convert numbers to dot syntax
property = _normalizeProperty(property, sep);
const levels = property.split(sep);
try {
return levels.reduce((obj, prop) => obj[prop], object);
} catch (e) {
return undefined;
}
}
return null;
}
/**
* Reliably get the type of the value of a variable
*
* @param {*} x The variable to get the type of
* @return {string} The name of the type
*/
export function getType(x) {
// is it an array?
if (Array.isArray(x)) {
return 'array';
}
// Use typeof for truthy primitives
if (typeof x !== 'object') {
return (typeof x).toLowerCase();
}
const type = function () {
return Object.prototype.toString.call(this).slice(8, -1);
}
// Otherwise, strip the type out of the '[Object x]' toString value
return type.call(x).toLowerCase();
}
/**
* Check whether the value matches the passed type name
*
* @param {string} type Javascript type name
* @param {*} val The value to type check
* @return {boolean}
*/
export function isType(type, val) {
return getType(val) === String(type).toLowerCase();
}

View File

@ -1,5 +1,6 @@
import _ from './anime-client.js'
import { renderSearchResults } from './template-helpers.js'
import { getNestedProperty, hasNestedProperty } from "./fns";
const search = (query) => {
_.show('.cssload-loader');
@ -36,7 +37,7 @@ _.on('.manga.list', 'click', '.edit-buttons button', (e) => {
let type = thisSel.classList.contains('plus-one-chapter') ? 'chapter' : 'volume';
let completed = parseInt(_.$(`.${type}s_read`, parentSel)[ 0 ].textContent, 10) || 0;
let total = parseInt(_.$(`.${type}_count`, parentSel)[ 0 ].textContent, 10);
let mangaName = _.$('.name', parentSel)[ 0 ].textContent;
let title = _.$('.name', parentSel)[ 0 ].textContent;
if (isNaN(completed)) {
completed = 0;
@ -45,12 +46,21 @@ _.on('.manga.list', 'click', '.edit-buttons button', (e) => {
// Setup the update data
let data = {
id: parentSel.dataset.kitsuId,
anilist_id: parentSel.dataset.anilistId,
mal_id: parentSel.dataset.malId,
data: {
progress: completed
}
};
const displayMessage = (type, message) => {
_.hide('#loading-shadow');
_.showMessage(type, `${message} ${title}`);
_.scrollToTop();
}
const showError = () => displayMessage('error', 'Failed to update');
// If the episode count is 0, and incremented,
// change status to currently reading
if (isNaN(completed) || completed === 0) {
@ -73,33 +83,32 @@ _.on('.manga.list', 'click', '.edit-buttons button', (e) => {
type: 'POST',
mimeType: 'application/json',
success: (res) => {
const resData = JSON.parse(res)
if (resData.error) {
_.hide('#loading-shadow');
_.showMessage('error', `Failed to update ${mangaName}. `);
_.scrollToTop();
return;
try {
const resData = JSON.parse(res);
// Do a rough sanity check for weird errors
let updatedProgress = getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.progress');
if (hasNestedProperty(resData, 'error') || updatedProgress !== data.data.progress) {
showError();
return;
}
// We've completed the series
if (getNestedProperty(resData, 'data.libraryEntry.update.libraryEntry.status') === 'COMPLETED') {
_.hide(parentSel);
displayMessage('success', 'Completed')
return;
}
// Just a normal update
_.$(`.${type}s_read`, parentSel)[ 0 ].textContent = String(completed);
displayMessage('success', 'Updated');
} catch (_) {
showError();
}
if (String(data.data.status).toUpperCase() === 'COMPLETED') {
_.hide(parentSel);
_.hide('#loading-shadow');
_.showMessage('success', `Successfully completed ${mangaName}`);
_.scrollToTop();
return;
}
_.hide('#loading-shadow');
_.$(`.${type}s_read`, parentSel)[ 0 ].textContent = String(completed);
_.showMessage('success', `Successfully updated ${mangaName}`);
_.scrollToTop();
},
error: () => {
_.hide('#loading-shadow');
_.showMessage('error', `Failed to update ${mangaName}`);
_.scrollToTop();
}
error: showError,
});
});

View File

@ -15,7 +15,7 @@
"cssnano": "^5.0.1",
"postcss": "^8.2.6",
"postcss-import": "^15.0.0",
"postcss-preset-env": "^7.8.2",
"postcss-preset-env": "^8.0.1",
"watch": "^1.0.2"
}
}

View File

@ -1,4 +1,6 @@
module.exports = {
const { config } = require("@swc/core/spack");
module.exports = config({
entry: {
'scripts.min': __dirname + '/js/index.js',
'tables.min': __dirname + '/js/base/sort-tables.js',
@ -8,12 +10,15 @@ module.exports = {
},
options: {
jsc: {
target: 'es3',
loose: true,
parser: {
syntax: "ecmascript",
jsx: false,
},
target: 'es2016',
loose: false,
},
minify: true,
module: {
type: 'es6'
}
sourceMaps: false,
isModule: true,
}
}
});

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,25 @@
default:
@just --list
# Runs rector, showing what changes will be make
rector-dry-run:
tools/vendor/bin/rector process --config=tools/rector.php --dry-run src
# -------------------------------------------------------------------
# Front-end stuff
# -------------------------------------------------------------------
# Runs rector, and updates the files
rector:
tools/vendor/bin/rector process --config=tools/rector.php src
# Builds/optimizes JS and CSS
build:
cd frontEndSrc && npm run build && cd ..
# Builds/optimizes CSS
css:
composer run-script build:css
# Builds/optimizes JS
js:
composer run-script build:js
# -------------------------------------------------------------------
# Code Quality and Formatting
# -------------------------------------------------------------------
# Check code formatting
check-fmt:
@ -18,6 +30,22 @@ check-fmt:
fmt:
tools/vendor/bin/php-cs-fixer fix --verbose
# Runs phpstan code check
phpstan:
composer run-script phpstan
# Runs rector, showing what changes will be make
rector-dry-run:
tools/vendor/bin/rector process --config=tools/rector.php --dry-run src tests
# Runs rector, and updates the source files
rector:
tools/vendor/bin/rector process --config=tools/rector.php src tests
# -------------------------------------------------------------------
# Testing
# -------------------------------------------------------------------
# Run tests
test:
composer run-script test
@ -26,10 +54,14 @@ test:
test-update:
composer run-script test-update
# Update the per-file header comments
update-headers:
php tools/update_header_comments.php
# Run unit tests and generate test-coverage report
coverage:
composer run-script coverage
composer run-script coverage
# -------------------------------------------------------------------
# Misc
# -------------------------------------------------------------------
# Update the per-file header comments
update-headers:
php tools/update_header_comments.php

View File

@ -9,11 +9,12 @@ parameters:
- ./console
- index.php
ignoreErrors:
- "#Offset 'fields' does not exist on array#"
- '#Function imagepalletetotruecolor not found#'
- '#Unable to resolve the template type T#'
- '#imagepalletetotruecolor not found#'
- '#Call to an undefined method Aura\\\Html\\\HelperLocator::[a-zA-Z0-9_]+\(\)#'
- '#Call to an undefined method Query\\QueryBuilderInterface::[a-zA-Z0-9_]+\(\)#'
excludes_analyse:
excludePaths:
- src/Ion/Type/Stringy.php
- tests/mocks.php
- vendor
# These are objects that basically can return anything

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
var LightTableSorter=function(){var th=null;var cellIndex=null;var order="";var text=function(row){return row.cells.item(cellIndex).textContent.toLowerCase()};var sort=function(a,b){var textA=text(a);var textB=text(b);console.log("Comparing "+textA+" and "+textB);if(th.classList.contains("numeric")){var arrayA=textA.replace("episodes: ","").replace("-",0).split("/");var arrayB=textB.replace("episodes: ","").replace("-",0).split("/");if(arrayA.length>1){textA=parseInt(arrayA[0],10)/parseInt(arrayA[1],10);textB=parseInt(arrayB[0],10)/parseInt(arrayB[1],10)}else{textA=parseInt(arrayA[0],10);textB=parseInt(arrayB[0],10)}}else if(parseInt(textA,10)){textA=parseInt(textA,10);textB=parseInt(textB,10)}if(textA>textB)return 1;if(textA<textB)return -1;return 0};var toggle=function(){var c=order!=="sorting-asc"?"sorting-asc":"sorting-desc";th.className=(th.className.replace(order,"")+" "+c).trim();return order=c};var reset=function(){th.classList.remove("sorting-asc","sorting-desc");th.classList.add("sorting");return order=""};var onClickEvent=function(e){if(th&&cellIndex!==e.target.cellIndex)reset();th=e.target;if(th.nodeName.toLowerCase()==="th"){cellIndex=th.cellIndex;var tbody=th.offsetParent.getElementsByTagName("tbody")[0];var rows=Array.from(tbody.rows);if(rows){rows.sort(sort);if(order==="sorting-asc")rows.reverse();toggle();tbody.innerHtml="";rows.forEach(function(row){tbody.appendChild(row)})}}};return{init:function(){var ths=document.getElementsByTagName("th");var results=[];for(var i=0,len=ths.length;i<len;i++){var th=ths[i];th.classList.add("sorting");th.classList.add("testing");results.push(th.onclick=onClickEvent)}return results}}}();LightTableSorter.init();
const LightTableSorter=(()=>{let th=null;let cellIndex=null;let order="";const text=row=>row.cells.item(cellIndex).textContent.toLowerCase();const sort=(a,b)=>{let textA=text(a);let textB=text(b);console.log("Comparing "+textA+" and "+textB);if(th.classList.contains("numeric")){let arrayA=textA.replace("episodes: ","").replace("-",0).split("/");let arrayB=textB.replace("episodes: ","").replace("-",0).split("/");if(arrayA.length>1){textA=parseInt(arrayA[0],10)/parseInt(arrayA[1],10);textB=parseInt(arrayB[0],10)/parseInt(arrayB[1],10)}else{textA=parseInt(arrayA[0],10);textB=parseInt(arrayB[0],10)}}else if(parseInt(textA,10)){textA=parseInt(textA,10);textB=parseInt(textB,10)}if(textA>textB)return 1;if(textA<textB)return -1;return 0};const toggle=()=>{const c=order!=="sorting-asc"?"sorting-asc":"sorting-desc";th.className=(th.className.replace(order,"")+" "+c).trim();return order=c};const reset=()=>{th.classList.remove("sorting-asc","sorting-desc");th.classList.add("sorting");return order=""};const onClickEvent=e=>{if(th&&cellIndex!==e.target.cellIndex)reset();th=e.target;if(th.nodeName.toLowerCase()==="th"){cellIndex=th.cellIndex;const tbody=th.offsetParent.getElementsByTagName("tbody")[0];let rows=Array.from(tbody.rows);if(rows){rows.sort(sort);if(order==="sorting-asc")rows.reverse();toggle();tbody.innerHtml="";rows.forEach(row=>{tbody.appendChild(row)})}}};return{init:()=>{let ths=document.getElementsByTagName("th");let results=[];for(let i=0,len=ths.length;i<len;i++){let th=ths[i];th.classList.add("sorting");th.classList.add("testing");results.push(th.onclick=onClickEvent)}return results}}})();LightTableSorter.init();

File diff suppressed because one or more lines are too long

View File

@ -1,15 +0,0 @@
{
"name": "Anilist Schema",
"schemaPath": "schema.graphql",
"extensions": {
"endpoints": {
"Anilist": {
"url": "https://graphql.anilist.co",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": true
}
}
}
}

View File

@ -189,7 +189,7 @@ final class Model
*/
public function deleteItem(FormItem $data, string $type): ?Request
{
$mediaId = $this->getMediaId((array)$data, $type);
$mediaId = $this->getMediaId((array) $data, $type);
if ($mediaId === NULL)
{
return NULL;
@ -209,7 +209,7 @@ final class Model
*/
public function getListIdFromData(FormItem $data, string $type = 'ANIME'): ?string
{
$mediaId = $this->getMediaId((array)$data, $type);
$mediaId = $this->getMediaId((array) $data, $type);
if ($mediaId === NULL)
{
return NULL;
@ -244,7 +244,7 @@ final class Model
/**
* Find the id to update by
*/
private function getMediaId (array $data, string $type = 'ANIME'): ?string
private function getMediaId(array $data, string $type = 'ANIME'): ?string
{
if (isset($data['anilist_id']))
{

View File

@ -0,0 +1,8 @@
schema: schema.graphql
extensions:
endpoints:
Anilist:
url: https://graphql.anilist.co
headers:
user-agent: JS GraphQL
introspect: true

View File

@ -1,15 +0,0 @@
{
"name": "Kitsu Schema",
"schemaPath": "schema.graphql",
"extensions": {
"endpoints": {
"Kitsu": {
"url": "https://kitsu.io/api/graphql",
"headers": {
"user-agent": "JS GraphQL"
},
"introspect": true
}
}
}
}

View File

@ -52,7 +52,7 @@ final class Auth
->getSegment(SESSION_SEGMENT);
$this->model = $container->get('kitsu-model');
Event::on('::unauthorized::', [$this, 'reAuthenticate']);
Event::on('::unauthorized::', $this->reAuthenticate(...));
}
/**
@ -133,7 +133,7 @@ final class Auth
/**
* Save the new authentication information
*/
private function storeAuth(array|FALSE $auth): bool
private function storeAuth(array|false $auth): bool
{
if (FALSE !== $auth)
{

View File

@ -34,6 +34,7 @@ use Aviat\AnimeClient\API\{
use Aviat\AnimeClient\Enum\MediaType;
use Aviat\AnimeClient\Kitsu as K;
use Aviat\AnimeClient\Types\{Anime, MangaPage};
use Aviat\AnimeClient\Types\{AnimeListItem, MangaListItem};
use Aviat\Ion\{
Di\ContainerAware,
Json
@ -282,7 +283,7 @@ final class Model
if ($list === NULL)
{
$data = $this->getList(MediaType::ANIME, $status) ?? [];
$data = $this->getList(MediaType::ANIME, $status);
// Bail out on no data
if (empty($data))
@ -319,7 +320,7 @@ final class Model
/**
* Get all the anime entries, that are organized for output to html
*
* @return array<string, mixed[]>
* @return array<string, array>
*/
public function getFullOrganizedAnimeList(): array
{
@ -330,7 +331,7 @@ final class Model
foreach ($statuses as $status)
{
$mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getAnimeList($status) ?? [];
$output[$mappedStatus] = $this->getAnimeList($status);
}
return $output;
@ -412,7 +413,7 @@ final class Model
if ($list === NULL)
{
$data = $this->getList(MediaType::MANGA, $status) ?? [];
$data = $this->getList(MediaType::MANGA, $status);
// Bail out on no data
if (empty($data))
@ -534,14 +535,14 @@ final class Model
* Get the data for a specific list item, generally for editing
*
* @param string $listId - The unique identifier of that list item
* @return mixed
*/
public function getListItem(string $listId)
public function getListItem(string $listId): AnimeListItem|MangaListItem|array
{
$baseData = $this->listItem->get($listId);
if ( ! isset($baseData['data']['findLibraryEntryById']))
{
return [];
// We need to get the errors...
return $baseData;
}
return (new LibraryEntryTransformer())->transform($baseData['data']['findLibraryEntryById']);
@ -566,7 +567,7 @@ final class Model
// this way is much faster...
foreach ($statuses as $status)
{
foreach ($this->getPages([$this, 'getThumbListPages'], strtoupper($type), $status) as $page)
foreach ($this->getPages($this->getThumbListPages(...), strtoupper($type), $status) as $page)
{
$pages[] = $page;
}
@ -596,7 +597,7 @@ final class Model
// this way is much faster...
foreach ($statuses as $status)
{
foreach ($this->getPages([$this, 'getSyncPages'], strtoupper($type), $status) as $page)
foreach ($this->getPages($this->getSyncPages(...), strtoupper($type), $status) as $page)
{
$pages[] = $page;
}
@ -626,7 +627,7 @@ final class Model
{
$pages = [];
foreach ($this->getPages([$this, 'getListPages'], strtoupper($type), strtoupper($status)) as $page)
foreach ($this->getPages($this->getListPages(...), strtoupper($type), strtoupper($status)) as $page)
{
$pages[] = $page;
}
@ -786,7 +787,7 @@ final class Model
}
}
private function getUserId(): string
protected function getUserId(): string
{
static $userId = NULL;

View File

@ -67,9 +67,6 @@ trait MutationTrait
/**
* Remove a list item
*
* @param FormItem $data
* @return Request
*/
public function deleteItem(FormItem $data): Request
{

View File

@ -19,7 +19,7 @@ query ($slug: String!) {
}
categories(first: 100) {
nodes {
title
title(locales: "en")
}
}
characters(first: 100) {
@ -29,7 +29,7 @@ query ($slug: String!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
image {
original {
@ -50,7 +50,7 @@ query ($slug: String!) {
startCursor
}
}
description
description(locales: "en")
startDate
endDate
episodeCount
@ -87,7 +87,7 @@ query ($slug: String!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
slug
}
@ -118,7 +118,7 @@ query ($slug: String!) {
alternatives
canonical
canonicalLocale
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
totalLength
youtubeTrailerVideoId

View File

@ -19,7 +19,7 @@ query ($id: ID!) {
}
categories(first: 100) {
nodes {
title
title(locales: "en")
}
}
characters(first: 100) {
@ -29,7 +29,7 @@ query ($id: ID!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
image {
original {
@ -50,7 +50,7 @@ query ($id: ID!) {
startCursor
}
}
description
description(locales: "en")
startDate
endDate
episodeCount
@ -87,7 +87,7 @@ query ($id: ID!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
slug
}
@ -118,7 +118,7 @@ query ($id: ID!) {
alternatives
canonical
canonicalLocale
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
totalLength
youtubeTrailerVideoId

View File

@ -6,12 +6,12 @@ query ($slug: String!) {
url
}
}
description
description(locales: "en")
names {
alternatives
canonical
canonicalLocale
localized
localized(locales: "*")
},
media(first: 100) {
nodes {
@ -22,7 +22,7 @@ query ($slug: String!) {
alternatives
canonical
canonicalLocale
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
posterImage {
original {
@ -41,7 +41,7 @@ query ($slug: String!) {
type
}
role
voices(first: 10) {
voices(first: 10, locale:"*", sort:{direction:ASCENDING, on: UPDATED_AT}) {
nodes {
id
locale
@ -53,7 +53,7 @@ query ($slug: String!) {
alternatives
canonical
canonicalLocale
localized
localized(locales: "*")
}
image {
original {
@ -70,4 +70,4 @@ query ($slug: String!) {
}
slug
}
}
}

View File

@ -57,7 +57,7 @@ query (
type
titles {
canonical
localized
localized(locales: "*")
alternatives
}
...on Anime {

View File

@ -16,7 +16,7 @@ query($id: ID!) {
ageRating
categories(first: 100) {
nodes {
title
title(locales: "*")
}
}
mappings(first: 10) {
@ -41,7 +41,7 @@ query($id: ID!) {
endDate
titles {
canonical
localized
localized(locales: "*")
canonicalLocale
}
type

View File

@ -33,7 +33,7 @@ query ($slug: String!) {
titles {
alternatives
canonical
localized
localized(locales: "*")
}
...on Anime {
episodeCount

View File

@ -19,7 +19,7 @@ query ($slug: String!) {
}
categories(first: 100) {
nodes {
title
title(locales: "en")
}
}
chapterCount
@ -51,7 +51,7 @@ query ($slug: String!) {
startCursor
}
}
description
description(locales: "en")
startDate
endDate
mappings(first: 10) {
@ -98,7 +98,7 @@ query ($slug: String!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
slug
}
@ -116,7 +116,7 @@ query ($slug: String!) {
titles {
canonical
canonicalLocale
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
}
}

View File

@ -19,7 +19,7 @@ query ($id: ID!) {
}
categories(first: 100) {
nodes {
title
title(locales: "*")
}
}
chapterCount
@ -116,7 +116,7 @@ query ($id: ID!) {
titles {
canonical
canonicalLocale
localized
localized(locales: "*")
}
}
}

View File

@ -1,7 +1,7 @@
query ($slug: String!) {
findPersonBySlug(slug: $slug) {
id
description
description(locales: "en")
birthday
image {
original {
@ -20,7 +20,7 @@ query ($slug: String!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
mediaStaff(first: 100) {
nodes {
@ -47,7 +47,7 @@ query ($slug: String!) {
titles {
alternatives
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
}
}
@ -91,7 +91,7 @@ query ($slug: String!) {
}
titles {
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
}
}

View File

@ -26,7 +26,7 @@ query ($type: MediaTypeEnum!) {
}
categories(first: 100) {
nodes {
title
title(locales: "*")
}
}
characters(first: 100) {
@ -36,7 +36,7 @@ query ($type: MediaTypeEnum!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
image {
original {
@ -90,7 +90,7 @@ query ($type: MediaTypeEnum!) {
names {
alternatives
canonical
localized
localized(locales: "*")
}
slug
}
@ -108,7 +108,7 @@ query ($type: MediaTypeEnum!) {
alternatives
canonical
canonicalLocale
localized
localized(locales: "*")
}
...on Anime {
episodeCount

View File

@ -19,7 +19,7 @@ query ($query: String!) {
slug
titles {
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
alternatives
}
myLibraryEntry {

View File

@ -19,7 +19,7 @@ query ($query: String!) {
slug
titles {
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
alternatives
}
myLibraryEntry {

View File

@ -18,8 +18,10 @@ query ($slug: String!) {
}
}
birthday
createdAt
id
location
gender
name
proMessage
proTier
@ -52,7 +54,7 @@ query ($slug: String!) {
}
titles {
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
}
...on Manga {
@ -72,7 +74,7 @@ query ($slug: String!) {
}
titles {
canonical
localized
localized(locales: ["en", "en-t-ja", "ja", "ja-jp"])
}
}
...on Person {
@ -88,11 +90,12 @@ query ($slug: String!) {
width
}
}
name,
names {
alternatives
canonical
canonicalLocale
localized
localized(locales: "*")
},
}
...on Character {
@ -107,12 +110,12 @@ query ($slug: String!) {
height
width
}
}
},
names {
alternatives
canonical
canonicalLocale
localized
localized(locales: "*")
},
}
}
@ -150,7 +153,7 @@ query ($slug: String!) {
names {
canonical
alternatives
localized
localized(locales: "*")
}
}
waifuOrHusbando

View File

@ -78,7 +78,7 @@ final class RequestBuilder extends APIRequestBuilder
elseif ($url !== K::AUTH_URL && $sessionSegment->get('auth_token') !== NULL)
{
$token = $sessionSegment->get('auth_token');
if ( ! (empty($token) || $cache->has(K::AUTH_TOKEN_CACHE_KEY)))
if ( ! empty($token))
{
$cache->set(K::AUTH_TOKEN_CACHE_KEY, $token);
}
@ -239,43 +239,4 @@ final class RequestBuilder extends APIRequestBuilder
'body' => $body,
]);
}
/**
* Make a request
*/
private function request(string $type, string $url, array $options = []): array
{
$logger = $this->container->getLogger('kitsu-request');
$response = $this->getResponse($type, $url, $options);
$statusCode = $response->getStatus();
// Check for requests that are unauthorized
if ($statusCode === 401 || $statusCode === 403)
{
Event::emit(EventType::UNAUTHORIZED);
}
$rawBody = wait($response->getBody()->buffer());
// Any other type of failed request
if ($statusCode > 299 || $statusCode < 200)
{
if ($logger !== NULL)
{
$logger->warning('Non 2xx response for api call', (array) $response);
}
}
try
{
return Json::decode($rawBody);
}
catch (JsonException)
{
// dump($e);
dump($rawBody);
exit();
}
}
}

View File

@ -23,8 +23,6 @@ trait RequestBuilderTrait
/**
* Set the request builder object
*
* @return ListItem|Model|RequestBuilderTrait
*/
public function setRequestBuilder(RequestBuilder $requestBuilder): self
{

View File

@ -36,7 +36,8 @@ final class AnimeTransformer extends AbstractTransformer
$characters = [];
$links = [];
$staff = [];
$genres = array_map(static fn ($genre) => $genre['title']['en'], $base['categories']['nodes']);
$rawGenres = array_filter($base['categories']['nodes'], static fn ($c) => $c !== NULL);
$genres = array_map(static fn ($genre) => $genre['title']['en'], $rawGenres);
sort($genres);
@ -56,7 +57,7 @@ final class AnimeTransformer extends AbstractTransformer
$details = $rawCharacter['character'];
$characters[$type][$details['id']] = [
'image' => $details['image']['original']['url'] ?? '',
'image' => Kitsu::getImage($details),
'name' => $details['names']['canonical'],
'slug' => $details['slug'],
];
@ -100,7 +101,7 @@ final class AnimeTransformer extends AbstractTransformer
$staff[$role][$person['id']] = [
'id' => $person['id'],
'name' => $name,
'image' => $person['image']['original']['url'],
'image' => Kitsu::getImage($person),
'slug' => $person['slug'],
];

View File

@ -49,7 +49,7 @@ final class CharacterTransformer extends AbstractTransformer
'castings' => $castings,
'description' => $data['description']['en'],
'id' => $data['id'],
'image' => $data['image']['original']['url'] ?? 'images/placeholder.png',
'image' => Kitsu::getImage($data),
'media' => $media,
'name' => $name,
'names' => $names,
@ -130,7 +130,7 @@ final class CharacterTransformer extends AbstractTransformer
'person' => [
'id' => $voice['person']['id'],
'slug' => $voice['person']['slug'],
'image' => $voice['person']['image']['original']['url'],
'image' => Kitsu::getImage($voice['person']),
'name' => $voice['person']['name'],
],
'series' => [],

View File

@ -54,10 +54,10 @@ final class MangaTransformer extends AbstractTransformer
}
$details = $rawCharacter['character'];
if (array_key_exists($details['id'], $characters[$type]))
if (array_key_exists($details['id'], (array)$characters[$type]))
{
$characters[$type][$details['id']] = [
'image' => $details['image']['original']['url'],
'image' => Kitsu::getImage($details),
'name' => $details['names']['canonical'],
'slug' => $details['slug'],
];
@ -103,7 +103,7 @@ final class MangaTransformer extends AbstractTransformer
'id' => $person['id'],
'slug' => $person['slug'],
'name' => $name,
'image' => $person['image']['original']['url'],
'image' => Kitsu::getImage($person),
];
usort($staff[$role], static fn ($a, $b) => $a['name'] <=> $b['name']);

View File

@ -35,7 +35,8 @@ final class PersonTransformer extends AbstractTransformer
return Person::from([
'id' => $data['id'],
'name' => $canonicalName,
'image' => $data['image']['original']['url'],
'birthday' => $data['birthday'],
'image' => Kitsu::getImage($data),
'names' => array_diff($data['names']['localized'], [$canonicalName]),
'description' => $data['description']['en'] ?? '',
'characters' => $orgData['characters'],
@ -97,7 +98,12 @@ final class PersonTransformer extends AbstractTransformer
{
foreach ($data['voices']['nodes'] as $voicing)
{
$character = $voicing['mediaCharacter']['character'];
if ($voicing === NULL)
{
continue;
}
$character = $voicing['mediaCharacter']['character'] ?? [];
$charId = $character['id'];
$rawMedia = $voicing['mediaCharacter']['media'];
$role = strtolower($voicing['mediaCharacter']['role']);
@ -123,7 +129,7 @@ final class PersonTransformer extends AbstractTransformer
'character' => [
'id' => $character['id'],
'slug' => $character['slug'],
'image' => $character['image']['original']['url'],
'image' => Kitsu::getImage($character),
'canonicalName' => $character['names']['canonical'],
],
'media' => [

View File

@ -14,11 +14,11 @@
namespace Aviat\AnimeClient\API\Kitsu\Transformer;
use Aviat\AnimeClient\Kitsu;
use Aviat\AnimeClient\Types\User;
use Aviat\Ion\Transformer\AbstractTransformer;
use function Aviat\AnimeClient\{formatDate, friendlyTime, getDateDiff};
/**
* Transform user profile data for display
*
@ -39,15 +39,22 @@ final class UserTransformer extends AbstractTransformer
] : [];
return User::from([
'about' => $base['about'],
'avatar' => $base['avatarImage']['original']['url'],
'about' => $base['about'] ?? '',
'avatar' => $base['avatarImage']['original']['url'] ?? NULL,
'birthday' => $base['birthday'] !== NULL
? formatDate($base['birthday']) . ' (' .
friendlyTime(getDateDiff($base['birthday']), 'year') . ')'
: NULL,
'joinDate' => formatDate($base['createdAt']) . ' (' .
friendlyTime(getDateDiff($base['createdAt']), 'day') . ' ago)',
'gender' => $base['gender'],
'favorites' => $this->organizeFavorites($favorites),
'location' => $base['location'],
'name' => $base['name'],
'slug' => $base['slug'],
'stats' => $this->organizeStats($stats),
'waifu' => $waifu,
'website' => $base['siteLinks']['nodes'][0]['url'],
'website' => $base['siteLinks']['nodes'][0]['url'] ?? NULL,
]);
}
@ -81,7 +88,7 @@ final class UserTransformer extends AbstractTransformer
if (array_key_exists('animeAmountConsumed', $stats))
{
$animeStats = [
'Time spent watching anime:' => Kitsu::friendlyTime($stats['animeAmountConsumed']['time']),
'Time spent watching anime:' => friendlyTime($stats['animeAmountConsumed']['time']),
'Anime series watched:' => number_format($stats['animeAmountConsumed']['media']),
'Anime episodes watched:' => number_format($stats['animeAmountConsumed']['units']),
];

View File

@ -0,0 +1,8 @@
schema: schema.graphql
extensions:
endpoints:
Kitsu:
url: https://kitsu.io/api/graphql
headers:
user-agent: JS GraphQL
introspect: true

View File

@ -35,7 +35,7 @@ final class ParallelAPIRequest
/**
* Add a request
*/
public function addRequest(string|Request $request, string|int|NULL $key = NULL): self
public function addRequest(string|Request $request, string|int|null $key = NULL): self
{
if ($key !== NULL)
{
@ -56,7 +56,7 @@ final class ParallelAPIRequest
*/
public function addRequests(array $requests): self
{
array_walk($requests, [$this, 'addRequest']);
array_walk($requests, $this->addRequest(...));
return $this;
}

View File

@ -17,6 +17,7 @@ namespace Aviat\AnimeClient;
use Amp\Http\Client\{HttpClient, HttpClientBuilder, Request, Response};
use Aviat\Ion\{ConfigInterface, ImageBuilder};
use DateTimeImmutable;
use Psr\SimpleCache\CacheInterface;
use Throwable;
@ -25,13 +26,17 @@ use Yosymfony\Toml\{Toml, TomlBuilder};
use function Amp\Promise\wait;
use function Aviat\Ion\_dir;
const SECONDS_IN_MINUTE = 60;
const MINUTES_IN_HOUR = 60;
const MINUTES_IN_DAY = 1440;
const MINUTES_IN_YEAR = 525_600;
// ----------------------------------------------------------------------------
//! TOML Functions
// ----------------------------------------------------------------------------
/**
* Load configuration options from .toml files
*
* @codeCoverageIgnore
* @param string $path - Path to load config
*/
function loadConfig(string $path): array
@ -72,8 +77,6 @@ function loadConfig(string $path): array
/**
* Load config from one specific TOML file
*
* @codeCoverageIgnore
*/
function loadTomlFile(string $filename): array
{
@ -131,19 +134,6 @@ function tomlToArray(string $toml): array
//! Misc Functions
// ----------------------------------------------------------------------------
if ( ! function_exists('array_is_list'))
{
/**
* Polyfill for PHP 8
*
* @see https://www.php.net/manual/en/function.array-is-list
*/
function array_is_list(array $a): bool
{
return $a === [] || (array_keys($a) === range(0, count($a) - 1));
}
}
/**
* Is the array sequential, not associative?
*/
@ -256,8 +246,6 @@ function getLocalImg(string $kitsuUrl, bool $webp = TRUE): string
/**
* Create a transparent placeholder image
*
* @codeCoverageIgnore
*/
function createPlaceholderImage(string $path, int $width = 200, int $height = 200, string $text = 'Image Unavailable'): bool
{
@ -303,15 +291,13 @@ function clearCache(CacheInterface $cache): bool
$cleared = $cache->clear();
$saved = (empty($userData)) ? TRUE : $cache->setMultiple($userData);
$saved = empty($userData) || $cache->setMultiple($userData);
return $cleared && $saved;
}
/**
* Render a PHP code template as a string
*
* @codeCoverageIgnore
*/
function renderTemplate(string $path, array $data): string
{
@ -322,3 +308,87 @@ function renderTemplate(string $path, array $data): string
return (is_string($rawOutput)) ? $rawOutput : '';
}
function formatDate(string $date): string
{
$date = new DateTimeImmutable($date);
return $date->format('F d, Y');
}
function getDateDiff(string $date): int
{
$now = new DateTimeImmutable();
$then = new DateTimeImmutable($date);
$interval = $now->diff($then, TRUE);
$years = $interval->y * SECONDS_IN_MINUTE * MINUTES_IN_YEAR;
$days = $interval->d * SECONDS_IN_MINUTE * MINUTES_IN_DAY;
$hours = $interval->h * SECONDS_IN_MINUTE * MINUTES_IN_HOUR;
$minutes = $interval->i * SECONDS_IN_MINUTE;
$seconds = $interval->s;
return $years + $days + $hours + $minutes + $seconds;
}
/**
* Convert a time in seconds to a more human-readable format
*/
function friendlyTime(int $seconds, string $minUnit = 'second'): string
{
// All the seconds left
$remSeconds = $seconds % SECONDS_IN_MINUTE;
$minutes = ($seconds - $remSeconds) / SECONDS_IN_MINUTE;
// Minutes short of a year
$years = (int) floor($minutes / MINUTES_IN_YEAR);
$minutes %= MINUTES_IN_YEAR;
// Minutes short of a day
$extraMinutes = $minutes % MINUTES_IN_DAY;
$days = ($minutes - $extraMinutes) / MINUTES_IN_DAY;
// Minutes short of an hour
$remMinutes = $extraMinutes % MINUTES_IN_HOUR;
$hours = ($extraMinutes - $remMinutes) / MINUTES_IN_HOUR;
$parts = [];
foreach ([
'year' => $years,
'day' => $days,
'hour' => $hours,
'minute' => $remMinutes,
'second' => $remSeconds,
] as $label => $value)
{
if ($value === 0)
{
continue;
}
if ($value > 1)
{
$label .= 's';
}
$parts[] = "{$value} {$label}";
if ($label === $minUnit || $label === $minUnit . 's')
{
break;
}
}
$last = array_pop($parts);
if (empty($parts))
{
return $last ?? '';
}
return (count($parts) > 1)
? implode(', ', $parts) . ", and {$last}"
: "{$parts[0]}, {$last}";
}

View File

@ -44,7 +44,7 @@ abstract class BaseCommand extends Command
/**
* Echo text in a box
*/
public function echoBox(string|array $message, string|int|NULL $fgColor = NULL, string|int|NULL $bgColor = NULL): void
public function echoBox(string|array $message, string|int|null $fgColor = NULL, string|int|null $bgColor = NULL): void
{
if (is_array($message))
{
@ -131,7 +131,7 @@ abstract class BaseCommand extends Command
return $this->_di($configArray, $APP_DIR);
}
private function _line(string $message, int|string|NULL $fgColor = NULL, int|string|NULL $bgColor = NULL): void
private function _line(string $message, int|string|null $fgColor = NULL, int|string|null $bgColor = NULL): void
{
if ($fgColor !== NULL)
{

View File

@ -98,21 +98,23 @@ final class SyncLists extends BaseCommand
if ( ! $anilistEnabled)
{
$this->echoErrorBox('Anlist API is not enabled. Can not sync.');
return false;
return FALSE;
}
// Authentication is required to update Kitsu
$isKitsuAuthenticated = $this->container->get('auth')->isAuthenticated();
if ( !$isKitsuAuthenticated)
if ( ! $isKitsuAuthenticated)
{
$this->echoErrorBox('Kitsu is not authenticated. Kitsu list can not be updated.');
return false;
return FALSE;
}
$this->anilistModel = $this->container->get('anilist-model');
$this->kitsuModel = $this->container->get('kitsu-model');
return true;
return TRUE;
}
/**
@ -148,7 +150,7 @@ final class SyncLists extends BaseCommand
*/
protected function fetch(string $type): array
{
$this->echo("Fetching $type List Data");
$this->echo("Fetching {$type} List Data");
$progress = new Widgets\ProgressBar($this->getConsole(), 2, 50, FALSE);
$anilist = $this->fetchAnilist($type);

View File

@ -42,6 +42,11 @@ class Controller
{
use ContainerAware;
/**
* The global configuration object
*/
public ConfigInterface $config;
/**
* The authentication object
*/
@ -52,11 +57,6 @@ class Controller
*/
protected CacheInterface $cache;
/**
* The global configuration object
*/
public ConfigInterface $config;
/**
* Request object
*/
@ -120,197 +120,194 @@ class Controller
Event::on(EventType::RESET_CACHE_KEY, fn (string $key) => $this->cache->delete($key));
}
/**
* Set the current url in the session as the target of a future redirect
*
* @codeCoverageIgnore
* @throws ContainerException
* @throws NotFoundException
*/
public function setSessionRedirect(?string $url = NULL): void
{
$serverParams = $this->request->getServerParams();
/**
* Set the current url in the session as the target of a future redirect
*
* @throws ContainerException
* @throws NotFoundException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function setSessionRedirect(?string $url = NULL): void
{
$serverParams = $this->request->getServerParams();
if ( ! array_key_exists('HTTP_REFERER', $serverParams))
{
return;
}
if ( ! array_key_exists('HTTP_REFERER', $serverParams))
{
return;
}
$util = $this->container->get('util');
$doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri();
$isLoginPage = str_contains($serverParams['HTTP_REFERER'], 'login');
$util = $this->container->get('util');
$doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri();
$isLoginPage = str_contains($serverParams['HTTP_REFERER'], 'login');
// Don't attempt to set the redirect url if
// the page is one of the form type pages,
// and the previous page is also a form type
if ($doubleFormPage || $isLoginPage)
{
return;
}
// Don't attempt to set the redirect url if
// the page is one of the form type pages,
// and the previous page is also a form type
if ($doubleFormPage || $isLoginPage)
{
return;
}
if (NULL === $url)
{
$url = $util->isViewPage()
? (string) $this->request->getUri()
: $serverParams['HTTP_REFERER'];
}
if (NULL === $url)
{
$url = $util->isViewPage()
? (string) $this->request->getUri()
: $serverParams['HTTP_REFERER'];
}
$this->session->set('redirect_url', $url);
}
$this->session->set('redirect_url', $url);
}
/**
* Redirect to the url previously set in the session
*
* If one is not set, redirect to default url
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
public function sessionRedirect(): void
{
$target = $this->session->get('redirect_url') ?? '/';
/**
* Redirect to the url previously set in the session
*
* If one is not set, redirect to default url
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function sessionRedirect(): void
{
$target = $this->session->get('redirect_url') ?? '/';
$this->redirect($target, 303);
$this->session->set('redirect_url', NULL);
}
$this->redirect($target, 303);
$this->session->set('redirect_url', NULL);
}
/**
* Check if the current user is authenticated, else error and exit
* @codeCoverageIgnore
*/
protected function checkAuth(): void
{
if ( ! $this->auth->isAuthenticated())
{
$this->errorPage(
403,
'Forbidden',
'You must <a href="/login">log in</a> to perform this action.'
);
}
}
/**
* Check if the current user is authenticated, else error and exit
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function checkAuth(): void
{
if ( ! $this->auth->isAuthenticated())
{
$this->errorPage(
403,
'Forbidden',
'You must <a href="/login">log in</a> to perform this action.'
);
}
}
/**
* Get the string output of a partial template
*
* @codeCoverageIgnore
*/
protected function loadPartial(HtmlView $view, string $template, array $data = []): string
{
$router = $this->container->get('dispatcher');
/**
* Get the string output of a partial template
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function loadPartial(HtmlView $view, string $template, array $data = []): string
{
$router = $this->container->get('dispatcher');
if (isset($this->baseData))
{
$data = array_merge($this->baseData, $data);
}
if (isset($this->baseData))
{
$data = array_merge($this->baseData, $data);
}
$route = $router->getRoute();
$data['route_path'] = $route !== FALSE ? $route->path : '';
$route = $router->getRoute();
$data['route_path'] = $route !== FALSE ? $route->path : '';
$templatePath = _dir($this->config->get('view_path'), "{$template}.php");
$templatePath = _dir($this->config->get('view_path'), "{$template}.php");
if ( ! is_file($templatePath))
{
throw new InvalidArgumentException("Invalid template : {$template}");
}
if ( ! is_file($templatePath))
{
throw new InvalidArgumentException("Invalid template : {$template}");
}
return $view->renderTemplate($templatePath, $data);
}
return $view->renderTemplate($templatePath, $data);
}
/**
* Render a template with header and footer
*
* @codeCoverageIgnore
*/
protected function renderFullPage(HtmlView $view, string $template, array $data): HtmlView
{
$csp = [
"default-src 'self' media.kitsu.io kitsu-production-media.s3.us-west-002.backblazeb2.com",
"object-src 'none'",
"child-src 'self' *.youtube.com polyfill.io",
];
/**
* Render a template with header and footer
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function renderFullPage(HtmlView $view, string $template, array $data): HtmlView
{
$csp = [
"default-src 'self' media.kitsu.io kitsu-production-media.s3.us-west-002.backblazeb2.com",
"object-src 'none'",
"child-src 'self' *.youtube.com polyfill.io",
];
$view->addHeader('Content-Security-Policy', implode('; ', $csp));
$view->appendOutput($this->loadPartial($view, 'header', $data));
$view->addHeader('Content-Security-Policy', implode('; ', $csp));
$view->appendOutput($this->loadPartial($view, 'header', $data));
if (array_key_exists('message', $data) && is_array($data['message']))
{
$view->appendOutput($this->loadPartial($view, 'message', $data['message']));
}
if (array_key_exists('message', $data) && is_array($data['message']))
{
$view->appendOutput($this->loadPartial($view, 'message', $data['message']));
}
$view->appendOutput($this->loadPartial($view, $template, $data));
$view->appendOutput($this->loadPartial($view, 'footer', $data));
$view->appendOutput($this->loadPartial($view, $template, $data));
$view->appendOutput($this->loadPartial($view, 'footer', $data));
return $view;
}
return $view;
}
/**
* 404 action
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
public function notFound(
string $title = 'Sorry, page not found',
string $message = 'Page Not Found'
): void {
$this->outputHTML('404', [
'title' => $title,
'message' => $message,
], NULL, 404);
/**
* 404 action
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function notFound(
string $title = 'Sorry, page not found',
string $message = 'Page Not Found'
): never {
$this->outputHTML('404', [
'title' => $title,
'message' => $message,
], NULL, 404);
exit();
}
exit();
}
/**
* Display a generic error page
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
public function errorPage(int $httpCode, string $title, string $message, string $longMessage = ''): void
{
$this->outputHTML('error', [
'title' => $title,
'message' => $message,
'long_message' => $longMessage,
], NULL, $httpCode);
}
/**
* Display a generic error page
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function errorPage(int $httpCode, string $title, string $message, string $longMessage = ''): void
{
$this->outputHTML('error', [
'title' => $title,
'message' => $message,
'long_message' => $longMessage,
], NULL, $httpCode);
}
/**
* Redirect to the default controller/url from an empty path
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
public function redirectToDefaultRoute(): void
{
$defaultType = $this->config->get('default_list');
$this->redirect($this->urlGenerator->defaultUrl($defaultType), 303);
}
/**
* Redirect to the default controller/url from an empty path
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function redirectToDefaultRoute(): void
{
$defaultType = $this->config->get('default_list');
$this->redirect($this->urlGenerator->defaultUrl($defaultType), 303);
}
/**
* Set a session flash variable to display a message on
* next page load
*
* @codeCoverageIgnore
*/
public function setFlashMessage(string $message, string $type = 'info'): void
{
static $messages;
/**
* Set a session flash variable to display a message on
* next page load
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
public function setFlashMessage(string $message, string $type = 'info'): void
{
static $messages;
if ( ! $messages)
{
$messages = [];
}
if ( ! $messages)
{
$messages = [];
}
$messages[] = [
'message_type' => $type,
'message' => $message,
];
$messages[] = [
'message_type' => $type,
'message' => $message,
];
$this->session->setFlash('message', $messages);
}
$this->session->setFlash('message', $messages);
}
/**
* Helper for consistent page titles
@ -322,63 +319,62 @@ class Controller
return implode(' &middot; ', $parts);
}
/**
* Add a message box to the page
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
protected function showMessage(HtmlView $view, string $type, string $message): string
{
return $this->loadPartial($view, 'message', [
'message_type' => $type,
'message' => $message,
]);
}
/**
* Add a message box to the page
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function showMessage(HtmlView $view, string $type, string $message): string
{
return $this->loadPartial($view, 'message', [
'message_type' => $type,
'message' => $message,
]);
}
/**
* Output a template to HTML, using the provided data
*
* @codeCoverageIgnore
* @throws InvalidArgumentException
*/
protected function outputHTML(string $template, array $data = [], ?HtmlView $view = NULL, int $code = 200): void
{
if (NULL === $view)
{
$view = new HtmlView($this->container);
}
/**
* Output a template to HTML, using the provided data
*
* @throws InvalidArgumentException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function outputHTML(string $template, array $data = [], ?HtmlView $view = NULL, int $code = 200): void
{
if (NULL === $view)
{
$view = new HtmlView($this->container);
}
$view->setStatusCode($code);
$this->renderFullPage($view, $template, $data)->send();
}
$view->setStatusCode($code);
$this->renderFullPage($view, $template, $data)->send();
}
/**
* Output a JSON Response
*
* @codeCoverageIgnore
* @param int $code - the http status code
* @throws DoubleRenderException
*/
protected function outputJSON(mixed $data, int $code): void
{
JsonView::new()
->setOutput($data)
->setStatusCode($code)
->send();
}
/**
* Output a JSON Response
*
* @param int $code - the http status code
* @throws DoubleRenderException
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function outputJSON(mixed $data, int $code): void
{
JsonView::new()
->setOutput($data)
->setStatusCode($code)
->send();
}
/**
* Redirect to the selected page
*
* @codeCoverageIgnore
*/
protected function redirect(string $url, int $code): void
{
HttpView::new()
->redirect($url, $code)
->send();
}
/**
* Redirect to the selected page
*/
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
protected function redirect(string $url, int $code): void
{
HttpView::new()
->redirect($url, $code)
->send();
}
}
// End of BaseController.php

View File

@ -21,8 +21,7 @@ use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Model\Anime as AnimeModel;
use Aviat\AnimeClient\Types\FormItem;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Json;
@ -307,8 +306,6 @@ final class Anime extends BaseController
'Anime not found',
'Anime Not Found'
);
return;
}
$this->outputHTML('anime/details', [
@ -346,8 +343,6 @@ final class Anime extends BaseController
'Anime not found',
'Anime Not Found'
);
return;
}
$this->outputHTML('anime/details', [

View File

@ -20,11 +20,9 @@ use Aviat\AnimeClient\Model\{
Anime as AnimeModel,
AnimeCollection as AnimeCollectionModel
};
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Json;
use Aviat\Ion\Exception\DoubleRenderException;
use InvalidArgumentException;
@ -114,7 +112,6 @@ final class AnimeCollection extends BaseController
/**
* Show the anime collection add/edit form
*
* @param int|null $id
* @throws ContainerException
* @throws InvalidArgumentException
* @throws NotFoundException

View File

@ -18,8 +18,7 @@ use Aviat\AnimeClient\API\Kitsu\Model;
use Aviat\AnimeClient\API\Kitsu\Transformer\CharacterTransformer;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
@ -60,8 +59,6 @@ final class Character extends BaseController
),
'Character Not Found'
);
return;
}
$data = (new CharacterTransformer())->transform($rawData)->toArray();

View File

@ -14,9 +14,8 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\AnimeClient\{Controller as BaseController, Model};
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};

View File

@ -15,8 +15,7 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Throwable;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\{createPlaceholderImage, getResponse};
@ -97,7 +96,7 @@ final class Images extends BaseController
$kitsuUrl .= $imageType['kitsuUrl'];
$width = $imageType['width'];
$height = $imageType['height'];
$height = $imageType['height'] ?? 225;
$filePrefix = "{$baseSavePath}/{$type}/{$id}";
$response = getResponse($kitsuUrl);
@ -121,11 +120,11 @@ final class Images extends BaseController
if ($display)
{
$this->getPlaceholder("{$baseSavePath}/{$type}", $width, $height);
$this->getPlaceholder("{$baseSavePath}/{$type}", $width ?? 225, $height);
}
else
{
createPlaceholderImage("{$baseSavePath}/{$type}", $width, $height);
createPlaceholderImage("{$baseSavePath}/{$type}", $width ?? 225, $height);
}
return;
@ -133,7 +132,13 @@ final class Images extends BaseController
$data = wait($response->getBody()->buffer());
[$origWidth] = getimagesizefromstring($data);
$size = getimagesizefromstring($data);
if ($size === FALSE)
{
return;
}
[$origWidth] = $size;
$gdImg = imagecreatefromstring($data);
if ($gdImg === FALSE)
{
@ -183,15 +188,15 @@ final class Images extends BaseController
/**
* Get a placeholder for a missing image
*/
private function getPlaceholder(string $path, ?int $width = 200, ?int $height = NULL): void
private function getPlaceholder(string $path, ?int $width = NULL, ?int $height = NULL): void
{
$height ??= $width;
$height ??= $width ?? 200;
$filename = $path . '/placeholder.png';
if ( ! file_exists($path . '/placeholder.png'))
{
createPlaceholderImage($path, $width, $height);
createPlaceholderImage($path, $width ?? 200, $height);
}
header('Content-Type: image/png');

View File

@ -14,21 +14,16 @@
namespace Aviat\AnimeClient\Controller;
use Aura\Router\Exception\RouteNotFound;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaListTransformer;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Model\Manga as MangaModel;
use Aviat\AnimeClient\Types\FormItem;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Json;
use InvalidArgumentException;
use Throwable;
/**
* Controller for manga list
*/
@ -282,8 +277,6 @@ final class Manga extends BaseController
'Manga not found',
'Manga Not Found'
);
return;
}
$this->outputHTML('manga/details', [
@ -311,8 +304,6 @@ final class Manga extends BaseController
'Manga not found',
'Manga Not Found'
);
return;
}
$this->outputHTML('manga/details', [

View File

@ -15,12 +15,10 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\API\Kitsu\Model;
use Aviat\AnimeClient\API\Kitsu\Transformer\CharacterTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\PersonTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\{CharacterTransformer, PersonTransformer};
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Enum\EventType;
use Aviat\Ion\Attribute\DefaultController;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{DefaultController, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Event;
use Aviat\Ion\View\HtmlView;
@ -103,11 +101,7 @@ final class Misc extends BaseController
}
$this->setFlashMessage('Invalid username or password.');
$redirectUrl = $this->url->generate('login');
$redirectUrl = ($redirectUrl !== FALSE) ? $redirectUrl : '';
$this->redirect($redirectUrl, 303);
$this->redirect($this->url->generate('login'), 303);
}
/**
@ -147,8 +141,6 @@ final class Misc extends BaseController
),
'Character Not Found'
);
return;
}
$data = (new CharacterTransformer())->transform($rawData)->toArray();
@ -180,8 +172,6 @@ final class Misc extends BaseController
),
'Person Not Found'
);
return;
}
$this->outputHTML('person/details', [

View File

@ -18,8 +18,7 @@ use Aviat\AnimeClient\API\Kitsu\Model;
use Aviat\AnimeClient\API\Kitsu\Transformer\PersonTransformer;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
@ -61,8 +60,6 @@ final class People extends BaseController
),
'Person Not Found'
);
return;
}
$this->outputHTML('person/details', [

View File

@ -18,8 +18,7 @@ use Aura\Router\Exception\RouteNotFound;
use Aviat\AnimeClient\API\Anilist\Model as AnilistModel;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Model\Settings as SettingsModel;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
@ -87,10 +86,7 @@ final class Settings extends BaseController
? $this->setFlashMessage('Saved config settings.', 'success')
: $this->setFlashMessage('Failed to save config file.', 'error');
$redirectUrl = $this->url->generate('settings');
$redirectUrl = ($redirectUrl !== FALSE) ? $redirectUrl : '';
$this->redirect($redirectUrl, 303);
$this->redirect($this->url->generate('settings'), 303);
}
/**
@ -153,9 +149,6 @@ final class Settings extends BaseController
? $this->setFlashMessage('Linked Anilist Account', 'success')
: $this->setFlashMessage('Error Linking Anilist Account', 'error');
$redirectUrl = $this->url->generate('settings');
$redirectUrl = ($redirectUrl !== FALSE) ? $redirectUrl : '';
$this->redirect($redirectUrl, 303);
$this->redirect($this->url->generate('settings'), 303);
}
}

View File

@ -18,8 +18,7 @@ use Aviat\AnimeClient\API\Kitsu\Model;
use Aviat\AnimeClient\API\Kitsu\Transformer\UserTransformer;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\Ion\Attribute\Controller;
use Aviat\Ion\Attribute\Route;
use Aviat\Ion\Attribute\{Controller, Route};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
@ -70,6 +69,11 @@ final class User extends BaseController
: $username;
$rawData = $this->kitsuModel->getUserData($username);
if ($rawData['data']['findProfileBySlug'] === NULL)
{
$this->notFound('Sorry, user not found', "The user '{$username}' does not seem to exist.");
}
$data = (new UserTransformer())->transform($rawData)->toArray();
$this->outputHTML('user/details', [

View File

@ -69,6 +69,44 @@ final class Dispatcher extends RoutingBase
$this->outputRoutes = $this->setupRoutes();
}
/**
* Handle the current route
*
* @throws ReflectionException
*/
public function __invoke(?object $route = NULL): void
{
$logger = $this->container->getLogger();
if ($route === NULL)
{
$route = $this->getRoute();
$logger?->info('Dispatcher - Route invoke arguments');
$logger?->info(print_r($route, TRUE));
}
if ( ! $route)
{
// If not route was matched, return an appropriate http
// error message
$errorRoute = $this->getErrorParams();
$controllerName = DEFAULT_CONTROLLER;
$actionMethod = $errorRoute['action_method'];
$params = $errorRoute['params'];
$this->call($controllerName, $actionMethod, $params);
return;
}
$parsed = $this->processRoute(new Friend($route));
$controllerName = $parsed['controller_name'];
$actionMethod = $parsed['action_method'];
$params = $parsed['params'];
$this->call($controllerName, $actionMethod, $params);
}
/**
* Get the current route object, if one matches
*/
@ -100,47 +138,6 @@ final class Dispatcher extends RoutingBase
return $this->outputRoutes;
}
/**
* Handle the current route
*
* @throws ReflectionException
*/
public function __invoke(?object $route = NULL): void
{
$logger = $this->container->getLogger();
if ($route === NULL)
{
$route = $this->getRoute();
if ($logger !== NULL)
{
$logger->info('Dispatcher - Route invoke arguments');
$logger->info(print_r($route, TRUE));
}
}
if ( ! $route)
{
// If not route was matched, return an appropriate http
// error message
$errorRoute = $this->getErrorParams();
$controllerName = DEFAULT_CONTROLLER;
$actionMethod = $errorRoute['action_method'];
$params = $errorRoute['params'];
$this->call($controllerName, $actionMethod, $params);
return;
}
$parsed = $this->processRoute(new Friend($route));
$controllerName = $parsed['controller_name'];
$actionMethod = $parsed['action_method'];
$params = $parsed['params'];
$this->call($controllerName, $actionMethod, $params);
}
/**
* Parse out the arguments for the appropriate controller for
* the current route
@ -183,10 +180,7 @@ final class Dispatcher extends RoutingBase
}
$logger = $this->container->getLogger();
if ($logger !== NULL)
{
$logger->info(Json::encode($params));
}
$logger?->info(Json::encode($params));
return [
'controller_name' => $controllerName,
@ -208,10 +202,7 @@ final class Dispatcher extends RoutingBase
$controller = reset($segments);
$logger = $this->container->getLogger();
if ($logger !== NULL)
{
$logger->info('Controller: ' . $controller);
}
$logger?->info('Controller: ' . $controller);
if (empty($controller))
{
@ -224,7 +215,7 @@ final class Dispatcher extends RoutingBase
/**
* Get the list of controllers in the default namespace
*
* @return mixed[]
* @return array
*/
public function getControllerList(): array
{
@ -300,7 +291,6 @@ final class Dispatcher extends RoutingBase
/**
* Get the appropriate params for the error page
* passed on the failed route
* @return mixed[][]
*/
protected function getErrorParams(): array
{
@ -317,14 +307,15 @@ final class Dispatcher extends RoutingBase
$params = [];
switch ($failure->failedRule) {
switch ($failure?->failedRule)
{
case Rule\Allows::class:
$params = [
'http_code' => 405,
'title' => '405 Method Not Allowed',
'message' => 'Invalid HTTP Verb',
];
break;
break;
case Rule\Accepts::class:
$params = [
@ -332,12 +323,12 @@ final class Dispatcher extends RoutingBase
'title' => '406 Not Acceptable',
'message' => 'Unacceptable content type',
];
break;
break;
default:
// Fall back to a 404 message
$actionMethod = NOT_FOUND_METHOD;
break;
break;
}
return [
@ -348,8 +339,6 @@ final class Dispatcher extends RoutingBase
/**
* Select controller based on the current url, and apply its relevant routes
*
* @return mixed[]
*/
protected function setupRoutes(): array
{

View File

@ -83,16 +83,16 @@ final class FormGenerator
];
$params['strict'] = TRUE;
unset($params['attribs']['id']);
break;
break;
case 'string':
$params['type'] = 'text';
break;
break;
case 'select':
$params['type'] = 'select';
$params['options'] = array_flip($form['options']);
break;
break;
default:
break;

View File

@ -31,10 +31,6 @@ final class Kitsu
public const ANIME_HISTORY_LIST_CACHE_KEY = 'kitsu-anime-history-list';
public const MANGA_HISTORY_LIST_CACHE_KEY = 'kitsu-manga-history-list';
public const GRAPHQL_ENDPOINT = 'https://kitsu.io/api/graphql';
public const SECONDS_IN_MINUTE = 60;
public const MINUTES_IN_HOUR = 60;
public const MINUTES_IN_DAY = 1440;
public const MINUTES_IN_YEAR = 525_600;
/**
* Determine whether an anime is airing, finished airing, or has not yet aired
@ -72,18 +68,18 @@ final class Kitsu
}
$monthMap = [
'01' => 'Jan',
'02' => 'Feb',
'03' => 'Mar',
'04' => 'Apr',
'01' => 'January',
'02' => 'February',
'03' => 'March',
'04' => 'April',
'05' => 'May',
'06' => 'Jun',
'07' => 'Jul',
'08' => 'Aug',
'09' => 'Sep',
'10' => 'Oct',
'11' => 'Nov',
'12' => 'Dec',
'06' => 'June',
'07' => 'July',
'08' => 'August',
'09' => 'September',
'10' => 'October',
'11' => 'November',
'12' => 'December',
];
[$startYear, $startMonth, $startDay] = explode('-', $startDate);
@ -305,7 +301,16 @@ final class Kitsu
{
// Really don't care about languages that aren't english
// or Japanese for titles
if ( ! in_array($locale, ['en', 'en_us', 'en_jp', 'ja_jp'], TRUE))
if ( ! in_array($locale, [
'en',
'en-jp',
'en-us',
'en_jp',
'en_us',
'ja-jp',
'ja_jp',
'jp',
], TRUE))
{
continue;
}
@ -326,15 +331,29 @@ final class Kitsu
/**
* Get the url of the posterImage from Kitsu, with fallbacks
*/
public static function getPosterImage(array $base, int $size = 1): string
public static function getPosterImage(array $base, int $sizeId = 1): string
{
$rawUrl = $base['posterImage']['views'][$size]['url']
$rawUrl = $base['posterImage']['views'][$sizeId]['url']
?? $base['posterImage']['original']['url']
?? '/public/images/placeholder.png';
$parts = explode('?', $rawUrl);
return (empty($parts)) ? $rawUrl : $parts[0];
return $parts[0];
}
/**
* Get the url of the image from Kitsu, with fallbacks
*/
public static function getImage(array $base, int $sizeId = 1): string
{
$rawUrl = $base['image']['original']['url']
?? $base['image']['views'][$sizeId]['url']
?? '/public/images/placeholder.png';
$parts = explode('?', $rawUrl);
return $parts[0];
}
/**
@ -418,62 +437,6 @@ final class Kitsu
];
}
/**
* Convert a time in seconds to a more human-readable format
*/
public static function friendlyTime(int $seconds): string
{
// All the seconds left
$remSeconds = $seconds % self::SECONDS_IN_MINUTE;
$minutes = ($seconds - $remSeconds) / self::SECONDS_IN_MINUTE;
// Minutes short of a year
$years = (int) floor($minutes / self::MINUTES_IN_YEAR);
$minutes %= self::MINUTES_IN_YEAR;
// Minutes short of a day
$extraMinutes = $minutes % self::MINUTES_IN_DAY;
$days = ($minutes - $extraMinutes) / self::MINUTES_IN_DAY;
// Minutes short of an hour
$remMinutes = $extraMinutes % self::MINUTES_IN_HOUR;
$hours = ($extraMinutes - $remMinutes) / self::MINUTES_IN_HOUR;
$parts = [];
foreach ([
'year' => $years,
'day' => $days,
'hour' => $hours,
'minute' => $remMinutes,
'second' => $remSeconds,
] as $label => $value)
{
if ($value === 0)
{
continue;
}
if ($value > 1)
{
$label .= 's';
}
$parts[] = "{$value} {$label}";
}
$last = array_pop($parts);
if (empty($parts))
{
return $last ?? '';
}
return (count($parts) > 1)
? implode(', ', $parts) . ", and {$last}"
: "{$parts[0]}, {$last}";
}
/**
* Determine if an alternate title is unique enough to list
*/
@ -486,7 +449,7 @@ final class Kitsu
foreach ($existingTitles as $existing)
{
$isSubset = mb_substr_count($existing, $title) > 0;
$isSubset = mb_substr_count(mb_strtolower($existing), mb_strtolower($title)) > 0;
$diff = levenshtein(mb_strtolower($existing), mb_strtolower($title));
if ($diff <= 4 || $isSubset || mb_strlen($title) > 45 || mb_strlen($existing) > 50)

View File

@ -36,6 +36,19 @@ final class MenuGenerator extends UrlGenerator
*/
protected ServerRequestInterface $request;
/**
* MenuGenerator constructor.
*
* @throws ContainerException
* @throws NotFoundException
*/
private function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->helper = $container->get('html-helper');
$this->request = $container->get('request');
}
public static function new(ContainerInterface $container): self
{
return new self($container);
@ -80,19 +93,6 @@ final class MenuGenerator extends UrlGenerator
return (string) $this->helper->ul();
}
/**
* MenuGenerator constructor.
*
* @throws ContainerException
* @throws NotFoundException
*/
private function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->helper = $container->get('html-helper');
$this->request = $container->get('request');
}
/**
* Generate the full menu structure from the config files
*

View File

@ -91,7 +91,7 @@ final class AnimeCollection extends Collection
$genres = $this->getGenreList();
$media = $this->getMediaList();
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -133,7 +133,7 @@ final class AnimeCollection extends Collection
->get();
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -349,7 +349,7 @@ final class AnimeCollection extends Collection
->get()
->fetchAll(PDO::FETCH_ASSOC);
if ($mediaRows === FALSE)
if (empty($mediaRows))
{
return [];
}
@ -411,7 +411,7 @@ final class AnimeCollection extends Collection
->get();
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -479,7 +479,7 @@ final class AnimeCollection extends Collection
->get();
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -659,7 +659,7 @@ final class AnimeCollection extends Collection
->get();
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -691,7 +691,7 @@ final class AnimeCollection extends Collection
->get();
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}
@ -737,7 +737,7 @@ final class AnimeCollection extends Collection
// Add genres associated with each item
$rows = $query->fetchAll(PDO::FETCH_ASSOC);
if ($rows === FALSE)
if (empty($rows))
{
return [];
}

View File

@ -74,7 +74,7 @@ trait MediaTrait
* Get information about a specific list item
* for editing/updating that item
*/
public function getItem(string $itemId): AnimeListItem|MangaListItem
public function getItem(string $itemId): AnimeListItem|MangaListItem|array
{
return $this->kitsuModel->getListItem($itemId);
}

View File

@ -95,14 +95,7 @@ final class Settings
}
}
if (array_key_exists($key, $values) && is_scalar($values[$key]))
{
$value['value'] = $values[$key];
}
else
{
$value['value'] = $value['default'] ?? '';
}
$value['value'] = array_key_exists($key, $values) && is_scalar($values[$key]) ? $values[$key] : $value['default'] ?? '';
foreach (['readonly', 'disabled'] as $flag)
{

View File

@ -20,37 +20,6 @@ use Stringable;
abstract class AbstractType implements ArrayAccess, Countable, Stringable
{
/**
* Populate values for un-serializing data
*/
public static function __set_state(mixed $properties): self
{
return new static($properties);
}
/**
* Check the shape of the object, and return the array equivalent
*/
final public static function check(array $data = []): ?array
{
$currentClass = static::class;
if (get_parent_class($currentClass) !== FALSE)
{
return static::class::from($data)->toArray();
}
return NULL;
}
/**
* Static constructor
*/
final public static function from(mixed $data): static
{
return new static($data);
}
/**
* Sets the properties by using the constructor
*/
@ -73,6 +42,14 @@ abstract class AbstractType implements ArrayAccess, Countable, Stringable
}
}
/**
* Populate values for un-serializing data
*/
public static function __set_state(mixed $properties): self
{
return new static($properties);
}
/**
* See if a property is set
*/
@ -123,6 +100,29 @@ abstract class AbstractType implements ArrayAccess, Countable, Stringable
return print_r($this, TRUE);
}
/**
* Check the shape of the object, and return the array equivalent
*/
final public static function check(array $data = []): ?array
{
$currentClass = static::class;
if (get_parent_class($currentClass) !== FALSE)
{
return static::class::from($data)->toArray();
}
return NULL;
}
/**
* Static constructor
*/
final public static function from(mixed $data): static
{
return new static($data);
}
/**
* Implementing ArrayAccess
*/
@ -201,27 +201,25 @@ abstract class AbstractType implements ArrayAccess, Countable, Stringable
return TRUE;
}
/**
* @codeCoverageIgnore
*/
final protected function fromObject(mixed $parent = NULL): float|NULL|bool|int|array|string
{
$object = $parent ?? $this;
#[\PHPUnit\Framework\Attributes\CodeCoverageIgnore]
final protected function fromObject(mixed $parent = NULL): float|null|bool|int|array|string
{
$object = $parent ?? $this;
if (is_scalar($object) || $object === NULL)
{
return $object;
}
if (is_scalar($object) || $object === NULL)
{
return $object;
}
$output = [];
$output = [];
foreach ($object as $key => $value)
{
$output[$key] = (is_scalar($value) || empty($value))
? $value
: $this->fromObject((array) $value);
}
foreach ($object as $key => $value)
{
$output[$key] = (is_scalar($value) || empty($value))
? $value
: $this->fromObject((array) $value);
}
return $output;
}
return $output;
}
}

View File

@ -21,6 +21,7 @@ final class Person extends AbstractType
{
public string $id;
public ?string $name;
public ?string $birthday;
public string $image;
public array $names = [];
public ?string $description;

View File

@ -21,11 +21,14 @@ final class User extends AbstractType
{
public ?string $about;
public ?string $avatar;
public ?string $birthday;
public string $joinDate;
public ?string $gender;
public ?array $favorites;
public ?string $location;
public ?string $name;
public ?string $slug;
public ?array $stats;
public ?array $waifu;
public array $waifu;
public ?string $website;
}

View File

@ -17,6 +17,9 @@ namespace Aviat\Ion\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class Controller {
public function __construct(public string $prefix = '') {}
}
class Controller
{
public function __construct(public string $prefix = '')
{
}
}

View File

@ -17,4 +17,6 @@ namespace Aviat\Ion\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
class DefaultController {}
class DefaultController
{
}

View File

@ -17,16 +17,15 @@ namespace Aviat\Ion\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
class Route {
public const GET = 'get';
public const POST = 'post';
class Route
{
final public const GET = 'get';
final public const POST = 'post';
public function __construct(
public string $name,
public string $path,
public string $verb = self::GET,
)
{
) {
}
}

View File

@ -39,8 +39,8 @@ class Container implements ContainerInterface
*/
public function __construct(
/**
* Array of container Generator functions
*/
* Array of container Generator functions
*/
protected array $container = []
) {
$this->loggers = [];

View File

@ -25,7 +25,10 @@ class Json
/**
* Encode data in json format
*
* @throws JsonException
* @param mixed $data
* @param int $options
* @param int<1, max> $depth
* @return string
*/
public static function encode(mixed $data, int $options = 0, int $depth = 512): string
{
@ -54,7 +57,11 @@ class Json
/**
* Decode data from json
*
* @throws JsonException
* @param string|null $json
* @param bool $assoc
* @param int<1, max> $depth
* @param int $options
* @return mixed
*/
public static function decode(?string $json, bool $assoc = TRUE, int $depth = 512, int $options = 0): mixed
{
@ -74,7 +81,11 @@ class Json
/**
* Decode json data loaded from the passed filename
*
* @throws JsonException
* @param string $filename
* @param bool $assoc
* @param int<1, max> $depth
* @param int $options
* @return mixed
*/
public static function decodeFile(string $filename, bool $assoc = TRUE, int $depth = 512, int $options = 0): mixed
{

View File

@ -33,7 +33,7 @@ abstract class AbstractTransformer implements TransformerInterface
{
$list = (array) $collection;
return array_map([$this, 'transform'], $list);
return array_map($this->transform(...), $list);
}
/**

View File

@ -65,14 +65,6 @@ class ArrayType
'pop' => 'array_pop',
];
/**
* Create an ArrayType wrapper class from an array
*/
public static function from(array $arr): ArrayType
{
return new ArrayType($arr);
}
/**
* Create an ArrayType wrapper class
*/
@ -108,6 +100,14 @@ class ArrayType
throw new InvalidArgumentException("Method '{$method}' does not exist");
}
/**
* Create an ArrayType wrapper class from an array
*/
public static function from(array $arr): ArrayType
{
return new ArrayType($arr);
}
/**
* Does the passed key exist in the current array?
*/
@ -156,7 +156,7 @@ class ArrayType
/**
* Find an array key by its associated value
*/
public function search(mixed $value, bool $strict = TRUE): int|string|FALSE|null
public function search(mixed $value, bool $strict = TRUE): int|string|false|null
{
return array_search($value, $this->arr, $strict);
}
@ -172,7 +172,7 @@ class ArrayType
/**
* Return the array, or a key
*/
public function &get(string|int|NULL $key = NULL): mixed
public function &get(string|int|null $key = NULL): mixed
{
$value = NULL;
if ($key === NULL)

View File

@ -24,9 +24,9 @@ final class StringType extends Stringy
/**
* Alias for `create` static constructor
*/
public static function from(string $str): self
public static function from(string $str = '', ?string $encoding = NULL): self
{
return self::create($str);
return self::create($str, $encoding);
}
/**

File diff suppressed because it is too large Load Diff

View File

@ -34,11 +34,11 @@ class HtmlView extends HttpView
/**
* Create the Html View
*/
public function __construct(ContainerInterface $container)
public function __construct()
{
parent::__construct();
$this->setContainer($container);
$this->setContainer(func_get_arg(0));
$this->response = new HtmlResponse('');
}

View File

@ -20,6 +20,7 @@ use InvalidArgumentException;
use Laminas\Diactoros\Response;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use PHPUnit\Framework\Attributes\CodeCoverageIgnore;
use Psr\Http\Message\ResponseInterface;
use Stringable;
@ -168,10 +169,10 @@ class HttpView implements HttpViewInterface, Stringable
/**
* Send the appropriate response
*
* @codeCoverageIgnore
* @throws DoubleRenderException
* @throws InvalidArgumentException
*/
#[CodeCoverageIgnore]
protected function output(): void
{
if ($this->hasRendered)

View File

@ -40,8 +40,10 @@ final class APIRequestBuilderTest extends TestCase
$this->builder->setLogger(new NullLogger());
}
public function testGzipRequest(): void
public function testGzipRequest(): never
{
$this->markTestSkipped('Need new test API');
$request = $this->builder->newRequest('GET', 'gzip')
->getFullRequest();
$response = getResponse($request);
@ -49,15 +51,19 @@ final class APIRequestBuilderTest extends TestCase
$this->assertTrue($body['gzipped']);
}
public function testInvalidRequestMethod(): void
public function testInvalidRequestMethod(): never
{
$this->markTestSkipped('Need new test API');
$this->expectException(InvalidArgumentException::class);
$this->builder->newRequest('FOO', 'gzip')
->getFullRequest();
}
public function testRequestWithBasicAuth(): void
public function testRequestWithBasicAuth(): never
{
$this->markTestSkipped('Need new test API');
$request = $this->builder->newRequest('GET', 'headers')
->setBasicAuth('username', 'password')
->getFullRequest();
@ -68,8 +74,10 @@ final class APIRequestBuilderTest extends TestCase
$this->assertSame('Basic dXNlcm5hbWU6cGFzc3dvcmQ=', $body['headers']['Authorization']);
}
public function testRequestWithQueryString(): void
public function testRequestWithQueryString(): never
{
$this->markTestSkipped('Need new test API');
$query = [
'foo' => 'bar',
'bar' => [
@ -96,8 +104,10 @@ final class APIRequestBuilderTest extends TestCase
$this->assertSame($expected, $body['args']);
}
public function testFormValueRequest(): void
public function testFormValueRequest(): never
{
$this->markTestSkipped('Need new test API');
$formValues = [
'bar' => 'foo',
'foo' => 'bar',
@ -113,8 +123,10 @@ final class APIRequestBuilderTest extends TestCase
$this->assertSame($formValues, $body['form']);
}
public function testFullUrlRequest(): void
public function testFullUrlRequest(): never
{
$this->markTestSkipped('Need new test API');
$data = [
'foo' => [
'bar' => 1,

View File

@ -38,7 +38,7 @@ final class AnimeListTransformerTest extends AnimeClientTestCase
$this->transformer = new AnimeListTransformer();
}
public function testTransform(): void
public function testTransform(): never
{
$this->markTestSkipped('Old test data');
@ -46,11 +46,11 @@ final class AnimeListTransformerTest extends AnimeClientTestCase
$this->assertMatchesSnapshot($actual);
}
public function dataUntransform(): array
public static function dataUntransform(): array
{
return [[
'input' => [
'id' => 14047981,
'id' => 14_047_981,
'watching_status' => 'current',
'user_rating' => 8,
'episodes_watched' => 38,
@ -60,7 +60,7 @@ final class AnimeListTransformerTest extends AnimeClientTestCase
],
], [
'input' => [
'id' => 14047981,
'id' => 14_047_981,
'mal_id' => '12345',
'watching_status' => 'current',
'user_rating' => 8,
@ -73,7 +73,7 @@ final class AnimeListTransformerTest extends AnimeClientTestCase
],
], [
'input' => [
'id' => 14047983,
'id' => 14_047_983,
'mal_id' => '12347',
'watching_status' => 'current',
'user_rating' => 0,
@ -87,12 +87,10 @@ final class AnimeListTransformerTest extends AnimeClientTestCase
]];
}
/**
* @dataProvider dataUntransform
*/
public function testUntransform(array $input): void
{
$actual = $this->transformer->untransform($input);
$this->assertMatchesSnapshot($actual);
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataUntransform')]
public function testUntransform(array $input): void
{
$actual = $this->transformer->untransform($input);
$this->assertMatchesSnapshot($actual);
}
}

View File

@ -37,7 +37,7 @@ final class AnimeTransformerTest extends AnimeClientTestCase
$this->transformer = new AnimeTransformer();
}
public function testTransform()
public function testTransform(): never
{
$this->markTestSkipped('May fail on CI');
$actual = $this->transformer->transform($this->beforeTransform);

View File

@ -35,7 +35,7 @@ final class CharacterTransformerTest extends AnimeClientTestCase
$this->beforeTransform = $raw;
}
public function testTransform(): void
public function testTransform(): never
{
$this->markTestSkipped('Fails on CI');
$actual = (new CharacterTransformer())->transform($this->beforeTransform);

View File

@ -35,7 +35,7 @@ final class HistoryTransformerTest extends AnimeClientTestCase
$this->beforeTransform = $raw;
}
public function testAnimeTransform(): void
public function testAnimeTransform(): never
{
$this->markTestSkipped('Old test data');

View File

@ -35,7 +35,7 @@ final class PersonTransformerTest extends AnimeClientTestCase
$this->beforeTransform = $raw;
}
public function testTransform(): void
public function testTransform(): never
{
$this->markTestSkipped('Fails on CI');
$actual = (new PersonTransformer())->transform($this->beforeTransform);

View File

@ -38,6 +38,10 @@ final class UserTransformerTest extends AnimeClientTestCase
public function testTransform(): void
{
$actual = (new UserTransformer())->transform($this->beforeTransform);
// Unset the time value that will change every day, so the test is consistent
$actual->joinDate = '';
$this->assertMatchesSnapshot($actual);
}
}

View File

@ -17,7 +17,7 @@ id: '20286'
manga_type: MANGA
status: Completed
staff:
'Story & Art': [{ id: '8712', slug: ruri-miyahara, name: 'Ruri Miyahara', image: 'https://media.kitsu.io/people/images/8712/original.jpg?1533271952' }]
'Story & Art': [{ id: '8712', slug: ruri-miyahara, name: 'Ruri Miyahara', image: 'https://media.kitsu.io/people/images/8712/original.jpg' }]
synopsis: "Usa, a high-school student aspiring to begin a bachelor lifestyle, moves into a new apartment only to discover that he not only shares a room with a perverted roommate that has an obsession for underaged girls, but also that another girl, Ritsu, a love-at-first-sight, is living in the same building as well!\r\n(Source: Kirei Cake)"
title: 'Bokura wa Minna Kawai-sou'
titles:

View File

@ -15,11 +15,17 @@
namespace Aviat\AnimeClient\Tests;
use DateTime;
use function Aviat\AnimeClient\{arrayToToml, checkFolderPermissions, clearCache, colNotEmpty, getLocalImg, getResponse, isSequentialArray, tomlToArray};
use PHPUnit\Framework\Attributes\IgnoreFunctionForCodeCoverage;
use function Aviat\AnimeClient\{arrayToToml, checkFolderPermissions, clearCache, colNotEmpty, friendlyTime, getLocalImg, getResponse, isSequentialArray, tomlToArray};
use const Aviat\AnimeClient\{MINUTES_IN_DAY, MINUTES_IN_HOUR, MINUTES_IN_YEAR, SECONDS_IN_MINUTE};
/**
* @internal
*/
#[IgnoreFunctionForCodeCoverage('Aviat\AnimeClient\loadConfig')]
#[IgnoreFunctionForCodeCoverage('Aviat\AnimeClient\createPlaceholderImage')]
#[IgnoreFunctionForCodeCoverage('Aviat\AnimeClient\renderTemplate')]
#[IgnoreFunctionForCodeCoverage('Aviat\AnimeClient\getLocalImg')]
final class AnimeClientTest extends AnimeClientTestCase
{
public function testArrayToToml(): void
@ -128,4 +134,33 @@ final class AnimeClientTest extends AnimeClientTestCase
{
$this->assertTrue(clearCache($this->container->get('cache')));
}
public static function getFriendlyTime(): array
{
$SECONDS_IN_DAY = SECONDS_IN_MINUTE * MINUTES_IN_DAY;
$SECONDS_IN_HOUR = SECONDS_IN_MINUTE * MINUTES_IN_HOUR;
$SECONDS_IN_YEAR = SECONDS_IN_MINUTE * MINUTES_IN_YEAR;
return [[
'seconds' => $SECONDS_IN_YEAR,
'expected' => '1 year',
], [
'seconds' => $SECONDS_IN_HOUR,
'expected' => '1 hour',
], [
'seconds' => (2 * $SECONDS_IN_YEAR) + 30,
'expected' => '2 years, 30 seconds',
], [
'seconds' => (5 * $SECONDS_IN_YEAR) + (3 * $SECONDS_IN_DAY) + (17 * SECONDS_IN_MINUTE),
'expected' => '5 years, 3 days, and 17 minutes',
]];
}
#[\PHPUnit\Framework\Attributes\DataProvider('getFriendlyTime')]
public function testGetFriendlyTime(int $seconds, string $expected): void
{
$actual = friendlyTime($seconds);
$this->assertSame($expected, $actual);
}
}

View File

@ -40,10 +40,10 @@ class AnimeClientTestCase extends TestCase
use MatchesSnapshots;
// Test directory constants
public const ROOT_DIR = AC_TEST_ROOT_DIR;
public const SRC_DIR = SRC_DIR;
public const TEST_DATA_DIR = __DIR__ . '/test_data';
public const TEST_VIEW_DIR = __DIR__ . '/test_views';
final public const ROOT_DIR = AC_TEST_ROOT_DIR;
final public const SRC_DIR = SRC_DIR;
final public const TEST_DATA_DIR = __DIR__ . '/test_data';
final public const TEST_VIEW_DIR = __DIR__ . '/test_views';
protected ContainerInterface $container;
@ -97,7 +97,7 @@ class AnimeClientTestCase extends TestCase
$container = $di($config_array);
// Use mock session handler
$container->set('session-handler', static function () {
$container->set('session-handler', static function (): TestSessionHandler {
$session_handler = new TestSessionHandler();
session_set_save_handler($session_handler, TRUE);
@ -123,7 +123,7 @@ class AnimeClientTestCase extends TestCase
];
$request = call_user_func_array(
[ServerRequestFactory::class, 'fromGlobals'],
ServerRequestFactory::fromGlobals(...),
array_values(array_merge($default, $supers)),
);
$this->container->setInstance('request', $request);

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