Miscellaneous page improvements, including additional data and sorting
Some checks failed
timw4mail/HummingBirdAnimeClient/develop There was a failure building this commit

This commit is contained in:
Timothy Warren 2018-10-19 09:30:27 -04:00
parent 28164f72da
commit 16f62ceb8d
15 changed files with 457 additions and 110 deletions

View File

@ -1,21 +0,0 @@
test:7.1:
stage: test
before_script:
- sh build/docker_install.sh > /dev/null
- apk add --no-cache php7-phpdbg
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install --ignore-platform-reqs
image: php:7.1-alpine
script:
- phpdbg -qrr -- ./vendor/bin/phpunit --coverage-text --colors=never
test:7.2:
stage: test
before_script:
- sh build/docker_install.sh > /dev/null
- apk add --no-cache php7-phpdbg
- curl -sS https://getcomposer.org/installer | php
- php composer.phar install --ignore-platform-reqs
image: php:7.2-alpine
script:
- phpdbg -qrr -- ./vendor/bin/phpunit --coverage-text --colors=never

View File

@ -7,6 +7,8 @@
* Updated console command to sync Kitsu and Anilist data (Kitsu can sync MAL, and MAL's API broke, so MAL sync was removed) * Updated console command to sync Kitsu and Anilist data (Kitsu can sync MAL, and MAL's API broke, so MAL sync was removed)
* Added page to update settings without having to edit config files * Added page to update settings without having to edit config files
* Defaulted to secure (HTTPS) urls * Defaulted to secure (HTTPS) urls
* Updated Character pages to show voice actors
* Added People pages, showing which works they contributed to, and in what role
## Version 4 ## Version 4
* Updated to use Kitsu API after discontinuation of Hummingbird * Updated to use Kitsu API after discontinuation of Hummingbird

View File

@ -174,6 +174,14 @@ return [
'slug' => '[a-z0-9\-]+' 'slug' => '[a-z0-9\-]+'
] ]
], ],
'person' => [
'path' => '/people/{id}',
'action' => 'index',
'params' => [],
'tokens' => [
'id' => '[a-z0-9\-]+'
]
],
'user_info' => [ 'user_info' => [
'path' => '/me', 'path' => '/me',
'action' => 'me', 'action' => 'me',

View File

@ -1,4 +1,7 @@
<?php use Aviat\AnimeClient\API\Kitsu; ?> <?php
use function Aviat\AnimeClient\getLocalImg;
use Aviat\AnimeClient\API\Kitsu;
?>
<main class="details fixed"> <main class="details fixed">
<section class="flex flex-no-wrap"> <section class="flex flex-no-wrap">
<div> <div>
@ -89,14 +92,19 @@
<th>Cast Member</th> <th>Cast Member</th>
<th>Series</th> <th>Series</th>
</tr> </tr>
<?php foreach($casting as $c):?> <?php foreach($casting as $cid => $c):?>
<tr> <tr>
<td style="width:229px"> <td style="width:229px">
<article class="character"> <article class="character">
<img src="<?= $c['person']['image'] ?>" alt="" /> <?php
$link = $url->generate('person', ['id' => $c['person']['id']]);
?>
<a href="<?= $link ?>">
<img src="<?= $urlGenerator->assetUrl(getLocalImg($c['person']['image'])) ?>" alt="" />
<div class="name"> <div class="name">
<?= $c['person']['name'] ?> <?= $c['person']['name'] ?>
</div> </div>
</a>
</article> </article>
</td> </td>
<td> <td>
@ -108,7 +116,7 @@
$titles = Kitsu::filterTitles($series['attributes']); $titles = Kitsu::filterTitles($series['attributes']);
?> ?>
<a href="<?= $link ?>"> <a href="<?= $link ?>">
<img src="<?= $series['attributes']['posterImage']['small'] ?>" width="220" alt="" /> <img src="<?= $urlGenerator->assetUrl(getLocalImg($series['attributes']['posterImage']['small'])) ?>" width="220" alt="" />
</a> </a>
<div class="name"> <div class="name">
<a href="<?= $link ?>"> <a href="<?= $link ?>">

74
app/views/person.php Normal file
View File

@ -0,0 +1,74 @@
<?php
use function Aviat\AnimeClient\getLocalImg;
use Aviat\AnimeClient\API\Kitsu;
?>
<main class="details fixed">
<section class="flex flex-no-wrap">
<div>
<picture class="cover">
<source
srcset="<?= $urlGenerator->assetUrl("images/people/{$data['id']}-original.webp") ?>"
type="image/webp"
>
<source
srcset="<?= $urlGenerator->assetUrl("images/people/{$data['id']}-original.jpg") ?>"
type="image/jpeg"
>
<img src="<?= $urlGenerator->assetUrl("images/people/{$data['id']}-original.jpg") ?>" alt="" />
</picture>
</div>
<div>
<h2><?= $data['attributes']['name'] ?></h2>
</div>
</section>
<section>
<?php if ($castCount > 0): ?>
<h3>Castings</h3>
<?php foreach ($castings as $role => $entries): ?>
<h4><?= $role ?></h4>
<?php foreach ($entries as $type => $casting): ?>
<?php if ( ! empty($entries['manga'])): ?>
<h5><?= ucfirst($type) ?></h5>
<?php endif ?>
<section class="align_left media-wrap">
<?php foreach ($casting as $sid => $series): ?>
<article class="media">
<?php
$link = $url->generate('anime.details', ['id' => $series['attributes']['slug']]);
$titles = Kitsu::filterTitles($series['attributes']);
?>
<a href="<?= $link ?>">
<picture>
<source
srcset="<?= $urlGenerator->assetUrl("images/{$type}/{$sid}.webp") ?>"
type="image/webp"
/>
<source
srcset="<?= $urlGenerator->assetUrl("images/{$type}/{$sid}.jpg") ?>"
type="image/jpeg"
/>
<img
src="<?= $urlGenerator->assetUrl("images/{$type}/{$sid}.jpg") ?>"
width="220" alt=""
/>
</picture>
</a>
<div class="name">
<a href="<?= $link ?>">
<?= array_shift($titles) ?>
<?php foreach ($titles as $title): ?>
<br />
<small><?= $title ?></small>
<?php endforeach ?>
</a>
</div>
</article>
<?php endforeach; ?>
</section>
<br />
<?php endforeach ?>
<?php endforeach ?>
<?php endif ?>
</section>
</main>

View File

@ -61,6 +61,11 @@ final class JsonAPI {
// Inline organized data // Inline organized data
foreach($data['data'] as $i => &$item) foreach($data['data'] as $i => &$item)
{ {
if ( ! is_array($item))
{
continue;
}
if (array_key_exists('relationships', $item)) if (array_key_exists('relationships', $item))
{ {
foreach($item['relationships'] as $relType => $props) foreach($item['relationships'] as $relType => $props)

View File

@ -103,7 +103,7 @@ final class ListItem implements ListItemInterface {
$request = $this->requestBuilder->newRequest('GET', "library-entries/{$id}") $request = $this->requestBuilder->newRequest('GET', "library-entries/{$id}")
->setQuery([ ->setQuery([
'include' => 'media,media.genres,media.mappings' 'include' => 'media,media.categories,media.mappings'
]); ]);
if ($authHeader !== FALSE) if ($authHeader !== FALSE)

View File

@ -221,6 +221,21 @@ final class Model {
return $data; return $data;
} }
/**
* Get information about a person
*
* @param string $id
* @return array
*/
public function getPerson(string $id): array
{
return $this->getRequest("people/{$id}", [
'query' => [
'include' => 'castings,castings.media,staff,staff.media,voices'
],
]);
}
/** /**
* Get profile information for the configured user * Get profile information for the configured user
* *
@ -585,7 +600,7 @@ final class Model {
} }
$transformed = $this->mangaTransformer->transform($baseData); $transformed = $this->mangaTransformer->transform($baseData);
$transformed['included'] = $baseData['included']; $transformed['included'] = JsonAPI::organizeIncluded($baseData['included']);
return $transformed; return $transformed;
} }
@ -936,8 +951,8 @@ final class Model {
'characters' => 'slug,name,image' 'characters' => 'slug,name,image'
], ],
'include' => ($type === 'anime') 'include' => ($type === 'anime')
? 'categories,mappings,streamingLinks,animeCharacters.character' ? 'staff,staff.person,categories,mappings,streamingLinks,animeCharacters.character'
: 'categories,mappings,mangaCharacters.character,castings.character', : 'staff,staff.person,categories,mappings,mangaCharacters.character,castings.character',
] ]
]; ];

View File

@ -19,6 +19,10 @@ namespace Aviat\AnimeClient;
use Aviat\Ion\ConfigInterface; use Aviat\Ion\ConfigInterface;
use Yosymfony\Toml\{Toml, TomlBuilder}; use Yosymfony\Toml\{Toml, TomlBuilder};
// ----------------------------------------------------------------------------
//! TOML Functions
// ----------------------------------------------------------------------------
/** /**
* Load configuration options from .toml files * Load configuration options from .toml files
* *
@ -67,30 +71,6 @@ function loadTomlFile(string $filename): array
return Toml::parseFile($filename); return Toml::parseFile($filename);
} }
/**
* Is the array sequential, not associative?
*
* @param mixed $array
* @return bool
*/
function isSequentialArray($array): bool
{
if ( ! is_array($array))
{
return FALSE;
}
$i = 0;
foreach ($array as $k => $v)
{
if ($k !== $i++)
{
return FALSE;
}
}
return TRUE;
}
function _iterateToml(TomlBuilder $builder, $data, $parentKey = NULL): void function _iterateToml(TomlBuilder $builder, $data, $parentKey = NULL): void
{ {
foreach ($data as $key => $value) foreach ($data as $key => $value)
@ -147,6 +127,34 @@ function tomlToArray(string $toml): array
return Toml::parse($toml); return Toml::parse($toml);
} }
// ----------------------------------------------------------------------------
//! Misc Functions
// ----------------------------------------------------------------------------
/**
* Is the array sequential, not associative?
*
* @param mixed $array
* @return bool
*/
function isSequentialArray($array): bool
{
if ( ! is_array($array))
{
return FALSE;
}
$i = 0;
foreach ($array as $k => $v)
{
if ($k !== $i++)
{
return FALSE;
}
}
return TRUE;
}
/** /**
* Check that folder permissions are correct for proper operation * Check that folder permissions are correct for proper operation
* *
@ -186,4 +194,38 @@ function checkFolderPermissions(ConfigInterface $config): array
} }
return $errors; return $errors;
}
/**
* Generate the path for the cached image from the original iamge
*
* @param string $kitsuUrl
* @return string
*/
function getLocalImg ($kitsuUrl): string
{
if ( ! is_string($kitsuUrl))
{
return '/404';
}
$parts = parse_url($kitsuUrl);
if ($parts === FALSE)
{
return '/404';
}
$file = basename($parts['path']);
$fileParts = explode('.', $file);
$ext = array_pop($fileParts);
$segments = explode('/', trim($parts['path'], '/'));
// dump($segments);
$type = $segments[0] === 'users' ? $segments[1] : $segments[0];
$id = $segments[count($segments) - 2];
return implode('/', ['images', $type, "{$id}.{$ext}"]);
} }

View File

@ -16,20 +16,8 @@
namespace Aviat\AnimeClient\Command; namespace Aviat\AnimeClient\Command;
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\JsonAPI;
FailedResponseException,
JsonAPI,
ParallelAPIRequest
};
use Aviat\AnimeClient\API\Anilist\Transformer\{
AnimeListTransformer as AALT,
MangaListTransformer as AMLT
};
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\AnimeClient\Controller\Index; use Aviat\AnimeClient\Controller\Index;
use Aviat\AnimeClient\Types\FormItem;
use Aviat\Ion\Json;
use DateTime;
/** /**
* Clears out image cache directories, then re-creates the image cache * Clears out image cache directories, then re-creates the image cache
@ -69,9 +57,11 @@ final class UpdateThumbnails extends BaseCommand {
{ {
$this->controller->images($type, "{$id}.jpg", FALSE); $this->controller->images($type, "{$id}.jpg", FALSE);
} }
$this->echoBox("Finished regenerating {$type} thumbnails");
} }
$this->echoBox('Finished regenerating thumbnails'); $this->echoBox('Finished regenerating all thumbnails');
} }
public function clearThumbs() public function clearThumbs()

View File

@ -275,10 +275,11 @@ final class Anime extends BaseController {
*/ */
public function details(string $animeId): void public function details(string $animeId): void
{ {
$show_data = $this->model->getAnime($animeId); $data = $this->model->getAnime($animeId);
$characters = []; $characters = [];
$staff = [];
if ($show_data->title === '') if ($data->title === '')
{ {
$this->notFound( $this->notFound(
$this->config->get('whose_list') . $this->config->get('whose_list') .
@ -290,22 +291,56 @@ final class Anime extends BaseController {
return; return;
} }
if (array_key_exists('characters', $show_data['included'])) if (array_key_exists('characters', $data['included']))
{ {
foreach($show_data['included']['characters'] as $id => $character)
foreach($data['included']['characters'] as $id => $character)
{ {
$characters[$id] = $character['attributes']; $characters[$id] = $character['attributes'];
} }
} }
if (array_key_exists('mediaStaff', $data['included']))
{
foreach ($data['included']['mediaStaff'] as $id => $person)
{
$personDetails = [];
foreach ($person['relationships']['person']['people'] as $p)
{
$personDetails = $p['attributes'];
}
$role = $person['attributes']['role'];
if ( ! array_key_exists($role, $staff))
{
$staff[$role] = [];
}
$staff[$role][$id] = [
'name' => $personDetails['name'] ?? '??',
'image' => $personDetails['image'],
];
}
}
uasort($characters, function ($a, $b) {
return $a['name'] <=> $b['name'];
});
// dump($characters);
// dump($staff);
$this->outputHTML('anime/details', [ $this->outputHTML('anime/details', [
'title' => $this->formatTitle( 'title' => $this->formatTitle(
$this->config->get('whose_list') . "'s Anime List", $this->config->get('whose_list') . "'s Anime List",
'Anime', 'Anime',
$show_data->title $data->title
), ),
'characters' => $characters, 'characters' => $characters,
'show_data' => $show_data, 'show_data' => $data,
'staff' => $staff,
]); ]);
} }

View File

@ -16,6 +16,8 @@
namespace Aviat\AnimeClient\Controller; namespace Aviat\AnimeClient\Controller;
use function Aviat\AnimeClient\getLocalImg;
use Aviat\AnimeClient\Controller as BaseController; use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\API\JsonAPI; use Aviat\AnimeClient\API\JsonAPI;
use Aviat\Ion\ArrayWrapper; use Aviat\Ion\ArrayWrapper;
@ -23,7 +25,7 @@ use Aviat\Ion\ArrayWrapper;
/** /**
* Controller for character description pages * Controller for character description pages
*/ */
final class Character extends BaseController { class Character extends BaseController {
use ArrayWrapper; use ArrayWrapper;
@ -57,6 +59,23 @@ final class Character extends BaseController {
$data = JsonAPI::organizeData($rawData); $data = JsonAPI::organizeData($rawData);
if (array_key_exists('included', $data))
{
if (array_key_exists('anime', $data['included']))
{
uasort($data['included']['anime'], function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];
});
}
if (array_key_exists('manga', $data['included']))
{
uasort($data['included']['manga'], function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];
});
}
}
$viewData = [ $viewData = [
'title' => $this->formatTitle( 'title' => $this->formatTitle(
'Characters', 'Characters',
@ -67,10 +86,13 @@ final class Character extends BaseController {
'castings' => [] 'castings' => []
]; ];
if (array_key_exists('included', $data) && array_key_exists('castings', $data['included'])) if (array_key_exists('included', $data))
{ {
$viewData['castings'] = $this->organizeCast($data['included']['castings']); if (array_key_exists('castings', $data['included']))
$viewData['castCount'] = $this->getCastCount($viewData['castings']); {
$viewData['castings'] = $this->organizeCast($data['included']['castings']);
$viewData['castCount'] = $this->getCastCount($viewData['castings']);
}
} }
$this->outputHTML('character', $viewData); $this->outputHTML('character', $viewData);
@ -121,25 +143,26 @@ final class Character extends BaseController {
return $output; return $output;
} }
private function getCastCount(array $cast): int protected function getCastCount(array $cast): int
{ {
$count = 0; $count = 0;
foreach($cast as $role) foreach($cast as $role)
{ {
if ( $count++;
/* if (
array_key_exists('attributes', $role) && array_key_exists('attributes', $role) &&
array_key_exists('role', $role['attributes']) && array_key_exists('role', $role['attributes']) &&
$role['attributes']['role'] !== NULL $role['attributes']['role'] !== NULL
) { ) {
$count++; $count++;
} } */
} }
return $count; return $count;
} }
private function organizeCast(array $cast): array protected function organizeCast(array $cast): array
{ {
$cast = $this->dedupeCast($cast); $cast = $this->dedupeCast($cast);
$output = []; $output = [];
@ -157,8 +180,19 @@ final class Character extends BaseController {
if ($isVA) if ($isVA)
{ {
$person = current($role['relationships']['person']['people'])['attributes']; foreach($role['relationships']['person']['people'] as $pid => $peoples)
$name = $person['name']; {
$p = $peoples;
}
$person = $p['attributes'];
$person['id'] = $pid;
$person['image'] = $person['image']['original'];
uasort($role['relationships']['media']['anime'], function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];
});
$item = [ $item = [
'person' => $person, 'person' => $person,
'series' => $role['relationships']['media']['anime'] 'series' => $role['relationships']['media']['anime']
@ -168,7 +202,11 @@ final class Character extends BaseController {
} }
else else
{ {
$output[$roleName][] = $role['relationships']['person']['people']; foreach($role['relationships']['person']['people'] as $pid => $person)
{
$person['id'] = $pid;
$output[$roleName][$pid] = $person;
}
} }
} }

View File

@ -268,36 +268,45 @@ final class Index extends BaseController {
$kitsuUrl = 'https://media.kitsu.io/'; $kitsuUrl = 'https://media.kitsu.io/';
$fileName = str_replace('-original', '', $file); $fileName = str_replace('-original', '', $file);
[$id, $ext] = explode('.', basename($fileName)); [$id, $ext] = explode('.', basename($fileName));
switch ($type)
$typeMap = [
'anime' => [
'kitsuUrl' => "anime/poster_images/{$id}/medium.{$ext}",
'width' => 220,
],
'avatars' => [
'kitsuUrl' => "users/avatars/{$id}/original.{$ext}",
'width' => null,
],
'characters' => [
'kitsuUrl' => "characters/images/{$id}/original.{$ext}",
'width' => 225,
],
'manga' => [
'kitsuUrl' => "manga/poster_images/{$id}/medium.{$ext}",
'width' => 220,
],
'people' => [
'kitsuUrl' => "people/images/{$id}/original.{$ext}",
'width' => null,
],
];
if ( ! array_key_exists($type, $typeMap))
{ {
case 'anime': $this->notFound();
$kitsuUrl .= "anime/poster_images/{$id}/small.jpg"; return;
$width = 220;
break;
case 'avatars':
$kitsuUrl .= "users/avatars/{$id}/original.jpg";
break;
case 'manga':
$kitsuUrl .= "manga/poster_images/{$id}/small.jpg";
$width = 220;
break;
case 'characters':
$kitsuUrl .= "characters/images/{$id}/original.jpg";
$width = 225;
break;
default:
$this->notFound();
return;
} }
$kitsuUrl .= $typeMap[$type]['kitsuUrl'];
$width = $typeMap[$type]['width'];
$promise = (new HummingbirdClient)->request($kitsuUrl); $promise = (new HummingbirdClient)->request($kitsuUrl);
$response = wait($promise); $response = wait($promise);
$data = wait($response->getBody()); $data = wait($response->getBody());
// echo "Fetching {$kitsuUrl}\n";
$baseSavePath = $this->config->get('img_cache_path'); $baseSavePath = $this->config->get('img_cache_path');
$filePrefix = "{$baseSavePath}/{$type}/{$id}"; $filePrefix = "{$baseSavePath}/{$type}/{$id}";

View File

@ -280,6 +280,7 @@ final class Manga extends Controller {
public function details($manga_id): void public function details($manga_id): void
{ {
$data = $this->model->getManga($manga_id); $data = $this->model->getManga($manga_id);
$staff = [];
$characters = []; $characters = [];
if (empty($data)) if (empty($data))
@ -293,14 +294,44 @@ final class Manga extends Controller {
return; return;
} }
foreach($data['included'] as $included) if (array_key_exists('characters', $data['included']))
{ {
if ($included['type'] === 'characters') foreach ($data['included']['characters'] as $id => $character)
{ {
$characters[$included['id']] = $included['attributes']; $characters[$id] = $character['attributes'];
} }
} }
if (array_key_exists('mediaStaff', $data['included']))
{
foreach ($data['included']['mediaStaff'] as $id => $person)
{
$personDetails = [];
foreach ($person['relationships']['person']['people'] as $p)
{
$personDetails = $p['attributes'];
}
$role = $person['attributes']['role'];
if ( ! array_key_exists($role, $staff))
{
$staff[$role] = [];
}
$staff[$role][$id] = [
'name' => $personDetails['name'] ?? '??',
'image' => $personDetails['image'],
];
}
}
uasort($characters, function ($a, $b) {
return $a['name'] <=> $b['name'];
});
// dump($staff);
$this->outputHTML('manga/details', [ $this->outputHTML('manga/details', [
'title' => $this->formatTitle( 'title' => $this->formatTitle(
$this->config->get('whose_list') . "'s Manga List", $this->config->get('whose_list') . "'s Manga List",
@ -309,6 +340,7 @@ final class Manga extends Controller {
), ),
'characters' => $characters, 'characters' => $characters,
'data' => $data, 'data' => $data,
'staff' => $staff,
]); ]);
} }

110
src/Controller/People.php Normal file
View File

@ -0,0 +1,110 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.1
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.1
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\API\JsonAPI;
/**
* Controller for People pages
*/
final class People extends BaseController {
/**
* Show information about a person
*
* @param string $id
* @return void
*/
public function index(string $id): void
{
$model = $this->container->get('kitsu-model');
$rawData = $model->getPerson($id);
if (( ! array_key_exists('data', $rawData)) || empty($rawData['data']))
{
$this->notFound(
$this->formatTitle(
'People',
'Person not found'
),
'Person Not Found'
);
return;
}
$data = JsonAPI::organizeData($rawData);
$viewData = [
'title' => $this->formatTitle(
'People',
$data['attributes']['name']
),
'data' => $data,
'castCount' => 0,
'castings' => []
];
if (array_key_exists('included', $data) && array_key_exists('castings', $data['included']))
{
$viewData['castings'] = $this->organizeCast($data['included']['castings']);
$viewData['castCount'] = count($viewData['castings']);
}
$this->outputHTML('person', $viewData);
}
protected function organizeCast(array $cast): array
{
$output = [];
foreach ($cast as $id => $role)
{
if (empty($role['attributes']['role']))
{
continue;
}
$roleName = $role['attributes']['role'];
$media = $role['relationships']['media'];
if (array_key_exists('anime', $media))
{
foreach($media['anime'] as $sid => $series)
{
$output[$roleName]['anime'][$sid] = $series;
}
uasort($output[$roleName]['anime'], function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];
});
}
else if (array_key_exists('manga', $media))
{
foreach ($media['manga'] as $sid => $series)
{
$output[$roleName]['manga'][$sid] = $series;
}
uasort($output[$roleName]['anime'], function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];
});
}
}
return $output;
}
}