Version 5.1 - All the GraphQL #32

Closed
timw4mail wants to merge 1160 commits from develop into master
64 changed files with 3697 additions and 3282 deletions
Showing only changes of commit 650ddc781f - Show all commits

View File

@ -3,7 +3,10 @@
## Version 4
* Updated to use Kitsu API after discontinuation of Hummingbird
* Added streaming links to list entries from the Kitsu API
* Added simple integration with MyAnimeList, so an update can cross-post to both Kitsu and MyAnimeList
* Added simple integration with MyAnimeList, so an update can cross-post to both Kitsu and MyAnimeList (anime and manga)
* Added console command to sync Kitsu and MyAnimeList data
* Added character pages
## Version 3
* Converted user configuration to toml files

View File

@ -14,6 +14,7 @@
* @link https://github.com/timw4mail/HummingBirdAnimeClient
*/
use function Aviat\AnimeClient\loadToml;
// ----------------------------------------------------------------------------
// Lower level configuration
@ -23,7 +24,9 @@
$APP_DIR = realpath(__DIR__ . '/../');
$ROOT_DIR = realpath("{$APP_DIR}/../");
$base_config = [
$tomlConfig = loadToml(__DIR__);
$base_config = array_merge($tomlConfig, [
'asset_dir' => "{$ROOT_DIR}/public",
// Template file path
@ -34,6 +37,5 @@ $base_config = [
'img_cache_path' => "{$ROOT_DIR}/public/images",
// Included config files
'menus' => require 'menus.php',
'routes' => require 'routes.php',
];
]);

View File

@ -1,41 +0,0 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://github.com/timw4mail/HummingBirdAnimeClient
*/
return [
'anime_list' => [
'route_prefix' => '/anime',
'items' => [
'watching' => '/watching',
'plan_to_watch' => '/plan_to_watch',
'on_hold' => '/on_hold',
'dropped' => '/dropped',
'completed' => '/completed',
'all' => '/all'
]
],
'manga_list' => [
'route_prefix' => '/manga',
'items' => [
'reading' => '/reading',
'plan_to_read' => '/plan_to_read',
'on_hold' => '/on_hold',
'dropped' => '/dropped',
'completed' => '/completed',
'all' => '/all'
]
]
];

19
app/appConf/menus.toml Normal file
View File

@ -0,0 +1,19 @@
[anime_list]
route_prefix = "/anime"
[anime_list.items]
watching = '/watching'
plan_to_watch = '/plan_to_watch'
on_hold = '/on_hold'
dropped = '/dropped'
completed = '/completed'
all = '/all'
[manga_list]
route_prefix = "/manga"
[manga_list.items]
reading = '/reading'
plan_to_read = '/plan_to_read'
on_hold = '/on_hold'
dropped = '/dropped'
completed = '/completed'
all = '/all'

View File

@ -0,0 +1,19 @@
################################################################################
# Route config
#
# Default views and paths
################################################################################
# Path to public directory, where images/css/javascript are located,
# appended to the url
asset_path = "/public"
# Which list should be the default?
default_list = "anime" # anime or manga
# Default pages for anime/manga
default_anime_list_path = "watching" # watching|plan_to_watch|on_hold|dropped|completed|all
default_manga_list_path = "reading" # reading|plan_to_read|on_hold|dropped|completed|all
# Default view type (cover_view/list_view)
default_view_type = "cover_view"

View File

@ -17,38 +17,17 @@
use const Aviat\AnimeClient\{
DEFAULT_CONTROLLER_METHOD,
DEFAULT_CONTROLLER_NAMESPACE
DEFAULT_CONTROLLER
};
use Aviat\AnimeClient\AnimeClient;
// -------------------------------------------------------------------------
// Routing Config
//
// Maps paths to controlers and methods
// -------------------------------------------------------------------------
return [
// -------------------------------------------------------------------------
// Routing options
//
// Specify default paths and views
// -------------------------------------------------------------------------
'route_config' => [
// Path to public directory, where images/css/javascript are located,
// appended to the url
'asset_path' => '/public',
// Which list should be the default?
'default_list' => 'anime', // anime or manga
// Default pages for anime/manga
'default_anime_list_path' => "watching", // watching|plan_to_watch|on_hold|dropped|completed|all
'default_manga_list_path' => "reading", // reading|plan_to_read|on_hold|dropped|completed|all
// Default view type (cover_view/list_view)
'default_view_type' => 'cover_view',
],
// -------------------------------------------------------------------------
// Routing Config
//
// Maps paths to controlers and methods
// -------------------------------------------------------------------------
'routes' => [
// ---------------------------------------------------------------------
// Anime List Routes
// ---------------------------------------------------------------------
@ -171,25 +150,25 @@ return [
'cache_purge' => [
'path' => '/cache_purge',
'action' => 'clearCache',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'controller' => DEFAULT_CONTROLLER,
'verb' => 'get',
],
'login' => [
'path' => '/login',
'action' => 'login',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'controller' => DEFAULT_CONTROLLER,
'verb' => 'get',
],
'login.post' => [
'path' => '/login',
'action' => 'loginAction',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'controller' => DEFAULT_CONTROLLER,
'verb' => 'post',
],
'logout' => [
'path' => '/logout',
'action' => 'logout',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'controller' => DEFAULT_CONTROLLER,
],
'update' => [
'path' => '/{controller}/update',
@ -225,8 +204,7 @@ return [
],
'index_redirect' => [
'path' => '/',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'controller' => DEFAULT_CONTROLLER,
'action' => 'redirectToDefaultRoute',
],
],
];

View File

@ -0,0 +1,19 @@
################################################################################
# Route config
#
# Default views and paths
################################################################################
# Path to public directory, where images/css/javascript are located,
# appended to the url
asset_path = "/public"
# Which list should be the default?
default_list = "anime" # anime or manga
# Default pages for anime/manga
default_anime_list_path = "watching" # watching|plan_to_watch|on_hold|dropped|completed|all
default_manga_list_path = "reading" # reading|plan_to_read|on_hold|dropped|completed|all
# Default view type (cover_view/list_view)
default_view_type = "cover_view"

View File

@ -1,4 +1,4 @@
<main class="details">
<main class="details fixed">
<section class="flex flex-no-wrap">
<div>
<img class="cover" width="402" height="284" src="<?= $data['cover_image'] ?>" alt="" />
@ -74,30 +74,27 @@
</tbody>
</table>
<?php endif ?>
<?php /* <pre><?= print_r($characters, TRUE) ?></pre> */ ?>
</div>
</section>
<section>
<?php if (count($characters) > 0): ?>
<h2>Characters</h2>
<div class="flex flex-wrap">
<section class="media-wrap">
<?php foreach($characters as $char): ?>
<?php if ( ! empty($char['image']['original'])): ?>
<div class="character">
<article class="character">
<?php $link = $url->generate('character', ['slug' => $char['slug']]) ?>
<div class="name">
<?= $helper->a($link, $char['name']); ?>
<br />
</div>
<a href="<?= $link ?>">
<?= $helper->img($char['image']['original'], [
'width' => '225'
]) ?>
</a>
</div>
</article>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
</section>
<?php endif ?>
</main>

View File

@ -30,7 +30,11 @@
<tr id="a-<?= $item['id'] ?>">
<?php if ($auth->isAuthenticated()): ?>
<td>
<a class="bracketed" href="<?= $urlGenerator->url("/anime/edit/{$item['id']}/{$item['watching_status']}") ?>">Edit</a>
<a class="bracketed" href="<?= $url->generate('edit', [
'controller' => 'anime',
'id' => $item['id'],
'status' => $item['watching_status']
]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="justify">

View File

@ -1,7 +1,7 @@
<main class="details">
<main class="details fixed">
<section class="flex flex-no-wrap">
<div>
<img class="cover" width="402" height="284" src="<?= $data['image']['original'] ?>" alt="" />
<img class="cover" width="284" src="<?= $data['image']['original'] ?>" alt="" />
</div>
<div>
<h2><?= $data['name'] ?></h2>

View File

@ -21,8 +21,11 @@
<div class="table">
<?php if ($auth->isAuthenticated()): ?>
<div class="row">
<span class="edit"><a class="bracketed" href="<?= $urlGenerator->url("collection/edit/{$item['hummingbird_id']}") ?>">Edit</a></span>
<?php /*<span class="delete"><a class="bracketed" href="<?= $urlGenerator->url("collection/delete/{$item['hummingbird_id']}") ?>">Delete</a></span> */ ?>
<span class="edit">
<a class="bracketed" href="<?= $url->generate('collection.edit.get', [
'id' => $item['hummingbird_id']
]) ?>">Edit</a>
</span>
</div>
<?php endif ?>
<div class="row">

View File

@ -1,6 +1,6 @@
<main>
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $urlGenerator->fullUrl('collection/add', 'anime') ?>">Add Item</a>
<a class="bracketed" href="<?= $url->generate('collection.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
@ -26,12 +26,11 @@
<tr>
<?php if($auth->isAuthenticated()): ?>
<td>
<a class="bracketed" href="<?= $urlGenerator->fullUrl("collection/edit/{$item['hummingbird_id']}") ?>">Edit</a>
<?php /*<a class="bracketed" href="<?= $urlGenerator->fullUrl("collection/delete/{$item['hummingbird_id']}") ?>">Delete</a>*/ ?>
<a class="bracketed" href="<?= $url->generate('collection.edit.get', ['id' => $item['hummingbird_id']]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="align_left">
<a href="https://hummingbird.me/anime/<?= $item['slug'] ?>">
<a href="<?= $url->generate('anime.details', ['id' => $item['slug']]) ?>">
<?= $item['title'] ?>
</a>
<?= ( ! empty($item['alternate_title'])) ? " <br /><small> " . $item['alternate_title'] . "</small>" : "" ?>

View File

@ -6,11 +6,11 @@
<?= $config->get('whose_list') ?>'s <?= ucfirst($url_type) ?> List
</a>
<?php if($config->get("show_{$url_type}_collection")): ?>
[<a href="<?= $urlGenerator->url('collection/view') ?>"><?= ucfirst($url_type) ?> Collection</a>]
[<a href="<?= $url->generate('collection.view') ?>"><?= ucfirst($url_type) ?> Collection</a>]
<?php endif ?>
[<a href="<?= $urlGenerator->defaultUrl($other_type) ?>"><?= ucfirst($other_type) ?> List</a>]
<?php else: ?>
<a href="<?= $urlGenerator->url('collection/view') ?>">
<a href="<?= $url->generate('collection.view') ?>">
<?= $config->get('whose_list') ?>'s <?= ucfirst($url_type) ?> Collection
</a>
[<a href="<?= $urlGenerator->defaultUrl('anime') ?>">Anime List</a>]

View File

@ -1,6 +1,6 @@
<main>
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $urlGenerator->url('manga/add') ?>">Add Item</a>
<a class="bracketed" href="<?= $url->generate('manga.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
@ -10,10 +10,11 @@
<h2><?= $escape->html($name) ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<article class="media" id="manga-<?= $item['id'] ?>">
<article class="media" data-kitsu-id="<?= $item['id'] ?>" data-mal-id="<?= $item['mal_id'] ?>">
<?php if ($auth->isAuthenticated()): ?>
<div class="edit_buttons" hidden>
<button class="plus_one_chapter">+1 Chapter</button>
<?php /* <button class="plus_one_volume">+1 Volume</button> */ ?>
</div>
<?php endif ?>
<img src="<?= $escape->attr($item['manga']['image']) ?>" />
@ -29,13 +30,38 @@
<?php if ($auth->isAuthenticated()): ?>
<div class="row">
<span class="edit">
<a class="bracketed" title="Edit information about this manga" href="<?= $urlGenerator->url("manga/edit/{$item['id']}/{$name}") ?>">Edit</a>
<a class="bracketed"
title="Edit information about this manga"
href="<?= $url->generate('edit', [
'controller' => 'manga',
'id' => $item['id'],
'status' => $name
]) ?>">
Edit
</a>
</span>
</div>
<?php endif ?>
<div class="row">
<div class="user_rating">Rating: <?= $item['user_rating'] ?> / 10</div>
</div>
<?php if ($item['rereading']): ?>
<div class="row">
<?php foreach(['rereading'] as $attr): ?>
<?php if($item[$attr]): ?>
<span class="item-<?= $attr ?>"><?= ucfirst($attr) ?></span>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
<?php if ($item['reread'] > 0): ?>
<div class="row">
<div>Reread <?= $item['reread'] ?> time(s)</div>
</div>
<?php endif ?>
<div class="row">
<div class="chapter_completion">
Chapters: <span class="chapters_read"><?= $item['chapters']['read'] ?></span> /

View File

@ -1,4 +1,4 @@
<main class="details">
<main class="details fixed">
<section class="flex flex-no-wrap">
<div>
<img class="cover" src="<?= $data['cover_image'] ?>" alt="<?= $data['title'] ?> cover image" />
@ -35,26 +35,25 @@
<p><?= nl2br($data['synopsis']) ?></p>
</div>
</section>
<section>
<?php if (count($characters) > 0): ?>
<h2>Characters</h2>
<div class="flex flex-wrap">
<section class="media-wrap">
<?php foreach($characters as $char): ?>
<?php if ( ! empty($char['image']['original'])): ?>
<div class="character">
<article class="character">
<?php $link = $url->generate('character', ['slug' => $char['slug']]) ?>
<div class="name">
<?= $helper->a($link, $char['name']); ?>
<br />
</div>
<a href="<?= $link ?>">
<?= $helper->img($char['image']['original'], [
'width' => '225'
]) ?>
</a>
</div>
</article>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>
</section>
<?php endif ?>
</main>

View File

@ -74,6 +74,7 @@
<td>&nbsp;</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id" />
<input type="hidden" value="<?= $item['mal_id'] ?>" name="mal_id" />
<input type="hidden" value="<?= $item['manga']['slug'] ?>" name="manga_id" />
<input type="hidden" value="<?= $item['user_rating'] ?>" name="old_rating" />
<input type="hidden" value="true" name="edit" />
@ -92,6 +93,7 @@
<td>&nbsp;</td>
<td>
<input type="hidden" value="<?= $item['id'] ?>" name="id" />
<input type="hidden" value="<?= $item['mal_id'] ?>" name="mal_id" />
<button type="submit" class="danger">Delete Entry</button>
</td>
</tr>

View File

@ -1,6 +1,6 @@
<main>
<?php if ($auth->isAuthenticated()): ?>
<a class="bracketed" href="<?= $urlGenerator->url('manga/add') ?>">Add Item</a>
<a class="bracketed" href="<?= $url->generate('manga.add.get') ?>">Add Item</a>
<?php endif ?>
<?php if (empty($sections)): ?>
<h3>There's nothing here!</h3>
@ -17,7 +17,9 @@
<th>Rating</th>
<th>Completed Chapters</th>
<th># of Volumes</th>
<th>Attributes</th>
<th>Type</th>
<th>Genres</th>
</tr>
</thead>
<tbody>
@ -25,7 +27,11 @@
<tr id="manga-<?= $item['id'] ?>">
<?php if($auth->isAuthenticated()): ?>
<td>
<a class="bracketed" href="<?= $urlGenerator->url("manga/edit/{$item['id']}/{$name}") ?>">Edit</a>
<a class="bracketed" href="<?= $url->generate('edit', [
'controller' => 'manga',
'id' => $item['id'],
'status' => $name
]) ?>">Edit</a>
</td>
<?php endif ?>
<td class="align_left">
@ -39,7 +45,22 @@
<td><?= $item['user_rating'] ?> / 10</td>
<td><?= $item['chapters']['read'] ?> / <?= $item['chapters']['total'] ?></td>
<td><?= $item['volumes']['total'] ?></td>
<td>
<ul>
<?php if ($item['reread'] > 0): ?>
<li>Reread <?= $item['reread'] ?> time(s)</li>
<?php endif ?>
<?php foreach(['rereading'] as $attr): ?>
<?php if($item[$attr]): ?>
<li><?= ucfirst($attr); ?></li>
<?php endif ?>
<?php endforeach ?>
</ul>
</td>
<td><?= $item['manga']['type'] ?></td>
<td class="align_left">
<?= implode(', ', $item['manga']['genres']) ?>
</td>
</tr>
<?php endforeach ?>
</tbody>

View File

@ -1,11 +1,22 @@
<main class="details">
<?php use Aviat\AnimeClient\API\Kitsu; ?>
<main class="user-page details">
<section class="flex flex-no-wrap">
<div>
<h2><?= $attributes['name'] ?></h2>
<center>
<h2>
<a title='View profile on Kisu'
href="https://kitsu.io/users/<?= $attributes['name'] ?>">
<?= $attributes['name'] ?>
</a>
</h2>
<img src="<?= $attributes['avatar']['original'] ?>" alt="" />
</center>
<br />
<br />
<table class="media_details">
<tr>
<th colspan="2">General</th>
</tr>
<tr>
<td>Location</td>
<td><?= $attributes['location'] ?></td>
@ -28,6 +39,21 @@
</td>
</tr>
<?php endif ?>
<tr>
<th colspan="2">User Stats</th>
</tr>
<tr>
<td># of Posts</td>
<td><?= $attributes['postsCount'] ?></td>
</tr>
<tr>
<td># of Comments</td>
<td><?= $attributes['commentsCount'] ?></td>
</tr>
<tr>
<td># of Media Rated</td>
<td><?= $attributes['ratingsCount'] ?></td>
</tr>
</table>
</div>
<div>
@ -35,9 +61,72 @@
<dt>About:</dt>
<dd><?= $escape->html($attributes['bio']) ?></dd>
</dl>
<?php /* <pre><?= json_encode($attributes, \JSON_PRETTY_PRINT) ?></pre>
<pre><?= json_encode($relationships, \JSON_PRETTY_PRINT) ?></pre>
<pre><?= json_encode($included, \JSON_PRETTY_PRINT) ?></pre> */ ?>
<?php if ( ! empty($favorites)): ?>
<?php if ( ! empty($favorites['characters'])): ?>
<h4>Favorite Characters</h4>
<section class="media-wrap">
<?php foreach($favorites['characters'] as $char): ?>
<?php if ( ! empty($char['image']['original'])): ?>
<article class="small_character">
<?php $link = $url->generate('character', ['slug' => $char['slug']]) ?>
<div class="name"><?= $helper->a($link, $char['name']); ?></div>
<a href="<?= $link ?>">
<?= $helper->img($char['image']['original']) ?>
</a>
</article>
<?php endif ?>
<?php endforeach ?>
</section>
<?php endif ?>
<?php if ( ! empty($favorites['anime'])): ?>
<h4>Favorite Anime</h4>
<section class="media-wrap">
<?php foreach($favorites['anime'] as $anime): ?>
<article class="media">
<?php
$link = $url->generate('anime.details', ['id' => $anime['slug']]);
$titles = Kitsu::filterTitles($anime);
?>
<a href="<?= $link ?>">
<img src="<?= $anime['posterImage']['small'] ?>" width="220" alt="" />
</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>
<?php endif ?>
<?php if ( ! empty($favorites['manga'])): ?>
<h4>Favorite Manga</h4>
<section class="media-wrap">
<?php foreach($favorites['manga'] as $manga): ?>
<article class="media">
<?php
$link = $url->generate('manga.details', ['id' => $manga['slug']]);
$titles = Kitsu::filterTitles($manga);
?>
<a href="<?= $link ?>">
<img src="<?= $manga['posterImage']['small'] ?>" width="220" alt="" />
</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>
<?php endif ?>
<?php endif ?>
</div>
</section>
</main>

View File

@ -7,7 +7,9 @@ $file_patterns = [
'src/**/*.php',
'src/*.php',
'tests/**/*.php',
'tests/*.php'
'tests/*.php',
'index.php',
'Robofile.php'
];
if ( ! function_exists('glob_recursive'))

View File

@ -21,7 +21,7 @@
"aura/router": "^3.0",
"aura/session": "^2.0",
"aviat/banker": "^1.0.0",
"aviat/ion": "dev-master",
"aviat/ion": "^2.0.0",
"monolog/monolog": "^1.0",
"psr/http-message": "~1.0",
"psr/log": "~1.0",

View File

@ -1,23 +1,23 @@
<?php
<?php declare(strict_types=1);
/**
* Hummingbird Anime Client
* Hummingbird Anime List Client
*
* An API client for Hummingbird to manage anime and manga watch lists
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 5.6
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2016 Timothy J. Warren
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 3.1
* @link https://github.com/timw4mail/HummingBirdAnimeClient
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient;
use function Aviat\AnimeClient\loadToml;
use Aviat\AnimeClient\AnimeClient;
use function Aviat\Ion\_dir;
// Work around the silly timezone error
$timezone = ini_get('date.timezone');

View File

@ -14,7 +14,6 @@
* @link https://github.com/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\EasyMin;
require_once('./min.php');

View File

@ -1025,7 +1025,7 @@ a:hover, a:active {
Base list styles
------------------------------------------------------------------------------*/
.media {
.media, .character, .small_character {
position:relative;
vertical-align:top;
display:inline-block;
@ -1035,7 +1035,9 @@ a:hover, a:active {
margin:0.25em 0.125em;
}
.media > img {
.media > img,
.character > img,
.small_character > img {
width: 100%;
}
@ -1076,7 +1078,9 @@ a:hover, a:active {
top: 0;
}
.media:hover > .name,
.small_character:hover > .name,
.character:hover > .name,
.media:hover > .name,
.media:hover > .media_metadata > div,
.media:hover > .medium_metadata > div,
.media:hover > .table .row
@ -1094,7 +1098,11 @@ a:hover, a:active {
display:block;
}
.media > .name a,
.small_character > .name a,
.small_character > .name a small,
.character > .name a,
.character > .name a small,
.media > .name a,
.media > .name a small
{
background:none;
@ -1250,17 +1258,20 @@ a:hover, a:active {
.details {
margin:15px auto 0 auto;
margin: 1.5rem auto 0 auto;
max-width:930px;
max-width:93rem;
padding:10px;
padding:1rem;
font-size:inherit;
}
.details.fixed {
max-width:930px;
max-width:93rem;
}
.details .cover {
display: block;
width: 284px;
height: 402px;
/* height: 402px; */
}
.details h2 {
@ -1295,6 +1306,58 @@ a:hover, a:active {
text-align:left;
}
.character,
.small_character {
background: rgba(0, 0, 0, .5);
width: 225px;
height: 350px;
vertical-align: middle;
white-space: nowrap;
}
.small_character a {
display:inline-block;
width: 100%;
height: 100%;
}
.small_character .name,
.character .name {
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
}
.small_character img,
.character img {
position: relative;
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
z-index: 5;
width: 100%;
}
/* ----------------------------------------------------------------------------
User page styles
-----------------------------------------------------------------------------*/
.small_character {
width: 160px;
height: 250px;
}
.user-page .media-wrap {
text-align: left;
}
.media a {
display: inline-block;
width: 100%;
height: 100%;
}
/* ----------------------------------------------------------------------------
Viewport-based styles
-----------------------------------------------------------------------------*/

View File

@ -297,7 +297,7 @@ a:hover, a:active {
Base list styles
------------------------------------------------------------------------------*/
.media {
.media, .character, .small_character {
position:relative;
vertical-align:top;
display:inline-block;
@ -307,7 +307,9 @@ a:hover, a:active {
margin: var(--normal-padding);
}
.media > img {
.media > img,
.character > img,
.small_character > img {
width: 100%;
}
@ -347,7 +349,8 @@ a:hover, a:active {
position:absolute;
top: 0;
}
.small_character:hover > .name,
.character:hover > .name,
.media:hover > .name,
.media:hover > .media_metadata > div,
.media:hover > .medium_metadata > div,
@ -364,6 +367,10 @@ a:hover, a:active {
display:block;
}
.small_character > .name a,
.small_character > .name a small,
.character > .name a,
.character > .name a small,
.media > .name a,
.media > .name a small
{
@ -510,15 +517,18 @@ a:hover, a:active {
-----------------------------------------------------------------------------*/
.details {
margin: 1.5rem auto 0 auto;
max-width:93rem;
padding:1rem;
font-size:inherit;
}
.details.fixed {
max-width:93rem;
}
.details .cover {
display: block;
width: 284px;
height: 402px;
/* height: 402px; */
}
.details h2 {
@ -549,6 +559,55 @@ a:hover, a:active {
text-align:left;
}
.character,
.small_character {
background: rgba(0,0,0,0.5);
width: 225px;
height: 350px;
vertical-align: middle;
white-space: nowrap;
}
.small_character a {
display:inline-block;
width: 100%;
height: 100%;
}
.small_character .name,
.character .name {
position: absolute;
bottom: 0;
left: 0;
z-index: 10;
}
.small_character img,
.character img {
position: relative;
top: 50%;
transform: translateY(-50%);
z-index: 5;
width: 100%;
}
/* ----------------------------------------------------------------------------
User page styles
-----------------------------------------------------------------------------*/
.small_character {
width: 160px;
height: 250px;
}
.user-page .media-wrap {
text-align: left;
}
.media a {
display: inline-block;
width: 100%;
height: 100%;
}
/* ----------------------------------------------------------------------------
Viewport-based styles
-----------------------------------------------------------------------------*/

View File

@ -8,6 +8,11 @@
searchResults = JSON.parse(searchResults);
_.$('.cssload-loader')[0].setAttribute('hidden', 'hidden');
// Give mustache a key to iterate over
searchResults = {
data: searchResults.data
};
Mustache.parse(tempHtml);
_.$('#series_list')[0].innerHTML = Mustache.render(tempHtml, searchResults);
});

View File

@ -8,7 +8,6 @@
_.on('.manga.list', 'click', '.edit_buttons button', (e) => {
let thisSel = e.target;
let parentSel = _.closestParent(e.target, 'article');
let mangaId = parentSel.id.replace('manga-', '');
let type = thisSel.classList.contains('plus_one_chapter') ? 'chapter' : 'volume';
let completed = parseInt(_.$(`.${type}s_read`, parentSel)[0].textContent, 10);
let total = parseInt(_.$(`.${type}_count`, parentSel)[0].textContent, 10);
@ -20,7 +19,8 @@
// Setup the update data
let data = {
id: mangaId,
id: parentSel.dataset.kitsuId,
mal_id: parentSel.dataset.malId,
data: {
progress: completed
}

View File

@ -40,6 +40,201 @@ class JsonAPI {
*/
protected $data = [];
/**
* Inline all included data
*
* @param array $data - The raw JsonAPI response data
* @return data
*/
public static function organizeData(array $data): array
{
// relationships that have singular data
$singular = [
'waifu'
];
// Reorganize included data
$included = static::organizeIncluded($data['included']);
// Inline organized data
foreach($data['data'] as $i => $item)
{
if (array_key_exists('relationships', $item))
{
foreach($item['relationships'] as $relType => $props)
{
if (array_keys($props) === ['links'])
{
unset($data['data'][$i]['relationships'][$relType]);
if (empty($data['data'][$i]['relationships']))
{
unset($data['data'][$i]['relationships']);
}
continue;
}
if (array_key_exists('links', $props))
{
unset($data['data'][$i]['relationships'][$relType]['links']);
}
if (array_key_exists('data', $props))
{
if (empty($props['data']))
{
unset($data['data'][$i]['relationships'][$relType]['data']);
if (empty($data['data'][$i]['relationships'][$relType]))
{
unset($data['data'][$i]['relationships'][$relType]);
}
continue;
}
// Single data item
else if (array_key_exists('id', $props['data']))
{
$idKey = $props['data']['id'];
$typeKey = $props['data']['type'];
$relationship =& $data['data'][$i]['relationships'][$relType];
unset($relationship['data']);
if (in_array($relType, $singular))
{
$relationship = $included[$typeKey][$idKey];
continue;
}
if ($relType === $typeKey)
{
$relationship[$idKey] = $included[$typeKey][$idKey];
continue;
}
$relationship[$typeKey][$idKey] = $included[$typeKey][$idKey];
}
// Multiple data items
else
{
foreach($props['data'] as $j => $datum)
{
$idKey = $props['data'][$j]['id'];
$typeKey = $props['data'][$j]['type'];
$relationship =& $data['data'][$i]['relationships'][$relType];
unset($relationship['data'][$j]);
if (empty($relationship['data']))
{
unset($relationship['data']);
}
if ($relType === $typeKey)
{
$relationship[$idKey] = $included[$typeKey][$idKey];
continue;
}
$relationship[$typeKey][$idKey] = array_merge(
$included[$typeKey][$idKey],
$relationship[$typeKey][$idKey] ?? []
);
}
}
}
}
}
}
return $data['data'];
}
/**
* Restructure included data to make it simpler to inline
*
* @param array $included
* @return array
*/
public static function organizeIncluded(array $included): array
{
$organized = [];
// First pass, create [ type => items[] ] structure
foreach($included as &$item)
{
$type = $item['type'];
$id = $item['id'];
$organized[$type] = $organized[$type] ?? [];
$newItem = [];
foreach(['attributes', 'relationships'] as $key)
{
if (array_key_exists($key, $item))
{
// Remove 'links' type relationships
if ($key === 'relationships')
{
foreach($item['relationships'] as $relType => $props)
{
if (array_keys($props) === ['links'])
{
unset($item['relationships'][$relType]);
if (empty($item['relationships']))
{
continue 2;
}
}
}
}
$newItem[$key] = $item[$key];
}
}
$organized[$type][$id] = $newItem;
}
// Second pass, go through and fill missing relationships in the first pass
foreach($organized as $type => $items)
{
foreach($items as $id => $item)
{
if (array_key_exists('relationships', $item))
{
foreach($item['relationships'] as $relType => $props)
{
if (array_key_exists('data', $props))
{
if (array_key_exists($props['data']['id'], $organized[$props['data']['type']]))
{
$idKey = $props['data']['id'];
$typeKey = $props['data']['type'];
$relationship =& $organized[$type][$id]['relationships'][$relType];
unset($relationship['links']);
unset($relationship['data']);
if ($relType === $typeKey)
{
$relationship[$idKey] = $included[$typeKey][$idKey];
continue;
}
$relationship[$typeKey][$idKey] = $organized[$typeKey][$idKey];
}
}
}
}
}
}
return $organized;
}
public static function inlineRawIncludes(array &$data, string $key): array
{
foreach($data['data'] as $i => &$item)
@ -118,27 +313,7 @@ class JsonAPI {
*/
public static function lightlyOrganizeIncludes(array $includes): array
{
$organized = [];
foreach($includes as $item)
{
$type = $item['type'];
$id = $item['id'];
$organized[$type] = $organized[$type] ?? [];
$newItem = [];
foreach(['attributes', 'relationships'] as $key)
{
if (array_key_exists($key, $item))
{
$newItem[$key] = $item[$key];
}
}
$organized[$type][$id] = $newItem;
}
return $organized;
return static::organizeIncluded($includes);
}
/**

View File

@ -33,6 +33,8 @@ class ListItem extends AbstractListItem {
private function getAuthHeader()
{
$cache = $this->getContainer()->get('cache');
$cacheItem = $cache->getItem('kitsu-auth-token');
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
@ -43,6 +45,12 @@ class ListItem extends AbstractListItem {
return "bearer {$token}";
}
if ($cacheItem->isHit())
{
$token = $cacheItem->get();
return "bearer {$token}";
}
return FALSE;
}

View File

@ -19,7 +19,12 @@ namespace Aviat\AnimeClient\API\Kitsu;
use function Amp\{all, wait};
use Amp\Artax\{Client, Request};
use Aviat\AnimeClient\API\{CacheTrait, JsonAPI, Kitsu as K};
use Aviat\AnimeClient\API\{
CacheTrait,
JsonAPI,
Kitsu as K,
ParallelAPIRequest
};
use Aviat\AnimeClient\API\Enum\{
AnimeWatchingStatus\Title,
AnimeWatchingStatus\Kitsu as KitsuWatchingStatus,
@ -73,7 +78,6 @@ class Model {
*/
protected $mangaListTransformer;
/**
* Constructor
*
@ -88,6 +92,34 @@ class Model {
$this->mangaListTransformer = new MangaListTransformer();
}
/**
* Get the access token from the Kitsu API
*
* @param string $username
* @param string $password
* @return bool|string
*/
public function authenticate(string $username, string $password)
{
$response = $this->getResponse('POST', K::AUTH_URL, [
'headers' => [],
'form_params' => [
'grant_type' => 'password',
'username' => $username,
'password' => $password
]
]);
$data = Json::decode((string)$response->getBody());
if (array_key_exists('access_token', $data))
{
return $data;
}
return FALSE;
}
/**
* Get the userid for a username from Kitsu
*
@ -132,7 +164,7 @@ class Model {
$data = $this->getRequest('/characters', [
'query' => [
'filter' => [
'slug' => $slug
'name' => $slug
],
// 'include' => 'primaryMedia,castings'
]
@ -149,45 +181,86 @@ class Model {
*/
public function getUserData(string $username): array
{
$userId = $this->getUserIdByUsername($username);
$data = $this->getRequest("/users/{$userId}", [
// $userId = $this->getUserIdByUsername($username);
$data = $this->getRequest("/users", [
'query' => [
'include' => 'waifu,pinnedPost,blocks,linkedAccounts,profileLinks,profileLinks.profileLinkSite,mediaFollows,userRoles'
'filter' => [
'name' => $username,
],
'fields' => [
// 'anime' => 'slug,name,canonicalTitle',
'characters' => 'slug,name,image'
],
'include' => 'waifu,pinnedPost,blocks,linkedAccounts,profileLinks,profileLinks.profileLinkSite,mediaFollows,userRoles,favorites.item'
]
]);
// $data['included'] = JsonAPI::organizeIncludes($data['included']);
return $data;
}
/**
* Get the access token from the Kitsu API
* Search for an anime or manga
*
* @param string $username
* @param string $password
* @return bool|string
* @param string $type - 'anime' or 'manga'
* @param string $query - name of the item to search for
* @return array
*/
public function authenticate(string $username, string $password)
public function search(string $type, string $query): array
{
$response = $this->getResponse('POST', K::AUTH_URL, [
'headers' => [],
'form_params' => [
'grant_type' => 'password',
'username' => $username,
'password' => $password
$options = [
'query' => [
'filter' => [
'text' => $query
],
'page' => [
'offset' => 0,
'limit' => 20
],
]
]);
];
$data = Json::decode((string)$response->getBody());
$raw = $this->getRequest($type, $options);
if (array_key_exists('access_token', $data))
foreach ($raw['data'] as &$item)
{
return $data;
$item['attributes']['titles'] = K::filterTitles($item['attributes']);
array_shift($item['attributes']['titles']);
}
return FALSE;
return $raw;
}
/**
* Find a media item on Kitsu by its associated MAL id
*
* @param string $malId
* @param string $type "anime" or "manga"
* @return string
*/
public function getKitsuIdFromMALId(string $malId, string $type="anime"): string
{
$options = [
'query' => [
'filter' => [
'external_site' => "myanimelist/{$type}",
'external_id' => $malId
],
'fields' => [
'media' => 'id,slug'
],
'include' => 'media'
]
];
$raw = $this->getRequest('mappings', $options);
return $raw['included'][0]['id'];
}
// -------------------------------------------------------------------------
// ! Anime-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular anime
*
@ -220,202 +293,6 @@ class Model {
return $this->animeTransformer->transform($baseData);
}
/**
* Get the mal id for the anime represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForAnime(string $kitsuAnimeId)
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->getRequest("anime/{$kitsuAnimeId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/anime')
{
return $map['externalId'];
}
}
return NULL;
}
/**
* Get information about a particular manga
*
* @param string $mangaId
* @return array
*/
public function getManga(string $mangaId): array
{
$baseData = $this->getRawMediaData('manga', $mangaId);
if (empty($baseData))
{
return [];
}
$transformed = $this->mangaTransformer->transform($baseData);
$transformed['included'] = $baseData['included'];
return $transformed;
}
/**
* Get the number of anime list items
*
* @param string $status - Optional status to filter by
* @return int
*/
public function getAnimeListCount(string $status = '') : int
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserIdByUsername(),
'media_type' => 'Anime'
],
'page' => [
'limit' => 1
],
'sort' => '-updated_at'
]
];
if ( ! empty($status))
{
$options['query']['filter']['status'] = $status;
}
$response = $this->getRequest('library-entries', $options);
return $response['meta']['count'];
}
/**
* Get the full anime list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
*/
public function getPagedAnimeList(int $limit = 100, int $offset = 0, array $options = [
'include' => 'anime.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'media_type' => 'Anime'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the full anime list
*
* @param array $options
* @return array
*/
public function getFullAnimeList(array $options = [
'include' => 'anime.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getAnimeListCount($status);
$size = 100;
$pages = ceil($count / $size);
$requests = [];
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requests[] = $this->getPagedAnimeList($size, $offset, $options);
}
$promiseArray = (new Client())->requestMulti($requests);
$responses = wait(all($promiseArray));
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response->getBody());
$output = array_merge_recursive($output, $data);
}
return $output;
}
/**
* Get the raw (unorganized) anime list for the configured user
*
* @param string $status - The watching status to filter the list with
* @return array
*/
public function getRawAnimeList(string $status): array
{
$options = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'media_type' => 'Anime',
'status' => $status,
],
'include' => 'media,media.genres,media.mappings,anime.streamingLinks',
'sort' => '-updated_at'
];
return $this->getFullAnimeList($options);
}
/**
* Get all the anine entries, that are organized for output to html
*
* @return array
*/
public function getFullOrganizedAnimeList(): array
{
$cacheItem = $this->cache->getItem(self::FULL_TRANSFORMED_LIST_CACHE_KEY);
if ( ! $cacheItem->isHit())
{
$output = [];
$statuses = KitsuWatchingStatus::getConstList();
foreach ($statuses as $key => $status)
{
$mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getAnimeList($status) ?? [];
}
$cacheItem->set($output);
$cacheItem->save();
}
return $cacheItem->get();
}
/**
* Get the anime list for the configured user
*
@ -455,23 +332,200 @@ class Model {
}
/**
* Get all Manga lists
* Get the number of anime list items
*
* @param string $status - Optional status to filter by
* @return int
*/
public function getAnimeListCount(string $status = '') : int
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserIdByUsername(),
'media_type' => 'Anime'
],
'page' => [
'limit' => 1
],
'sort' => '-updated_at'
]
];
if ( ! empty($status))
{
$options['query']['filter']['status'] = $status;
}
$response = $this->getRequest('library-entries', $options);
return $response['meta']['count'];
}
/**
* Get the full anime list
*
* @param array $options
* @return array
*/
public function getFullOrganizedMangaList(): array
public function getFullAnimeList(array $options = [
'include' => 'anime.mappings'
]): array
{
$statuses = KitsuReadingStatus::getConstList();
$status = $options['filter']['status'] ?? '';
$count = $this->getAnimeListCount($status);
$size = 100;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedAnimeList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach ($statuses as $status)
foreach($responses as $response)
{
$mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getMangaList($status);
$data = Json::decode($response->getBody());
$output = array_merge_recursive($output, $data);
}
return $output;
}
/**
* Get all the anine entries, that are organized for output to html
*
* @return array
*/
public function getFullOrganizedAnimeList(): array
{
$output = [];
$statuses = KitsuWatchingStatus::getConstList();
foreach ($statuses as $key => $status)
{
$mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getAnimeList($status) ?? [];
}
return $output;
}
/**
* Get the mal id for the anime represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForAnime(string $kitsuAnimeId)
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->getRequest("anime/{$kitsuAnimeId}", $options);
if ( ! array_key_exists('included', $data))
{
return NULL;
}
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/anime')
{
return $map['externalId'];
}
}
return NULL;
}
/**
* Get the full anime list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
*/
public function getPagedAnimeList(int $limit = 100, int $offset = 0, array $options = [
'include' => 'anime.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'media_type' => 'Anime'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the raw (unorganized) anime list for the configured user
*
* @param string $status - The watching status to filter the list with
* @return array
*/
public function getRawAnimeList(string $status): array
{
$options = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'media_type' => 'Anime',
'status' => $status,
],
'include' => 'media,media.genres,media.mappings,anime.streamingLinks',
'sort' => '-updated_at'
];
return $this->getFullAnimeList($options);
}
// -------------------------------------------------------------------------
// ! Manga-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular manga
*
* @param string $slug
* @return array
*/
public function getManga(string $slug): array
{
$baseData = $this->getRawMediaData('manga', $slug);
if (empty($baseData))
{
return [];
}
$transformed = $this->mangaTransformer->transform($baseData);
$transformed['included'] = $baseData['included'];
return $transformed;
}
/**
* Get the manga list for the configured user
*
@ -489,7 +543,7 @@ class Model {
'media_type' => 'Manga',
'status' => $status,
],
'include' => 'media',
'include' => 'media,media.genres,media.mappings',
'page' => [
'offset' => $offset,
'limit' => $limit
@ -503,9 +557,16 @@ class Model {
if ( ! $cacheItem->isHit())
{
$data = $this->getRequest('library-entries', $options);
$data = JsonAPI::inlineRawIncludes($data, 'manga');
$transformed = $this->mangaListTransformer->transformCollection($data);
$included = JsonAPI::organizeIncludes($data['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'manga');
foreach($data['data'] as $i => &$item)
{
$item['included'] = $included;
}
$transformed = $this->mangaListTransformer->transformCollection($data['data']);
$cacheItem->set($transformed);
$cacheItem->save();
@ -515,37 +576,150 @@ class Model {
}
/**
* Search for an anime or manga
* Get the number of manga list items
*
* @param string $type - 'anime' or 'manga'
* @param string $query - name of the item to search for
* @return array
* @param string $status - Optional status to filter by
* @return int
*/
public function search(string $type, string $query): array
public function getMangaListCount(string $status = '') : int
{
$options = [
'query' => [
'filter' => [
'text' => $query
'user_id' => $this->getUserIdByUsername(),
'media_type' => 'Manga'
],
'page' => [
'offset' => 0,
'limit' => 20
'limit' => 1
],
'sort' => '-updated_at'
]
];
$raw = $this->getRequest($type, $options);
foreach ($raw['data'] as &$item)
if ( ! empty($status))
{
$item['attributes']['titles'] = K::filterTitles($item['attributes']);
array_shift($item['attributes']['titles']);
$options['query']['filter']['status'] = $status;
}
return $raw;
$response = $this->getRequest('library-entries', $options);
return $response['meta']['count'];
}
/**
* Get the full manga list
*
* @param array $options
* @return array
*/
public function getFullMangaList(array $options = [
'include' => 'manga.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getMangaListCount($status);
$size = 100;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedMangaList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response->getBody());
$output = array_merge_recursive($output, $data);
}
return $output;
}
/**
* Get all Manga lists
*
* @return array
*/
public function getFullOrganizedMangaList(): array
{
$statuses = KitsuReadingStatus::getConstList();
$output = [];
foreach ($statuses as $status)
{
$mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getMangaList($status);
}
return $output;
}
/**
* Get the full manga list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
*/
public function getPagedMangaList(int $limit = 100, int $offset = 0, array $options = [
'include' => 'manga.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'media_type' => 'Manga'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the mal id for the manga represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForManga(string $kitsuMangaId)
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->getRequest("manga/{$kitsuMangaId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/manga')
{
return $map['externalId'];
}
}
return NULL;
}
// -------------------------------------------------------------------------
// ! Generic API calls
// -------------------------------------------------------------------------
/**
* Create a list item
*
@ -665,6 +839,9 @@ class Model {
'filter' => [
'slug' => $slug
],
'fields' => [
'characters' => 'slug,name,image'
],
'include' => ($type === 'anime')
? 'genres,mappings,streamingLinks,animeCharacters.character'
: 'genres,mappings,mangaCharacters.character,castings.character',

View File

@ -123,7 +123,7 @@ class AnimeListTransformer extends AbstractTransformer {
]
];
if ( ! empty($item['user_rating']))
if (is_numeric($item['user_rating']))
{
$untransformed['data']['rating'] = $item['user_rating'] / 2;
}

View File

@ -35,22 +35,42 @@ class MangaListTransformer extends AbstractTransformer {
*/
public function transform($item)
{
$manga =& $item['manga'];
$included = $item['included'];
$mangaId = $item['relationships']['media']['data']['id'];
$manga = $included['manga'][$mangaId];
$genres = array_column($manga['relationships']['genres'], 'name') ?? [];
sort($genres);
$rating = (is_numeric($item['attributes']['rating']))
? intval(2 * $item['attributes']['rating'])
: '-';
$totalChapters = ($manga['attributes']['chapterCount'] > 0)
? $manga['attributes']['chapterCount']
$totalChapters = ($manga['chapterCount'] > 0)
? $manga['chapterCount']
: '-';
$totalVolumes = ($manga['attributes']['volumeCount'] > 0)
? $manga['attributes']['volumeCount']
$totalVolumes = ($manga['volumeCount'] > 0)
? $manga['volumeCount']
: '-';
$MALid = NULL;
if (array_key_exists('mappings', $manga['relationships']))
{
foreach ($manga['relationships']['mappings'] as $mapping)
{
if ($mapping['externalSite'] === 'myanimelist/manga')
{
$MALid = $mapping['externalId'];
break;
}
}
}
$map = [
'id' => $item['id'],
'mal_id' => $MALid,
'chapters' => [
'read' => $item['attributes']['progress'],
'total' => $totalChapters
@ -60,13 +80,13 @@ class MangaListTransformer extends AbstractTransformer {
'total' => $totalVolumes
],
'manga' => [
'titles' => Kitsu::filterTitles($manga['attributes']),
'titles' => Kitsu::filterTitles($manga),
'alternate_title' => NULL,
'slug' => $manga['attributes']['slug'],
'url' => 'https://kitsu.io/manga/' . $manga['attributes']['slug'],
'type' => $manga['attributes']['mangaType'],
'image' => $manga['attributes']['posterImage']['small'],
'genres' => [], //$manga['genres'],
'slug' => $manga['slug'],
'url' => 'https://kitsu.io/manga/' . $manga['slug'],
'type' => $manga['mangaType'],
'image' => $manga['posterImage']['small'],
'genres' => $genres,
],
'reading_status' => $item['attributes']['status'],
'notes' => $item['attributes']['notes'],
@ -90,16 +110,21 @@ class MangaListTransformer extends AbstractTransformer {
$map = [
'id' => $item['id'],
'mal_id' => $item['mal_id'],
'data' => [
'status' => $item['status'],
'progress' => (int)$item['chapters_read'],
'reconsuming' => $rereading,
'reconsumeCount' => (int)$item['reread_count'],
'notes' => $item['notes'],
'rating' => $item['new_rating'] / 2
],
];
if (is_numeric($item['new_rating']))
{
$map['data']['rating'] = $item['new_rating'] / 2;
}
return $map;
}
}

View File

@ -30,7 +30,14 @@ class ListItem {
use ContainerAware;
use MALTrait;
public function create(array $data): Request
/**
* Create a list item
*
* @param array $data
* @param string $type
* @return Request
*/
public function create(array $data, string $type = 'anime'): Request
{
$id = $data['id'];
$createData = [
@ -42,17 +49,24 @@ class ListItem {
$config = $this->container->get('config');
return $this->requestBuilder->newRequest('POST', "animelist/add/{$id}.xml")
return $this->requestBuilder->newRequest('POST', "{$type}list/add/{$id}.xml")
->setFormFields($createData)
->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password']))
->getFullRequest();
}
public function delete(string $id): Request
/**
* Delete a list item
*
* @param string $id
* @param string $type
* @return Request
*/
public function delete(string $id, string $type = 'anime'): Request
{
$config = $this->container->get('config');
return $this->requestBuilder->newRequest('DELETE', "animelist/delete/{$id}.xml")
return $this->requestBuilder->newRequest('DELETE', "{$type}list/delete/{$id}.xml")
->setFormFields([
'id' => $id
])
@ -67,7 +81,15 @@ class ListItem {
return [];
}
public function update(string $id, array $data): Request
/**
* Update a list item
*
* @param string $id
* @param array $data
* @param string $type
* @return Request
*/
public function update(string $id, array $data, string $type = 'anime'): Request
{
$config = $this->container->get('config');
@ -76,7 +98,7 @@ class ListItem {
->addField('id', $id)
->addField('data', $xml);
return $this->requestBuilder->newRequest('POST', "animelist/update/{$id}.xml")
return $this->requestBuilder->newRequest('POST', "{$type}list/update/{$id}.xml")
->setFormFields([
'id' => $id,
'data' => $xml

View File

@ -64,21 +64,6 @@ trait MALTrait {
return $this;
}
/**
* Unencode the dual-encoded ampersands in the body
*
* This is a dirty hack until I can fully track down where
* the dual-encoding happens
*
* @param FormBody $formBody The form builder object to fix
* @return string
*/
private function fixBody(FormBody $formBody): string
{
$rawBody = \Amp\wait($formBody->getBody());
return html_entity_decode($rawBody, \ENT_HTML5, 'UTF-8');
}
/**
* Create a request object
*

View File

@ -17,10 +17,13 @@
namespace Aviat\AnimeClient\API\MAL;
use Amp\Artax\Request;
use Aviat\AnimeClient\API\MAL\ListItem;
use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer;
use Aviat\AnimeClient\API\MAL\{
ListItem,
Transformer\AnimeListTransformer,
Transformer\MangaListTransformer
};
use Aviat\AnimeClient\API\XML;
use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus;
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\Ion\Di\ContainerAware;
/**
@ -48,15 +51,25 @@ class Model {
public function __construct(ListItem $listItem)
{
$this->animeListTransformer = new AnimeListTransformer();
$this->mangaListTransformer = new MangaListTransformer();
$this->listItem = $listItem;
}
public function createFullListItem(array $data): Request
/**
* Create a list item on MAL
*
* @param array $data
* @param string $type "anime" or "manga"
* @return Request
*/
public function createFullListItem(array $data, string $type = 'anime'): Request
{
return $this->listItem->create($data);
return $this->listItem->create($data, $type);
}
public function createListItem(array $data): Request
public function createListItem(array $data, string $type = 'anime'): Request
{
if ($type === 'anime')
{
$createData = [
'id' => $data['id'],
@ -64,11 +77,55 @@ class Model {
'status' => AnimeWatchingStatus::KITSU_TO_MAL[$data['status']]
]
];
return $this->listItem->create($createData);
}
elseif ($type === 'manga')
{
$createData = [
'id' => $data['id'],
'data' => [
'status' => MangaReadingStatus::KITSU_TO_MAL[$data['status']]
]
];
}
public function getFullList(): array
return $this->listItem->create($createData, $type);
}
public function getMangaList(): array
{
return $this->getList('manga');
}
public function getAnimeList(): array
{
return $this->getList('anime');
}
public function getListItem(string $listId): array
{
return [];
}
public function updateListItem(array $data, string $type = 'anime'): Request
{
if ($type === 'anime')
{
$updateData = $this->animeListTransformer->untransform($data);
}
else if ($type === 'manga')
{
$updateData = $this->mangaListTransformer->untransform($data);
}
return $this->listItem->update($updateData['id'], $updateData['data'], $type);
}
public function deleteListItem(string $id, string $type = 'anime'): Request
{
return $this->listItem->delete($id, $type);
}
private function getList(string $type): array
{
$config = $this->container->get('config');
$userName = $config->get(['mal', 'username']);
@ -78,26 +135,11 @@ class Model {
],
'query' => [
'u' => $userName,
'status' => 'all'
'status' => 'all',
'type' => $type
]
]);
return $list['myanimelist']['anime'];
}
public function getListItem(string $listId): array
{
return [];
}
public function updateListItem(array $data): Request
{
$updateData = $this->animeListTransformer->untransform($data);
return $this->listItem->update($updateData['id'], $updateData['data']);
}
public function deleteListItem(string $id): Request
{
return $this->listItem->delete($id);
return $list['myanimelist'][$type];
}
}

View File

@ -24,26 +24,14 @@ use Aviat\Ion\Transformer\AbstractTransformer;
*/
class AnimeListTransformer extends AbstractTransformer {
/**
* Transform MAL episode data to Kitsu episode data
* Identity transformation
*
* @param array $item
* @return array
*/
public function transform($item)
{
$rewatching = (array_key_exists('rewatching', $item) && $item['rewatching']);
return [
'id' => $item['mal_id'],
'data' => [
'status' => AnimeWatchingStatus::KITSU_TO_MAL[$item['watching_status']],
'rating' => $item['user_rating'],
'rewatch_value' => (int) $rewatching,
'times_rewatched' => $item['rewatched'],
'comments' => $item['notes'],
'episode' => $item['episodes_watched']
]
];
return $item;
}
/**

View File

@ -0,0 +1,85 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\MAL\Transformer;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\Ion\Transformer\AbstractTransformer;
/**
* Transformer for updating MAL List
*/
class MangaListTransformer extends AbstractTransformer {
/**
* Identity transformation
*
* @param array $item
* @return array
*/
public function transform($item)
{
return $item;
}
/**
* Transform Kitsu data to MAL data
*
* @param array $item
* @return array
*/
public function untransform(array $item): array
{
$map = [
'id' => $item['mal_id'],
'data' => [
'chapter' => $item['data']['progress']
]
];
$data =& $item['data'];
foreach($item['data'] as $key => $value)
{
switch($key)
{
case 'notes':
$map['data']['comments'] = $value;
break;
case 'rating':
$map['data']['score'] = $value * 2;
break;
case 'reconsuming':
$map['data']['enable_rereading'] = (bool) $value;
break;
case 'reconsumeCount':
$map['data']['times_reread'] = $value;
break;
case 'status':
$map['data']['status'] = MangaReadingStatus::KITSU_TO_MAL[$value];
break;
default:
break;
}
}
return $map;
}
}

View File

@ -29,7 +29,7 @@ use Aviat\Ion\Enum;
* and url route segments
*/
class MangaReadingStatus extends Enum {
const MAL_TO_KITSU = [
const KITSU_TO_MAL = [
Kitsu::READING => MAL::READING,
Kitsu::PLAN_TO_READ => MAL::PLAN_TO_READ,
Kitsu::COMPLETED => MAL::COMPLETED,
@ -37,12 +37,17 @@ class MangaReadingStatus extends Enum {
Kitsu::DROPPED => MAL::DROPPED
];
const KITSU_TO_MAL = [
const MAL_TO_KITSU = [
'1' => Kitsu::READING,
'2' => Kitsu::COMPLETED,
'3' => Kitsu::ON_HOLD,
'4' => Kitsu::DROPPED,
'6' => Kitsu::PLAN_TO_READ,
MAL::READING => Kitsu::READING,
MAL::PLAN_TO_READ => Kitsu::PLAN_TO_READ,
MAL::COMPLETED => Kitsu::COMPLETED,
MAL::ON_HOLD => Kitsu::ON_HOLD,
MAL::DROPPED => Kitsu::DROPPED
MAL::DROPPED => Kitsu::DROPPED,
MAL::PLAN_TO_READ => Kitsu::PLAN_TO_READ,
];
const KITSU_TO_TITLE = [
@ -50,7 +55,7 @@ class MangaReadingStatus extends Enum {
Kitsu::PLAN_TO_READ => Title::PLAN_TO_READ,
Kitsu::COMPLETED => Title::COMPLETED,
Kitsu::ON_HOLD => Title::ON_HOLD,
Kitsu::DROPPED => Title::DROPPED
Kitsu::DROPPED => Title::DROPPED,
];
const ROUTE_TO_KITSU = [
@ -58,7 +63,7 @@ class MangaReadingStatus extends Enum {
Route::READING => Kitsu::READING,
Route::COMPLETED => Kitsu::COMPLETED,
Route::DROPPED => Kitsu::DROPPED,
Route::ON_HOLD => Kitsu::ON_HOLD
Route::ON_HOLD => Kitsu::ON_HOLD,
];
const ROUTE_TO_TITLE = [
@ -67,7 +72,7 @@ class MangaReadingStatus extends Enum {
Route::READING => Title::READING,
Route::COMPLETED => Title::COMPLETED,
Route::DROPPED => Title::DROPPED,
Route::ON_HOLD => Title::ON_HOLD
Route::ON_HOLD => Title::ON_HOLD,
];
const TITLE_TO_KITSU = [
@ -75,6 +80,6 @@ class MangaReadingStatus extends Enum {
Title::READING => Kitsu::READING,
Title::COMPLETED => Kitsu::COMPLETED,
Title::DROPPED => Kitsu::DROPPED,
Title::ON_HOLD => Kitsu::ON_HOLD
Title::ON_HOLD => Kitsu::ON_HOLD,
];
}

View File

@ -21,25 +21,14 @@ use Yosymfony\Toml\Toml;
define('SRC_DIR', realpath(__DIR__));
const SESSION_SEGMENT = 'Aviat\AnimeClient\Auth';
const DEFAULT_CONTROLLER = 'Aviat\AnimeClient\Controller\Index';
const DEFAULT_CONTROLLER_NAMESPACE = 'Aviat\AnimeClient\Controller';
const DEFAULT_CONTROLLER = 'Aviat\AnimeClient\Controller\Anime';
const DEFAULT_LIST_CONTROLLER = 'Aviat\AnimeClient\Controller\Anime';
const DEFAULT_CONTROLLER_METHOD = 'index';
const NOT_FOUND_METHOD = 'notFound';
const ERROR_MESSAGE_METHOD = 'errorPage';
const SRC_DIR = SRC_DIR;
/**
* Joins paths together. Variadic to take an
* arbitrary number of arguments
*
* @param string[] ...$args
* @return string
*/
function _dir(...$args)
{
return implode(DIRECTORY_SEPARATOR, $args);
}
/**
* Load configuration options from .toml files
*

View File

@ -19,8 +19,16 @@ namespace Aviat\AnimeClient\Command;
use function Amp\{all, wait};
use Amp\Artax\Client;
use Aviat\AnimeClient\API\{JsonAPI, Mapping\AnimeWatchingStatus};
use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer as ALT;
use Aviat\AnimeClient\API\{
JsonAPI,
ParallelAPIRequest,
Mapping\AnimeWatchingStatus,
Mapping\MangaReadingStatus
};
use Aviat\AnimeClient\API\MAL\Transformer\{
AnimeListTransformer as ALT,
MangaListTransformer as MLT
};
use Aviat\Ion\Json;
/**
@ -55,63 +63,71 @@ class SyncKitsuWithMal extends BaseCommand {
$this->kitsuModel = $this->container->get('kitsu-model');
$this->malModel = $this->container->get('mal-model');
$malCount = count($this->getMALAnimeList());
$kitsuCount = $this->getKitsuAnimeListPageCount();
$this->syncAnime();
$this->syncManga();
}
$this->echoBox("Number of MAL list items: {$malCount}");
$this->echoBox("Number of Kitsu list items: {$kitsuCount}");
public function syncAnime()
{
$malCount = count($this->malModel->getAnimeList());
$kitsuCount = $this->kitsuModel->getAnimeListCount();
$this->echoBox("Number of MAL anime list items: {$malCount}");
$this->echoBox("Number of Kitsu anime list items: {$kitsuCount}");
$data = $this->diffAnimeLists();
$this->echoBox("Number of items that need to be added to MAL: " . count($data));
$this->echoBox("Number of anime items that need to be added to MAL: " . count($data['addToMAL']));
if ( ! empty($data['addToMAL']))
{
$this->echoBox("Adding missing list items to MAL");
$this->createMALAnimeListItems($data['addToMAL']);
}
$this->echoBox("Adding missing anime list items to MAL");
$this->createMALListItems($data['addToMAL'], 'anime');
}
public function getKitsuAnimeList()
$this->echoBox('Number of anime items that need to be added to Kitsu: ' . count($data['addToKitsu']));
if ( ! empty($data['addToKitsu']))
{
$count = $this->getKitsuAnimeListPageCount();
$size = 100;
$pages = ceil($count / $size);
$this->echoBox("Adding missing anime list items to Kitsu");
$this->createKitusListItems($data['addToKitsu'], 'anime');
}
}
$requests = [];
// Set up requests
for ($i = 0; $i < $pages; $i++)
public function syncManga()
{
$offset = $i * $size;
$requests[] = $this->kitsuModel->getPagedAnimeList($size, $offset);
}
$malCount = count($this->malModel->getMangaList());
$kitsuCount = $this->kitsuModel->getMangaListCount();
$promiseArray = (new Client())->requestMulti($requests);
$this->echoBox("Number of MAL manga list items: {$malCount}");
$this->echoBox("Number of Kitsu manga list items: {$kitsuCount}");
$responses = wait(all($promiseArray));
$output = [];
$data = $this->diffMangaLists();
foreach($responses as $response)
$this->echoBox("Number of manga items that need to be added to MAL: " . count($data['addToMAL']));
if ( ! empty($data['addToMAL']))
{
$data = Json::decode($response->getBody());
$output = array_merge_recursive($output, $data);
$this->echoBox("Adding missing manga list items to MAL");
$this->createMALListItems($data['addToMAL'], 'manga');
}
return $output;
}
$this->echoBox('Number of manga items that need to be added to Kitsu: ' . count($data['addToKitsu']));
public function getMALAnimeList()
if ( ! empty($data['addToKitsu']))
{
return $this->malModel->getFullList();
$this->echoBox("Adding missing manga list items to Kitsu");
$this->createKitsuListItems($data['addToKitsu'], 'manga');
}
}
public function filterMappings(array $includes): array
public function filterMappings(array $includes, string $type = 'anime'): array
{
$output = [];
foreach($includes as $id => $mapping)
{
if ($mapping['externalSite'] === 'myanimelist/anime')
if ($mapping['externalSite'] === "myanimelist/{$type}")
{
$output[$id] = $mapping;
}
@ -122,7 +138,7 @@ class SyncKitsuWithMal extends BaseCommand {
public function formatMALAnimeList()
{
$orig = $this->getMALAnimeList();
$orig = $this->malModel->getAnimeList();
$output = [];
foreach($orig as $item)
@ -137,7 +153,37 @@ class SyncKitsuWithMal extends BaseCommand {
? $item['times_rewatched']
: 0,
// 'notes' => ,
'rating' => $item['my_score'],
'rating' => $item['my_score'] / 2,
'updatedAt' => (new \DateTime())
->setTimestamp((int)$item['my_last_updated'])
->format(\DateTime::W3C),
]
];
}
return $output;
}
public function formatMALMangaList()
{
$orig = $this->malModel->getMangaList();
$output = [];
foreach($orig as $item)
{
$output[$item['series_mangadb_id']] = [
'id' => $item['series_mangadb_id'],
'data' => [
'my_status' => $item['my_status'],
'status' => MangaReadingStatus::MAL_TO_KITSU[$item['my_status']],
'progress' => $item['my_read_chapters'],
'volumes' => $item['my_read_volumes'],
'reconsuming' => (bool) $item['my_rereadingg'],
/* 'reconsumeCount' => array_key_exists('times_rewatched', $item)
? $item['times_rewatched']
: 0, */
// 'notes' => ,
'rating' => $item['my_score'] / 2,
'updatedAt' => (new \DateTime())
->setTimestamp((int)$item['my_last_updated'])
->format(\DateTime::W3C),
@ -186,9 +232,84 @@ class SyncKitsuWithMal extends BaseCommand {
return $output;
}
public function getKitsuAnimeListPageCount()
public function filterKitsuMangaList()
{
return $this->kitsuModel->getAnimeListCount();
$data = $this->kitsuModel->getFullMangaList();
$includes = JsonAPI::organizeIncludes($data['included']);
$includes['mappings'] = $this->filterMappings($includes['mappings'], 'manga');
$output = [];
foreach($data['data'] as $listItem)
{
$mangaId = $listItem['relationships']['manga']['data']['id'];
$potentialMappings = $includes['manga'][$mangaId]['relationships']['mappings'];
$malId = NULL;
foreach ($potentialMappings as $mappingId)
{
if (array_key_exists($mappingId, $includes['mappings']))
{
$malId = $includes['mappings'][$mappingId]['externalId'];
}
}
// Skip to the next item if there isn't a MAL ID
if (is_null($malId))
{
continue;
}
$output[$listItem['id']] = [
'id' => $listItem['id'],
'malId' => $malId,
'data' => $listItem['attributes'],
];
}
return $output;
}
public function diffMangaLists()
{
$kitsuList = $this->filterKitsuMangaList();
$malList = $this->formatMALMangaList();
$itemsToAddToMAL = [];
$itemsToAddToKitsu = [];
$malIds = array_column($malList, 'id');
$kitsuMalIds = array_column($kitsuList, 'malId');
$missingMalIds = array_diff($malIds, $kitsuMalIds);
foreach($missingMalIds as $mid)
{
$itemsToAddToKitsu[] = array_merge($malList[$mid]['data'], [
'id' => $this->kitsuModel->getKitsuIdFromMALId($mid, 'manga'),
'type' => 'manga'
]);
}
foreach($kitsuList as $kitsuItem)
{
if (in_array($kitsuItem['malId'], $malIds))
{
// Eventually, compare the list entries, and determine which
// needs to be updated
continue;
}
// Looks like this item only exists on Kitsu
$itemsToAddToMAL[] = [
'mal_id' => $kitsuItem['malId'],
'data' => $kitsuItem['data']
];
}
return [
'addToMAL' => $itemsToAddToMAL,
'addToKitsu' => $itemsToAddToKitsu
];
}
public function diffAnimeLists()
@ -201,10 +322,26 @@ class SyncKitsuWithMal extends BaseCommand {
$malList = $this->formatMALAnimeList();
$itemsToAddToMAL = [];
$itemsToAddToKitsu = [];
$malUpdateItems = [];
$kitsuUpdateItems = [];
$malIds = array_column($malList, 'id');
$kitsuMalIds = array_column($kitsuList, 'malId');
$missingMalIds = array_diff($malIds, $kitsuMalIds);
foreach($missingMalIds as $mid)
{
// print_r($malList[$mid]);
$itemsToAddToKitsu[] = array_merge($malList[$mid]['data'], [
'id' => $this->kitsuModel->getKitsuIdFromMALId($mid),
'type' => 'anime'
]);
}
foreach($kitsuList as $kitsuItem)
{
if (array_key_exists($kitsuItem['malId'], $malList))
if (in_array($kitsuItem['malId'], $malIds))
{
// Eventually, compare the list entries, and determine which
// needs to be updated
@ -230,34 +367,60 @@ class SyncKitsuWithMal extends BaseCommand {
return [
'addToMAL' => $itemsToAddToMAL,
'updateMAL' => $malUpdateItems,
'addToKitsu' => $itemsToAddToKitsu,
'updateKitsu' => $kitsuUpdateItems
];
}
public function createMALAnimeListItems($itemsToAdd)
public function createKitusAnimeListItems($itemsToAdd, $type = 'anime')
{
$requester = new ParallelAPIRequest();
foreach($itemsToAdd as $item)
{
$requester->addRequest($this->kitsuModel->createListItem($item));
}
$responses = $requester->makeRequests();
foreach($responses as $key => $response)
{
$id = $itemsToAdd[$key]['id'];
if ($response->getStatus() === 201)
{
$this->echoBox("Successfully created Kitsu {$type} list item with id: {$id}");
}
else
{
echo $response->getBody();
$this->echoBox("Failed to create Kitsu {$type} list item with id: {$id}");
}
}
}
public function createMALListItems($itemsToAdd, $type = 'anime')
{
$transformer = new ALT();
$requests = [];
$requester = new ParallelAPIRequest();
foreach($itemsToAdd as $item)
{
$data = $transformer->untransform($item);
$requests[] = $this->malModel->createFullListItem($data);
$requester->addRequest($this->malModel->createFullListItem($data, $type));
}
$promiseArray = (new Client())->requestMulti($requests);
$responses = wait(all($promiseArray));
$responses = $requester->makeRequests();
foreach($responses as $key => $response)
{
$id = $itemsToAdd[$key]['mal_id'];
if ($response->getBody() === 'Created')
{
$this->echoBox("Successfully create list item with id: {$id}");
$this->echoBox("Successfully created MAL {$type} list item with id: {$id}");
}
else
{
$this->echoBox("Failed to create list item with id: {$id}");
$this->echoBox("Failed to create MAL {$type} list item with id: {$id}");
}
}
}

View File

@ -18,7 +18,7 @@ namespace Aviat\AnimeClient;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use function Aviat\AnimeClient\_dir;
use function Aviat\Ion\_dir;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
@ -31,7 +31,66 @@ use InvalidArgumentException;
* @property Response object $response
*/
class Controller {
use ControllerTrait;
use ContainerAware;
/**
* Cache manager
* @var \Psr\Cache\CacheItemPoolInterface
*/
protected $cache;
/**
* The global configuration object
* @var \Aviat\Ion\ConfigInterface $config
*/
public $config;
/**
* Request object
* @var object $request
*/
protected $request;
/**
* Response object
* @var object $response
*/
public $response;
/**
* The api model for the current controller
* @var object
*/
protected $model;
/**
* Url generation class
* @var UrlGenerator
*/
protected $urlGenerator;
/**
* Aura url generator
* @var \Aura\Router\Generator
*/
protected $url;
/**
* Session segment
* @var \Aura\Session\Segment
*/
protected $session;
/**
* Common data to be sent to views
* @var array
*/
protected $baseData = [
'url_type' => 'anime',
'other_type' => 'manga',
'menu_name' => ''
];
/**
* Constructor
@ -55,6 +114,7 @@ class Controller {
'config' => $this->config
]);
$this->url = $auraUrlGenerator;
$this->urlGenerator = $urlGenerator;
$session = $container->get('session');
@ -72,81 +132,267 @@ class Controller {
}
/**
* Show the user profile page
* Redirect to the previous page
*
* @return void
*/
public function me()
public function redirectToPrevious()
{
$username = $this->config->get(['kitsu_username']);
$model = $this->container->get('kitsu-model');
$data = $model->getUserData($username);
$included = JsonAPI::lightlyOrganizeIncludes($data['included']);
$relationships = JsonAPI::fillRelationshipsFromIncludes($data['data']['relationships'], $included);
$this->outputHTML('me', [
'title' => 'About' . $this->config->get('whose_list'),
'attributes' => $data['data']['attributes'],
'relationships' => $relationships,
'included' => $included
$previous = $this->session->getFlash('previous');
$this->redirect($previous, 303);
}
/**
* Set the current url in the session as the target of a future redirect
*
* @param string|null $url
* @return void
*/
public function setSessionRedirect(string $url = NULL)
{
$serverParams = $this->request->getServerParams();
if ( ! array_key_exists('HTTP_REFERER', $serverParams))
{
return;
}
$util = $this->container->get('util');
$doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri();
// 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 page_segments
if ($doubleFormPage)
{
return;
}
if (is_null($url))
{
$url = $util->isViewPage()
? $this->request->url->get()
: $serverParams['HTTP_REFERER'];
}
$this->session->set('redirect_url', $url);
}
/**
* Redirect to the url previously set in the session
*
* @return void
*/
public function sessionRedirect()
{
$target = $this->session->get('redirect_url');
if (empty($target))
{
$this->notFound();
}
else
{
$this->redirect($target, 303);
$this->session->set('redirect_url', NULL);
}
}
/**
* Get the string output of a partial template
*
* @param HtmlView $view
* @param string $template
* @param array $data
* @throws InvalidArgumentException
* @return string
*/
protected function loadPartial($view, string $template, array $data = [])
{
$router = $this->container->get('dispatcher');
if (isset($this->baseData))
{
$data = array_merge($this->baseData, $data);
}
$route = $router->getRoute();
$data['route_path'] = $route ? $router->getRoute()->path : '';
$templatePath = _dir($this->config->get('view_path'), "{$template}.php");
if ( ! is_file($templatePath))
{
throw new InvalidArgumentException("Invalid template : {$template}");
}
return $view->renderTemplate($templatePath, (array)$data);
}
/**
* Render a template with header and footer
*
* @param HtmlView $view
* @param string $template
* @param array $data
* @return void
*/
protected function renderFullPage($view, string $template, array $data)
{
$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']));
}
$view->appendOutput($this->loadPartial($view, $template, $data));
$view->appendOutput($this->loadPartial($view, 'footer', $data));
}
/**
* 404 action
*
* @return void
*/
public function notFound(
string $title = 'Sorry, page not found',
string $message = 'Page Not Found'
)
{
$this->outputHTML('404', [
'title' => $title,
'message' => $message,
], NULL, 404);
}
/**
* Display a generic error page
*
* @param int $httpCode
* @param string $title
* @param string $message
* @param string $long_message
* @return void
*/
public function errorPage(int $httpCode, string $title, string $message, string $long_message = "")
{
$this->outputHTML('error', [
'title' => $title,
'message' => $message,
'long_message' => $long_message
], NULL, $httpCode);
}
/**
* Redirect to the default controller/url from an empty path
*
* @return void
*/
public function redirectToDefaultRoute()
{
$defaultType = $this->config->get(['routes', 'route_config', 'default_list']) ?? 'anime';
$this->redirect($this->urlGenerator->defaultUrl($defaultType), 303);
}
/**
* Set a session flash variable to display a message on
* next page load
*
* @param string $message
* @param string $type
* @return void
*/
public function setFlashMessage(string $message, string $type = "info")
{
static $messages;
if ( ! $messages)
{
$messages = [];
}
$messages[] = [
'message_type' => $type,
'message' => $message
];
$this->session->setFlash('message', $messages);
}
/**
* Helper for consistent page titles
*
* @param string ...$parts Title segements
* @return string
*/
public function formatTitle(string ...$parts) : string
{
return implode(' &middot; ', $parts);
}
/**
* Add a message box to the page
*
* @param HtmlView $view
* @param string $type
* @param string $message
* @return string
*/
protected function showMessage($view, string $type, string $message): string
{
return $this->loadPartial($view, 'message', [
'message_type' => $type,
'message' => $message
]);
}
/**
* Show the login form
* Output a template to HTML, using the provided data
*
* @param string $status
* @param string $template
* @param array $data
* @param HtmlView|null $view
* @param int $code
* @return void
*/
public function login(string $status = '')
protected function outputHTML(string $template, array $data = [], $view = NULL, int $code = 200)
{
if (is_null($view))
{
$message = '';
$view = new HtmlView($this->container);
if ($status !== '')
{
$message = $this->showMessage($view, 'error', $status);
}
// Set the redirect url
$this->setSessionRedirect();
$this->outputHTML('login', [
'title' => 'Api login',
'message' => $message
], $view);
$view->setStatusCode($code);
$this->renderFullPage($view, $template, $data);
}
/**
* Attempt login authentication
* Output a JSON Response
*
* @param mixed $data
* @param int $code - the http status code
* @return void
*/
public function loginAction()
protected function outputJSON($data = 'Empty response', int $code = 200)
{
$auth = $this->container->get('auth');
$post = $this->request->getParsedBody();
if ($auth->authenticate($post['password']))
{
$this->sessionRedirect();
return;
}
$this->setFlashMessage('Invalid username or password.');
$this->redirect($this->urlGenerator->url('login'), 303);
(new JsonView($this->container))
->setStatusCode($code)
->setOutput($data)
->send();
}
/**
* Deauthorize the current user
* Redirect to the selected page
*
* @param string $url
* @param int $code
* @return void
*/
public function logout()
protected function redirect(string $url, int $code)
{
$auth = $this->container->get('auth');
$auth->logout();
$this->redirectToDefaultRoute();
$http = new HttpView($this->container);
$http->redirect($url, $code);
}
}
// End of BaseController.php

View File

@ -116,7 +116,7 @@ class Anime extends BaseController {
$this->config->get('whose_list') . "'s Anime List",
'Add'
),
'action_url' => $this->urlGenerator->url('anime/add'),
'action_url' => $this->url->generate('anime.add.post'),
'status_list' => AnimeWatchingStatus::KITSU_TO_TITLE
]);
}
@ -168,8 +168,9 @@ class Anime extends BaseController {
),
'item' => $item,
'statuses' => AnimeWatchingStatus::KITSU_TO_TITLE,
'action' => $this->container->get('url-generator')
->url('/anime/update_form'),
'action' => $this->url->generate('update.post', [
'controller' => 'anime'
]),
]);
}

View File

@ -41,18 +41,6 @@ class Collection extends BaseController {
*/
private $animeModel;
/**
* Data to be sent to all routes in this controller
* @var array $baseData
*/
protected $baseData;
/**
* Url Generator class
* @var UrlGenerator
*/
protected $urlGenerator;
/**
* Constructor
*
@ -62,7 +50,6 @@ class Collection extends BaseController {
{
parent::__construct($container);
$this->urlGenerator = $container->get('url-generator');
$this->animeModel = $container->get('anime-model');
$this->animeCollectionModel = $container->get('anime-collection-model');
$this->baseData = array_merge($this->baseData, [
@ -118,10 +105,11 @@ class Collection extends BaseController {
$this->setSessionRedirect();
$action = (is_null($id)) ? "Add" : "Edit";
$urlAction = strtolower($action);
$this->outputHTML('collection/' . strtolower($action), [
$this->outputHTML('collection/' . $urlAction, [
'action' => $action,
'action_url' => $this->urlGenerator->fullUrl('collection/' . strtolower($action)),
'action_url' => $this->url->generate("collection.{$urlAction}.post"),
'title' => $this->formatTitle(
$this->config->get('whose_list') . "'s Anime Collection",
$action

138
src/Controller/Index.php Normal file
View File

@ -0,0 +1,138 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\Ion\View\HtmlView;
class Index extends BaseController {
/**
* Purges the API cache
*
* @return void
*/
public function clearCache()
{
$this->cache->clear();
$this->outputHTML('blank', [
'title' => 'Cache cleared'
], NULL, 200);
}
/**
* Show the login form
*
* @param string $status
* @return void
*/
public function login(string $status = '')
{
$message = '';
$view = new HtmlView($this->container);
if ($status !== '')
{
$message = $this->showMessage($view, 'error', $status);
}
// Set the redirect url
$this->setSessionRedirect();
$this->outputHTML('login', [
'title' => 'Api login',
'message' => $message
], $view);
}
/**
* Attempt login authentication
*
* @return void
*/
public function loginAction()
{
$auth = $this->container->get('auth');
$post = $this->request->getParsedBody();
if ($auth->authenticate($post['password']))
{
$this->sessionRedirect();
return;
}
$this->setFlashMessage('Invalid username or password.');
$this->redirect($this->url->generate('login'), 303);
}
/**
* Deauthorize the current user
*
* @return void
*/
public function logout()
{
$auth = $this->container->get('auth');
$auth->logout();
$this->redirectToDefaultRoute();
}
/**
* Show the user profile page
*
* @return void
*/
public function me()
{
$username = $this->config->get(['kitsu_username']);
$model = $this->container->get('kitsu-model');
$data = $model->getUserData($username);
$orgData = JsonAPI::organizeData($data);
$this->outputHTML('me', [
'title' => 'About' . $this->config->get('whose_list'),
'data' => $orgData[0],
'attributes' => $orgData[0]['attributes'],
'relationships' => $orgData[0]['relationships'],
'favorites' => $this->organizeFavorites($orgData[0]['relationships']['favorites']),
]);
}
private function organizeFavorites(array $rawfavorites): array
{
// return $rawfavorites;
$output = [];
foreach($rawfavorites as $item)
{
$rank = $item['attributes']['favRank'];
foreach($item['relationships']['item'] as $key => $fav)
{
$output[$key] = $output[$key] ?? [];
foreach ($fav as $id => $data)
{
$output[$key][$rank] = $data['attributes'];
}
}
ksort($output[$key]);
}
return $output;
}
}

View File

@ -98,17 +98,7 @@ class Manga extends Controller {
*/
public function addForm()
{
$raw_status_list = MangaReadingStatus::getConstList();
$statuses = [];
foreach ($raw_status_list as $status_item)
{
$statuses[$status_item] = (string)$this->string($status_item)
->underscored()
->humanize()
->titleize();
}
$statuses = MangaReadingStatus::KITSU_TO_TITLE;
$this->setSessionRedirect();
$this->outputHTML('manga/add', [
@ -116,7 +106,7 @@ class Manga extends Controller {
$this->config->get('whose_list') . "'s Manga List",
'Add'
),
'action_url' => $this->urlGenerator->url('manga/add'),
'action_url' => $this->url->generate('manga.add.post'),
'status_list' => $statuses
]);
}
@ -169,8 +159,9 @@ class Manga extends Controller {
'title' => $title,
'status_list' => MangaReadingStatus::KITSU_TO_TITLE,
'item' => $item,
'action' => $this->container->get('url-generator')
->url('/manga/update_form'),
'action' => $this->url->generate('update.post', [
'controller' => 'manga'
]),
]);
}
@ -221,7 +212,7 @@ class Manga extends Controller {
*/
public function update()
{
if ($this->request->getHeader('content-type')[0] === 'application/json')
if (stripos($this->request->getHeader('content-type')[0], 'application/json') !== FALSE)
{
$data = Json::decode((string)$this->request->getBody());
}
@ -245,7 +236,8 @@ class Manga extends Controller {
{
$body = $this->request->getParsedBody();
$id = $body['id'];
$response = $this->model->deleteLibraryItem($id);
$malId = $body['mal_id'];
$response = $this->model->deleteLibraryItem($id, $malId);
if ($response)
{

View File

@ -1,378 +0,0 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use function Aviat\AnimeClient\_dir;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
use Aviat\Ion\View\{HtmlView, HttpView, JsonView};
use InvalidArgumentException;
trait ControllerTrait {
use ContainerAware;
/**
* Cache manager
* @var \Psr\Cache\CacheItemPoolInterface
*/
protected $cache;
/**
* The global configuration object
* @var \Aviat\Ion\ConfigInterface $config
*/
protected $config;
/**
* Request object
* @var object $request
*/
protected $request;
/**
* Response object
* @var object $response
*/
protected $response;
/**
* The api model for the current controller
* @var object
*/
protected $model;
/**
* Url generation class
* @var UrlGenerator
*/
protected $urlGenerator;
/**
* Session segment
* @var \Aura\Session\Segment
*/
protected $session;
/**
* Common data to be sent to views
* @var array
*/
protected $baseData = [
'url_type' => 'anime',
'other_type' => 'manga',
'menu_name' => ''
];
/**
* Redirect to the default controller/url from an empty path
*
* @return void
*/
public function redirectToDefaultRoute()
{
$defaultType = $this->config->get(['routes', 'route_config', 'default_list']);
$this->redirect($this->urlGenerator->defaultUrl($defaultType), 303);
}
/**
* Redirect to the previous page
*
* @return void
*/
public function redirectToPrevious()
{
$previous = $this->session->getFlash('previous');
$this->redirect($previous, 303);
}
/**
* Set the current url in the session as the target of a future redirect
*
* @param string|null $url
* @return void
*/
public function setSessionRedirect($url = NULL)
{
$serverParams = $this->request->getServerParams();
if ( ! array_key_exists('HTTP_REFERER', $serverParams))
{
return;
}
$util = $this->container->get('util');
$doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri();
// 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 page_segments
if ($doubleFormPage)
{
return;
}
if (is_null($url))
{
$url = $util->isViewPage()
? $this->request->url->get()
: $serverParams['HTTP_REFERER'];
}
$this->session->set('redirect_url', $url);
}
/**
* Redirect to the url previously set in the session
*
* @return void
*/
public function sessionRedirect()
{
$target = $this->session->get('redirect_url');
if (empty($target))
{
$this->notFound();
}
else
{
$this->redirect($target, 303);
$this->session->set('redirect_url', NULL);
}
}
/**
* Get a class member
*
* @param string $key
* @return mixed
*/
public function __get(string $key)
{
$allowed = ['response', 'config'];
if (in_array($key, $allowed))
{
return $this->$key;
}
return NULL;
}
/**
* Get the string output of a partial template
*
* @param HtmlView $view
* @param string $template
* @param array $data
* @throws InvalidArgumentException
* @return string
*/
protected function loadPartial($view, $template, array $data = [])
{
$router = $this->container->get('dispatcher');
if (isset($this->baseData))
{
$data = array_merge($this->baseData, $data);
}
$route = $router->getRoute();
$data['route_path'] = $route ? $router->getRoute()->path : '';
$templatePath = _dir($this->config->get('view_path'), "{$template}.php");
if ( ! is_file($templatePath))
{
throw new InvalidArgumentException("Invalid template : {$template}");
}
return $view->renderTemplate($templatePath, (array)$data);
}
/**
* Render a template with header and footer
*
* @param HtmlView $view
* @param string $template
* @param array $data
* @return void
*/
protected function renderFullPage($view, $template, array $data)
{
$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']));
}
$view->appendOutput($this->loadPartial($view, $template, $data));
$view->appendOutput($this->loadPartial($view, 'footer', $data));
}
/**
* 404 action
*
* @return void
*/
public function notFound(
string $title = 'Sorry, page not found',
string $message = 'Page Not Found'
)
{
$this->outputHTML('404', [
'title' => $title,
'message' => $message,
], NULL, 404);
}
/**
* Display a generic error page
*
* @param int $httpCode
* @param string $title
* @param string $message
* @param string $long_message
* @return void
*/
public function errorPage($httpCode, $title, $message, $long_message = "")
{
$this->outputHTML('error', [
'title' => $title,
'message' => $message,
'long_message' => $long_message
], NULL, $httpCode);
}
/**
* Set a session flash variable to display a message on
* next page load
*
* @param string $message
* @param string $type
* @return void
*/
public function setFlashMessage($message, $type = "info")
{
static $messages;
if ( ! $messages)
{
$messages = [];
}
$messages[] = [
'message_type' => $type,
'message' => $message
];
$this->session->setFlash('message', $messages);
}
/**
* Purges the API cache
*
* @return void
*/
public function clearCache()
{
$this->cache->clear();
$this->outputHTML('blank', [
'title' => 'Cache cleared'
], NULL, 200);
}
/**
* Helper for consistent page titles
*
* @param string ...$parts Title segements
* @return string
*/
public function formatTitle(string ...$parts) : string
{
return implode(' &middot; ', $parts);
}
/**
* Add a message box to the page
*
* @param HtmlView $view
* @param string $type
* @param string $message
* @return string
*/
protected function showMessage($view, $type, $message)
{
return $this->loadPartial($view, 'message', [
'message_type' => $type,
'message' => $message
]);
}
/**
* Output a template to HTML, using the provided data
*
* @param string $template
* @param array $data
* @param HtmlView|null $view
* @param int $code
* @return void
*/
protected function outputHTML($template, array $data = [], $view = NULL, $code = 200)
{
if (is_null($view))
{
$view = new HtmlView($this->container);
}
$view->setStatusCode($code);
$this->renderFullPage($view, $template, $data);
}
/**
* Output a JSON Response
*
* @param mixed $data
* @param int $code - the http status code
* @return void
*/
protected function outputJSON($data = 'Empty response', int $code = 200)
{
(new JsonView($this->container))
->setStatusCode($code)
->setOutput($data)
->send();
}
/**
* Redirect to the selected page
*
* @param string $url
* @param int $code
* @return void
*/
protected function redirect($url, $code)
{
$http = new HttpView($this->container);
$http->redirect($url, $code);
}
}

View File

@ -24,7 +24,7 @@ use const Aviat\AnimeClient\{
SRC_DIR
};
use function Aviat\AnimeClient\_dir;
use function Aviat\Ion\_dir;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Friend;

View File

@ -16,10 +16,7 @@
namespace Aviat\AnimeClient;
use Aviat\Ion\
{
ArrayWrapper, StringWrapper
};
use Aviat\Ion\{ArrayWrapper, StringWrapper};
use Aviat\Ion\Di\ContainerInterface;
/**

View File

@ -21,6 +21,13 @@ namespace Aviat\AnimeClient\Model;
*/
class API extends AbstractModel {
/**
* Whether to use the MAL api
*
* @var boolean
*/
protected $useMALAPI;
/**
* Sort the list entries by their title
*

View File

@ -39,13 +39,6 @@ class Anime extends API {
*/
protected $malModel;
/**
* Whether to use the MAL api
*
* @var boolean
*/
protected $useMALAPI;
/**
* Anime constructor.
*
@ -53,10 +46,10 @@ class Anime extends API {
*/
public function __construct(ContainerInterface $container)
{
$config = $container->get('config');
$this->kitsuModel = $container->get('kitsu-model');
$this->malModel = $container->get('mal-model');
$config = $container->get('config');
$this->useMALAPI = $config->get(['use_mal_api']) === TRUE;
}
@ -66,7 +59,7 @@ class Anime extends API {
* @param string $status
* @return array
*/
public function getList($status)
public function getList($status): array
{
$data = $this->kitsuModel->getAnimeList($status);
$this->sortByName($data, 'anime');
@ -79,7 +72,12 @@ class Anime extends API {
return $output;
}
public function getAllLists()
/**
* Get data for the 'all' anime page
*
* @return array
*/
public function getAllLists(): array
{
$data = $this->kitsuModel->getFullOrganizedAnimeList();
@ -97,7 +95,7 @@ class Anime extends API {
* @param string $slug
* @return array
*/
public function getAnime($slug)
public function getAnime(string $slug): array
{
return $this->kitsuModel->getAnime($slug);
}
@ -108,7 +106,7 @@ class Anime extends API {
* @param string $animeId
* @return array
*/
public function getAnimeById($animeId)
public function getAnimeById(string $animeId): array
{
return $this->kitsuModel->getAnimeById($animeId);
}
@ -119,7 +117,7 @@ class Anime extends API {
* @param string $name
* @return array
*/
public function search($name)
public function search(string $name): array
{
return $this->kitsuModel->search('anime', $name);
}

View File

@ -16,9 +16,13 @@
namespace Aviat\AnimeClient\Model;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Title;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\AnimeClient\API\{
Enum\MangaReadingStatus\Title,
Mapping\MangaReadingStatus,
ParallelAPIRequest
};
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Json;
/**
* Model for handling requests dealing with the manga list
@ -46,6 +50,9 @@ class Manga extends API
{
$this->kitsuModel = $container->get('kitsu-model');
$this->malModel = $container->get('mal-model');
$config = $container->get('config');
$this->useMALAPI = $config->get(['use_mal_api']) === TRUE;
}
/**
@ -77,17 +84,6 @@ class Manga extends API
return $this->kitsuModel->getManga($manga_id);
}
/**
* Create a new manga list item
*
* @param array $data
* @return bool
*/
public function createLibraryItem(array $data): bool
{
return $this->kitsuModel->createListItem($data);
}
/**
* Get information about a specific list item
* for editing/updating that item
@ -100,6 +96,35 @@ class Manga extends API
return $this->kitsuModel->getListItem($itemId);
}
/**
* Create a new manga list item
*
* @param array $data
* @return bool
*/
public function createLibraryItem(array $data): bool
{
$requester = new ParallelAPIRequest();
if ($this->useMALAPI)
{
$malData = $data;
$malId = $this->kitsuModel->getMalIdForManga($malData['id']);
if ( ! is_null($malId))
{
$malData['id'] = $malId;
$requester->addRequest($this->malModel->createListItem($malData, 'manga'), 'mal');
}
}
$requester->addRequest($this->kitsuModel->createListItem($data), 'kitsu');
$results = $requester->makeRequests(TRUE);
return count($results[1]) > 0;
}
/**
* Update a list entry
*
@ -108,18 +133,44 @@ class Manga extends API
*/
public function updateLibraryItem(array $data): array
{
return $this->kitsuModel->updateListItem($data);
$requester = new ParallelAPIRequest();
if ($this->useMALAPI)
{
$requester->addRequest($this->malModel->updateListItem($data, 'manga'), 'mal');
}
$requester->addRequest($this->kitsuModel->updateListItem($data), 'kitsu');
$results = $requester->makeRequests(TRUE);
return [
'body' => Json::decode($results[1]['kitsu']->getBody()),
'statusCode' => $results[1]['kitsu']->getStatus()
];
}
/**
* Remove a list entry
* Delete a list entry
*
* @param string $itemId
* @param string $id
* @param string|null $malId
* @return bool
*/
public function deleteLibraryItem(string $itemId): bool
public function deleteLibraryItem(string $id, string $malId = NULL): bool
{
return $this->kitsuModel->deleteListItem($itemId);
$requester = new ParallelAPIRequest();
if ($this->useMALAPI && ! is_null($malId))
{
$requester->addRequest($this->malModel->deleteListItem($malId, 'manga'), 'MAL');
}
$requester->addRequest($this->kitsuModel->deleteListItem($id), 'kitsu');
$results = $requester->makeRequests(TRUE);
return count($results[1]) > 0;
}
/**

View File

@ -59,9 +59,8 @@ class RoutingBase {
{
$this->container = $container;
$this->config = $container->get('config');
$baseRoutes = $this->config->get('routes');
$this->routes = $baseRoutes['routes'];
$this->routeConfig = $baseRoutes['route_config'];
$this->routes = $this->config->get('routes');
$this->routeConfig = $this->config->get('route_config');
}
/**
@ -72,11 +71,9 @@ class RoutingBase {
*/
public function __get($key)
{
$routingConfig =& $this->routeConfig;
if (array_key_exists($key, $routingConfig))
if (array_key_exists($key, $this->routeConfig))
{
return $routingConfig[$key];
return $this->routeConfig[$key];
}
}

View File

@ -108,32 +108,5 @@ class UrlGenerator extends RoutingBase {
throw new InvalidArgumentException("Invalid default type: '{$type}'");
}
/**
* Generate full url path from the route path based on config
*
* @param string $path - (optional) The route path
* @param string $type - (optional) The controller (anime or manga), defaults to anime
* @return string
*/
public function fullUrl(string $path = "", string $type = "anime"): string
{
$configDefaultRoute = $this->__get("default_{$type}_path");
// Remove beginning/trailing slashes
$path = trim($path, '/');
// Set the default view
if ($path === '')
{
$path .= trim($configDefaultRoute, '/');
if ($this->__get('default_to_list_view'))
{
$path .= '/list';
}
}
return $this->url($path);
}
}
// End of UrlGenerator.php

View File

@ -32,10 +32,21 @@ class MangaListTransformerTest extends AnimeClientTestCase {
public function setUp()
{
parent::setUp();
$kitsuModel = $this->container->get('kitsu-model');
$this->dir = AnimeClientTestCase::TEST_DATA_DIR . '/Kitsu';
// Prep for transform
$rawBefore = Json::decodeFile("{$this->dir}/mangaListBeforeTransform.json");
$this->beforeTransform = JsonAPI::inlineRawIncludes($rawBefore, 'manga');
$included = JsonAPI::organizeIncludes($rawBefore['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'manga');
foreach($rawBefore['data'] as $i => &$item)
{
$item['included'] = $included;
}
$this->beforeTransform = $rawBefore['data'];
$this->afterTransform = Json::decodeFile("{$this->dir}/mangaListAfterTransform.json");
$this->transformer = new MangaListTransformer();
@ -54,7 +65,8 @@ class MangaListTransformerTest extends AnimeClientTestCase {
public function testUntransform()
{
$input = [
'id' => "15084773",
'id' => '15084773',
'mal_id' => '26769',
'chapters_read' => 67,
'manga' => [
'titles' => ["Bokura wa Minna Kawaisou"],
@ -75,6 +87,7 @@ class MangaListTransformerTest extends AnimeClientTestCase {
$actual = $this->transformer->untransform($input);
$expected = [
'id' => '15084773',
'mal_id' => '26769',
'data' => [
'status' => 'current',
'progress' => 67,

View File

@ -0,0 +1,40 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Tests\API\MAL;
use Aviat\AnimeClient\API\MAL\ListItem;
use Aviat\AnimeClient\API\MAL\MALRequestBuilder;
use Aviat\AnimeClient\Tests\AnimeClientTestCase;
use Aviat\Ion\Di\ContainerAware;
class ListItemTest extends AnimeClientTestCase {
protected $listItem;
public function setUp()
{
parent::setUp();
$this->listItem = new ListItem();
$this->listItem->setContainer($this->container);
$this->listItem->setRequestBuilder(new MALRequestBuilder());
}
public function testGet()
{
$this->assertEquals([], $this->listItem->get('foo'));
}
}

View File

@ -0,0 +1,51 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Tests\API\MAL;
use Aviat\AnimeClient\API\MAL\MALRequestBuilder;
use Aviat\AnimeClient\API\MAL\MALTrait;
use Aviat\AnimeClient\Tests\AnimeClientTestCase;
use Aviat\Ion\Di\ContainerAware;
class MALTraitTest extends AnimeClientTestCase {
protected $obj;
public function setUp()
{
parent::setUp();
$this->obj = new class {
use ContainerAware;
use MALTrait;
};
$this->obj->setContainer($this->container);
$this->obj->setRequestBuilder(new MALRequestBuilder());
}
public function testSetupRequest()
{
$request = $this->obj->setUpRequest('GET', 'foo', [
'query' => [
'foo' => 'bar'
],
'body' => ''
]);
$this->assertInstanceOf(\Amp\Artax\Request::class, $request);
$this->assertEquals($request->getUri(), 'https://myanimelist.net/api/foo?foo=bar');
$this->assertEquals($request->getBody(), '');
}
}

View File

@ -14,26 +14,22 @@
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Tests;
namespace Aviat\AnimeClient\Tests\API\MAL;
use Aviat\AnimeClient\ControllerTrait;
use Aviat\AnimeClient\Tests\AnimeClientTestCase;
class ControllerTraitTest extends AnimeClientTestCase {
class ModelTest extends AnimeClientTestCase {
protected $model;
public function setUp()
{
parent::setUp();
$this->controller = new class {
use ControllerTrait;
};
$this->model = $this->container->get('mal-model');
}
public function testFormatTitle()
public function testGetListItem()
{
$this->assertEquals(
$this->controller->formatTitle('foo', 'bar', 'baz'),
'foo &middot; bar &middot; baz'
);
$this->assertEquals([], $this->model->getListItem('foo'));
}
}

View File

@ -1,29 +0,0 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Tests;
use function Aviat\AnimeClient\_dir;
class AnimeClientTest extends AnimeClientTestCase {
/**
* Basic sanity test for _dir function
*/
public function testDir()
{
$this->assertEquals('foo' . \DIRECTORY_SEPARATOR . 'bar', _dir('foo', 'bar'));
}
}

View File

@ -18,7 +18,7 @@ namespace Aviat\AnimeClient\Tests;
use const Aviat\AnimeClient\SRC_DIR;
use function Aviat\AnimeClient\_dir;
use function Aviat\Ion\_dir;
use Aura\Web\WebFactory;
use Aviat\Ion\Json;
@ -95,13 +95,15 @@ class AnimeClientTestCase extends TestCase {
'file' => ':memory:',
]
],
'routes' => [
'route_config' => [
'asset_path' => '/assets'
],
'routes' => [
]
],
'mal' => [
'username' => 'foo',
'password' => 'bar'
]
];

View File

@ -80,31 +80,12 @@ class ControllerTest extends AnimeClientTestCase {
$this->assertTrue(is_object($this->BaseController));
}
public function dataGet()
public function testFormatTitle()
{
return [
'response' => [
'key' => 'response',
],
'config' => [
'key' => 'config',
]
];
}
/**
* @dataProvider dataGet
*/
public function testGet($key)
{
$result = $this->BaseController->__get($key);
$this->assertEquals($this->container->get($key), $result);
}
public function testGetNull()
{
$result = $this->BaseController->__get('foo');
$this->assertNull($result);
$this->assertEquals(
$this->BaseController->formatTitle('foo', 'bar', 'baz'),
'foo &middot; bar &middot; baz'
);
}
}

View File

@ -71,7 +71,6 @@ class DispatcherTest extends AnimeClientTestCase {
public function dataRoute()
{
$defaultConfig = [
'routes' => [
'routes' => [
'login_form' => [
'path' => '/login',
@ -104,7 +103,6 @@ class DispatcherTest extends AnimeClientTestCase {
'manga_path' => 'manga',
'default_list' => 'anime'
]
],
];
$data = [
@ -134,8 +132,8 @@ class DispatcherTest extends AnimeClientTestCase {
]
];
$data['manga_default_routing_anime']['config']['routes']['route_config']['default_list'] = 'manga';
$data['manga_default_routing_manga']['config']['routes']['route_config']['default_list'] = 'manga';
$data['manga_default_routing_anime']['config']['route_config']['default_list'] = 'manga';
$data['manga_default_routing_manga']['config']['route_config']['default_list'] = 'manga';
return $data;
}
@ -169,7 +167,6 @@ class DispatcherTest extends AnimeClientTestCase {
public function testDefaultRoute()
{
$config = [
'routes' => [
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
@ -201,7 +198,6 @@ class DispatcherTest extends AnimeClientTestCase {
]
]
]
]
];
$this->doSetUp($config, "/", "localhost");
@ -217,7 +213,6 @@ class DispatcherTest extends AnimeClientTestCase {
return [
'controller_list_sanity_check' => [
'config' => [
'routes' => [
'routes' => [
],
@ -228,18 +223,17 @@ class DispatcherTest extends AnimeClientTestCase {
'default_manga_list_path' => 'all',
'default_list' => 'manga'
],
]
],
'expected' => [
'anime' => 'Aviat\AnimeClient\Controller\Anime',
'manga' => 'Aviat\AnimeClient\Controller\Manga',
'collection' => 'Aviat\AnimeClient\Controller\Collection',
'character' => 'Aviat\AnimeClient\Controller\Character',
'index' => 'Aviat\AnimeClient\Controller\Index',
]
],
'empty_controller_list' => [
'config' => [
'routes' => [
'routes' => [
],
@ -250,13 +244,13 @@ class DispatcherTest extends AnimeClientTestCase {
'default_manga_path' => '/manga/all',
'default_list' => 'manga'
],
]
],
'expected' => [
'anime' => 'Aviat\AnimeClient\Controller\Anime',
'manga' => 'Aviat\AnimeClient\Controller\Manga',
'collection' => 'Aviat\AnimeClient\Controller\Collection',
'character' => 'Aviat\AnimeClient\Controller\Character',
'index' => 'Aviat\AnimeClient\Controller\Index',
]
]
];

View File

@ -49,60 +49,4 @@ class UrlGeneratorTest extends AnimeClientTestCase {
$result = $urlGenerator->assetUrl(...$args);
$this->assertEquals($expected, $result);
}
public function dataFullUrl()
{
return [
'default_view' => [
'config' => [
'routes' => [
'routes' => [],
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
'default_list' => 'manga',
'default_anime_path' => '/anime/watching',
'default_manga_path' => '/manga/all',
'default_to_list_view' => FALSE,
]
],
],
'path' => '',
'type' => 'manga',
'expected' => '//localhost/manga/all',
],
'default_view_list' => [
'config' => [
'routes' => [
'routes' => [],
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
'default_list' => 'manga',
'default_anime_path' => '/anime/watching',
'default_manga_path' => '/manga/all',
'default_to_list_view' => TRUE,
]
],
],
'path' => '',
'type' => 'manga',
'expected' => '//localhost/manga/all/list',
]
];
}
/**
* @dataProvider dataFullUrl
*/
public function testFullUrl($config, $path, $type, $expected)
{
$config = new Config($config);
$this->container->setInstance('config', $config);
$urlGenerator = new UrlGenerator($this->container);
$result = $urlGenerator->fullUrl($path, $type);
$this->assertEquals($expected, $result);
}
}

View File

@ -1,5 +1,6 @@
[{
"id": "15084773",
"mal_id": "26769",
"chapters": {
"read": 67,
"total": "-"
@ -15,7 +16,7 @@
"url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999",
"genres": []
"genres": ["Comedy", "Romance", "School", "Slice of Life", "Thriller"]
},
"reading_status": "current",
"notes": "",
@ -24,6 +25,7 @@
"user_rating": 9
}, {
"id": "15085607",
"mal_id": "16",
"chapters": {
"read": 17,
"total": 120
@ -39,7 +41,7 @@
"url": "https:\/\/kitsu.io\/manga\/love-hina",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/47\/small.jpg?1434249493",
"genres": []
"genres": ["Comedy", "Ecchi", "Harem", "Romance", "Sports"]
},
"reading_status": "current",
"notes": "",
@ -48,6 +50,7 @@
"user_rating": 7
}, {
"id": "15084529",
"mal_id": "35003",
"chapters": {
"read": 16,
"total": "-"
@ -63,7 +66,7 @@
"url": "https:\/\/kitsu.io\/manga\/yamada-kun-to-7-nin-no-majo",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/11777\/small.jpg?1438784325",
"genres": []
"genres": ["Comedy", "Ecchi", "Gender Bender", "Romance", "School", "Sports", "Supernatural"]
},
"reading_status": "current",
"notes": "",
@ -72,6 +75,7 @@
"user_rating": 9
}, {
"id": "15312827",
"mal_id": "78523",
"chapters": {
"read": 68,
"total": "-"
@ -87,7 +91,7 @@
"url": "https:\/\/kitsu.io\/manga\/relife",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/27175\/small.jpg?1464379411",
"genres": []
"genres": ["Romance", "School", "Slice of Life"]
},
"reading_status": "current",
"notes": "",
@ -95,33 +99,10 @@
"reread": 0,
"user_rating": "-"
}, {
"id": "15084772",
"id": "15084769",
"mal_id": "60815",
"chapters": {
"read": 28,
"total": 62
},
"volumes": {
"read": "-",
"total": 10
},
"manga": {
"titles": ["Usagi Drop", "Bunny Drop"],
"alternate_title": null,
"slug": "usagi-drop",
"url": "https:\/\/kitsu.io\/manga\/usagi-drop",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/7629\/small.jpg?1434265873",
"genres": []
},
"reading_status": "on_hold",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"id": "15251749",
"chapters": {
"read": 1,
"read": 43,
"total": "-"
},
"volumes": {
@ -129,111 +110,15 @@
"total": "-"
},
"manga": {
"titles": ["Shishunki Bitter Change"],
"titles": ["Joshikausei"],
"alternate_title": null,
"slug": "shishunki-bitter-change",
"url": "https:\/\/kitsu.io\/manga\/shishunki-bitter-change",
"slug": "joshikausei",
"url": "https:\/\/kitsu.io\/manga\/joshikausei",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25512\/small.jpg?1434305092",
"genres": []
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25491\/small.jpg?1434305043",
"genres": ["Comedy", "School", "Slice of Life"]
},
"reading_status": "planned",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15312881",
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Kuragehime", "Princess Jellyfish"],
"alternate_title": null,
"slug": "kuragehime",
"url": "https:\/\/kitsu.io\/manga\/kuragehime",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/5531\/small.jpg?1434261214",
"genres": []
},
"reading_status": "planned",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15315190",
"chapters": {
"read": 0,
"total": 80
},
"volumes": {
"read": "-",
"total": 9
},
"manga": {
"titles": ["Boku wa Mari no Naka", "Inside Mari"],
"alternate_title": null,
"slug": "boku-wa-mari-no-naka",
"url": "https:\/\/kitsu.io\/manga\/boku-wa-mari-no-naka",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/14261\/small.jpg?1434280674",
"genres": []
},
"reading_status": "planned",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15315189",
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Aizawa-san Zoushoku"],
"alternate_title": null,
"slug": "aizawa-san-zoushoku",
"url": "https:\/\/kitsu.io\/manga\/aizawa-san-zoushoku",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25316\/small.jpg?1434304656",
"genres": []
},
"reading_status": "planned",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15288185",
"chapters": {
"read": 28,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Tonari no Seki-kun", "My Neighbour Seki"],
"alternate_title": null,
"slug": "tonari-no-seki-kun",
"url": "https:\/\/kitsu.io\/manga\/tonari-no-seki-kun",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/21733\/small.jpg?1434297086",
"genres": []
},
"reading_status": "on_hold",
"reading_status": "current",
"notes": "",
"rereading": false,
"reread": 0,

File diff suppressed because it is too large Load Diff