Merge remote-tracking branch 'origin/develop'

This commit is contained in:
Timothy Warren 2017-03-31 17:02:26 -04:00
commit 650ddc781f
64 changed files with 3697 additions and 3282 deletions

View File

@ -3,7 +3,10 @@
## Version 4 ## Version 4
* Updated to use Kitsu API after discontinuation of Hummingbird * Updated to use Kitsu API after discontinuation of Hummingbird
* Added streaming links to list entries from the Kitsu API * 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 ## Version 3
* Converted user configuration to toml files * Converted user configuration to toml files

View File

@ -14,6 +14,7 @@
* @link https://github.com/timw4mail/HummingBirdAnimeClient * @link https://github.com/timw4mail/HummingBirdAnimeClient
*/ */
use function Aviat\AnimeClient\loadToml;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Lower level configuration // Lower level configuration
@ -23,7 +24,9 @@
$APP_DIR = realpath(__DIR__ . '/../'); $APP_DIR = realpath(__DIR__ . '/../');
$ROOT_DIR = realpath("{$APP_DIR}/../"); $ROOT_DIR = realpath("{$APP_DIR}/../");
$base_config = [ $tomlConfig = loadToml(__DIR__);
$base_config = array_merge($tomlConfig, [
'asset_dir' => "{$ROOT_DIR}/public", 'asset_dir' => "{$ROOT_DIR}/public",
// Template file path // Template file path
@ -34,6 +37,5 @@ $base_config = [
'img_cache_path' => "{$ROOT_DIR}/public/images", 'img_cache_path' => "{$ROOT_DIR}/public/images",
// Included config files // Included config files
'menus' => require 'menus.php',
'routes' => require 'routes.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,216 +17,194 @@
use const Aviat\AnimeClient\{ use const Aviat\AnimeClient\{
DEFAULT_CONTROLLER_METHOD, DEFAULT_CONTROLLER_METHOD,
DEFAULT_CONTROLLER_NAMESPACE DEFAULT_CONTROLLER
}; };
use Aviat\AnimeClient\AnimeClient; use Aviat\AnimeClient\AnimeClient;
// -------------------------------------------------------------------------
// Routing Config
//
// Maps paths to controlers and methods
// -------------------------------------------------------------------------
return [ return [
// ------------------------------------------------------------------------- // ---------------------------------------------------------------------
// Routing options // Anime List Routes
// // ---------------------------------------------------------------------
// Specify default paths and views 'anime.add.get' => [
// ------------------------------------------------------------------------- 'path' => '/anime/add',
'route_config' => [ 'action' => 'addForm',
// Path to public directory, where images/css/javascript are located, 'verb' => 'get',
// 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',
], ],
// ------------------------------------------------------------------------- 'anime.add.post' => [
// Routing Config 'path' => '/anime/add',
// 'action' => 'add',
// Maps paths to controlers and methods 'verb' => 'post',
// ------------------------------------------------------------------------- ],
'routes' => [ 'anime.details' => [
// --------------------------------------------------------------------- 'path' => '/anime/details/{id}',
// Anime List Routes 'action' => 'details',
// --------------------------------------------------------------------- 'tokens' => [
'anime.add.get' => [ 'id' => '[a-z0-9\-]+',
'path' => '/anime/add',
'action' => 'addForm',
'verb' => 'get',
], ],
'anime.add.post' => [ ],
'path' => '/anime/add', 'anime.delete' => [
'action' => 'add', 'path' => '/anime/delete',
'verb' => 'post', 'action' => 'delete',
'verb' => 'post',
],
// ---------------------------------------------------------------------
// Manga Routes
// ---------------------------------------------------------------------
'manga.search' => [
'path' => '/manga/search',
'action' => 'search',
],
'manga.add.get' => [
'path' => '/manga/add',
'action' => 'addForm',
'verb' => 'get',
],
'manga.add.post' => [
'path' => '/manga/add',
'action' => 'add',
'verb' => 'post',
],
'manga.delete' => [
'path' => '/manga/delete',
'action' => 'delete',
'verb' => 'post',
],
'manga.details' => [
'path' => '/manga/details/{id}',
'action' => 'details',
'tokens' => [
'id' => '[a-z0-9\-]+',
], ],
'anime.details' => [ ],
'path' => '/anime/details/{id}', // ---------------------------------------------------------------------
'action' => 'details', // Anime Collection Routes
'tokens' => [ // ---------------------------------------------------------------------
'id' => '[a-z0-9\-]+', 'collection.search' => [
], 'path' => '/collection/search',
'action' => 'search',
],
'collection.add.get' => [
'path' => '/collection/add',
'action' => 'form',
'params' => [],
],
'collection.edit.get' => [
'path' => '/collection/edit/{id}',
'action' => 'form',
'tokens' => [
'id' => '[0-9]+',
], ],
'anime.delete' => [ ],
'path' => '/anime/delete', 'collection.add.post' => [
'action' => 'delete', 'path' => '/collection/add',
'verb' => 'post', 'action' => 'add',
'verb' => 'post',
],
'collection.edit.post' => [
'path' => '/collection/edit',
'action' => 'edit',
'verb' => 'post',
],
'collection.view' => [
'path' => '/collection/view{/view}',
'action' => 'index',
'params' => [],
'tokens' => [
'view' => '[a-z_]+',
], ],
// --------------------------------------------------------------------- ],
// Manga Routes 'collection.delete' => [
// --------------------------------------------------------------------- 'path' => '/collection/delete',
'manga.search' => [ 'action' => 'delete',
'path' => '/manga/search', 'verb' => 'post',
'action' => 'search', ],
// ---------------------------------------------------------------------
// Manga Collection Routes
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------
// Other Routes
// ---------------------------------------------------------------------
'character' => [
'path' => '/character/{slug}',
'action' => 'index',
'params' => [],
'tokens' => [
'slug' => '[a-z0-9\-]+'
]
],
'user_info' => [
'path' => '/me',
'action' => 'me',
'controller' => 'me',
'verb' => 'get',
],
// ---------------------------------------------------------------------
// Default / Shared routes
// ---------------------------------------------------------------------
'cache_purge' => [
'path' => '/cache_purge',
'action' => 'clearCache',
'controller' => DEFAULT_CONTROLLER,
'verb' => 'get',
],
'login' => [
'path' => '/login',
'action' => 'login',
'controller' => DEFAULT_CONTROLLER,
'verb' => 'get',
],
'login.post' => [
'path' => '/login',
'action' => 'loginAction',
'controller' => DEFAULT_CONTROLLER,
'verb' => 'post',
],
'logout' => [
'path' => '/logout',
'action' => 'logout',
'controller' => DEFAULT_CONTROLLER,
],
'update' => [
'path' => '/{controller}/update',
'action' => 'update',
'verb' => 'post',
'tokens' => [
'controller' => '[a-z_]+',
], ],
'manga.add.get' => [ ],
'path' => '/manga/add', 'update.post' => [
'action' => 'addForm', 'path' => '/{controller}/update_form',
'verb' => 'get', 'action' => 'formUpdate',
'verb' => 'post',
'tokens' => [
'controller' => '[a-z_]+',
], ],
'manga.add.post' => [ ],
'path' => '/manga/add', 'edit' => [
'action' => 'add', 'path' => '/{controller}/edit/{id}/{status}',
'verb' => 'post', 'action' => 'edit',
'tokens' => [
'id' => '[0-9a-z_]+',
'status' => '([a-zA-Z\-_]|%20)+',
], ],
'manga.delete' => [ ],
'path' => '/manga/delete', 'list' => [
'action' => 'delete', 'path' => '/{controller}/{type}{/view}',
'verb' => 'post', 'action' => DEFAULT_CONTROLLER_METHOD,
], 'tokens' => [
'manga.details' => [ 'type' => '[a-z_]+',
'path' => '/manga/details/{id}', 'view' => '[a-z_]+',
'action' => 'details',
'tokens' => [
'id' => '[a-z0-9\-]+',
],
],
// ---------------------------------------------------------------------
// Anime Collection Routes
// ---------------------------------------------------------------------
'collection.search' => [
'path' => '/collection/search',
'action' => 'search',
],
'collection.add.get' => [
'path' => '/collection/add',
'action' => 'form',
'params' => [],
],
'collection.edit.get' => [
'path' => '/collection/edit/{id}',
'action' => 'form',
'tokens' => [
'id' => '[0-9]+',
],
],
'collection.add.post' => [
'path' => '/collection/add',
'action' => 'add',
'verb' => 'post',
],
'collection.edit.post' => [
'path' => '/collection/edit',
'action' => 'edit',
'verb' => 'post',
],
'collection.view' => [
'path' => '/collection/view{/view}',
'action' => 'index',
'params' => [],
'tokens' => [
'view' => '[a-z_]+',
],
],
'collection.delete' => [
'path' => '/collection/delete',
'action' => 'delete',
'verb' => 'post',
],
// ---------------------------------------------------------------------
// Manga Collection Routes
// ---------------------------------------------------------------------
// ---------------------------------------------------------------------
// Other Routes
// ---------------------------------------------------------------------
'character' => [
'path' => '/character/{slug}',
'action' => 'index',
'params' => [],
'tokens' => [
'slug' => '[a-z0-9\-]+'
]
],
'user_info' => [
'path' => '/me',
'action' => 'me',
'controller' => 'me',
'verb' => 'get',
],
// ---------------------------------------------------------------------
// Default / Shared routes
// ---------------------------------------------------------------------
'cache_purge' => [
'path' => '/cache_purge',
'action' => 'clearCache',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'verb' => 'get',
],
'login' => [
'path' => '/login',
'action' => 'login',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'verb' => 'get',
],
'login.post' => [
'path' => '/login',
'action' => 'loginAction',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'verb' => 'post',
],
'logout' => [
'path' => '/logout',
'action' => 'logout',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
],
'update' => [
'path' => '/{controller}/update',
'action' => 'update',
'verb' => 'post',
'tokens' => [
'controller' => '[a-z_]+',
],
],
'update.post' => [
'path' => '/{controller}/update_form',
'action' => 'formUpdate',
'verb' => 'post',
'tokens' => [
'controller' => '[a-z_]+',
],
],
'edit' => [
'path' => '/{controller}/edit/{id}/{status}',
'action' => 'edit',
'tokens' => [
'id' => '[0-9a-z_]+',
'status' => '([a-zA-Z\-_]|%20)+',
],
],
'list' => [
'path' => '/{controller}/{type}{/view}',
'action' => DEFAULT_CONTROLLER_METHOD,
'tokens' => [
'type' => '[a-z_]+',
'view' => '[a-z_]+',
],
],
'index_redirect' => [
'path' => '/',
'controller' => DEFAULT_CONTROLLER_NAMESPACE,
'action' => 'redirectToDefaultRoute',
], ],
], ],
'index_redirect' => [
'path' => '/',
'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"> <section class="flex flex-no-wrap">
<div> <div>
<img class="cover" width="402" height="284" src="<?= $data['cover_image'] ?>" alt="" /> <img class="cover" width="402" height="284" src="<?= $data['cover_image'] ?>" alt="" />
@ -74,30 +74,27 @@
</tbody> </tbody>
</table> </table>
<?php endif ?> <?php endif ?>
<?php /* <pre><?= print_r($characters, TRUE) ?></pre> */ ?>
</div> </div>
</section> </section>
<section>
<?php if (count($characters) > 0): ?> <?php if (count($characters) > 0): ?>
<h2>Characters</h2> <h2>Characters</h2>
<div class="flex flex-wrap"> <section class="media-wrap">
<?php foreach($characters as $char): ?> <?php foreach($characters as $char): ?>
<?php if ( ! empty($char['image']['original'])): ?> <?php if ( ! empty($char['image']['original'])): ?>
<div class="character"> <article class="character">
<?php $link = $url->generate('character', ['slug' => $char['slug']]) ?> <?php $link = $url->generate('character', ['slug' => $char['slug']]) ?>
<?= $helper->a($link, $char['name']); ?> <div class="name">
<br /> <?= $helper->a($link, $char['name']); ?>
</div>
<a href="<?= $link ?>"> <a href="<?= $link ?>">
<?= $helper->img($char['image']['original'], [ <?= $helper->img($char['image']['original'], [
'width' => '225' 'width' => '225'
]) ?> ]) ?>
</a> </a>
</div> </article>
<?php endif ?> <?php endif ?>
<?php endforeach ?> <?php endforeach ?>
</div>
<?php endif ?>
</section> </section>
<?php endif ?>
</main> </main>

View File

@ -30,7 +30,11 @@
<tr id="a-<?= $item['id'] ?>"> <tr id="a-<?= $item['id'] ?>">
<?php if ($auth->isAuthenticated()): ?> <?php if ($auth->isAuthenticated()): ?>
<td> <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> </td>
<?php endif ?> <?php endif ?>
<td class="justify"> <td class="justify">

View File

@ -1,7 +1,7 @@
<main class="details"> <main class="details fixed">
<section class="flex flex-no-wrap"> <section class="flex flex-no-wrap">
<div> <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>
<div> <div>
<h2><?= $data['name'] ?></h2> <h2><?= $data['name'] ?></h2>

View File

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

View File

@ -1,6 +1,6 @@
<main> <main>
<?php if ($auth->isAuthenticated()): ?> <?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 endif ?>
<?php if (empty($sections)): ?> <?php if (empty($sections)): ?>
<h3>There's nothing here!</h3> <h3>There's nothing here!</h3>
@ -26,12 +26,11 @@
<tr> <tr>
<?php if($auth->isAuthenticated()): ?> <?php if($auth->isAuthenticated()): ?>
<td> <td>
<a class="bracketed" href="<?= $urlGenerator->fullUrl("collection/edit/{$item['hummingbird_id']}") ?>">Edit</a> <a class="bracketed" href="<?= $url->generate('collection.edit.get', ['id' => $item['hummingbird_id']]) ?>">Edit</a>
<?php /*<a class="bracketed" href="<?= $urlGenerator->fullUrl("collection/delete/{$item['hummingbird_id']}") ?>">Delete</a>*/ ?>
</td> </td>
<?php endif ?> <?php endif ?>
<td class="align_left"> <td class="align_left">
<a href="https://hummingbird.me/anime/<?= $item['slug'] ?>"> <a href="<?= $url->generate('anime.details', ['id' => $item['slug']]) ?>">
<?= $item['title'] ?> <?= $item['title'] ?>
</a> </a>
<?= ( ! empty($item['alternate_title'])) ? " <br /><small> " . $item['alternate_title'] . "</small>" : "" ?> <?= ( ! 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 <?= $config->get('whose_list') ?>'s <?= ucfirst($url_type) ?> List
</a> </a>
<?php if($config->get("show_{$url_type}_collection")): ?> <?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 ?> <?php endif ?>
[<a href="<?= $urlGenerator->defaultUrl($other_type) ?>"><?= ucfirst($other_type) ?> List</a>] [<a href="<?= $urlGenerator->defaultUrl($other_type) ?>"><?= ucfirst($other_type) ?> List</a>]
<?php else: ?> <?php else: ?>
<a href="<?= $urlGenerator->url('collection/view') ?>"> <a href="<?= $url->generate('collection.view') ?>">
<?= $config->get('whose_list') ?>'s <?= ucfirst($url_type) ?> Collection <?= $config->get('whose_list') ?>'s <?= ucfirst($url_type) ?> Collection
</a> </a>
[<a href="<?= $urlGenerator->defaultUrl('anime') ?>">Anime List</a>] [<a href="<?= $urlGenerator->defaultUrl('anime') ?>">Anime List</a>]

View File

@ -1,6 +1,6 @@
<main> <main>
<?php if ($auth->isAuthenticated()): ?> <?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 endif ?>
<?php if (empty($sections)): ?> <?php if (empty($sections)): ?>
<h3>There's nothing here!</h3> <h3>There's nothing here!</h3>
@ -10,10 +10,11 @@
<h2><?= $escape->html($name) ?></h2> <h2><?= $escape->html($name) ?></h2>
<section class="media-wrap"> <section class="media-wrap">
<?php foreach($items as $item): ?> <?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()): ?> <?php if ($auth->isAuthenticated()): ?>
<div class="edit_buttons" hidden> <div class="edit_buttons" hidden>
<button class="plus_one_chapter">+1 Chapter</button> <button class="plus_one_chapter">+1 Chapter</button>
<?php /* <button class="plus_one_volume">+1 Volume</button> */ ?>
</div> </div>
<?php endif ?> <?php endif ?>
<img src="<?= $escape->attr($item['manga']['image']) ?>" /> <img src="<?= $escape->attr($item['manga']['image']) ?>" />
@ -29,13 +30,38 @@
<?php if ($auth->isAuthenticated()): ?> <?php if ($auth->isAuthenticated()): ?>
<div class="row"> <div class="row">
<span class="edit"> <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> </span>
</div> </div>
<?php endif ?> <?php endif ?>
<div class="row"> <div class="row">
<div class="user_rating">Rating: <?= $item['user_rating'] ?> / 10</div> <div class="user_rating">Rating: <?= $item['user_rating'] ?> / 10</div>
</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="row">
<div class="chapter_completion"> <div class="chapter_completion">
Chapters: <span class="chapters_read"><?= $item['chapters']['read'] ?></span> / 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"> <section class="flex flex-no-wrap">
<div> <div>
<img class="cover" src="<?= $data['cover_image'] ?>" alt="<?= $data['title'] ?> cover image" /> <img class="cover" src="<?= $data['cover_image'] ?>" alt="<?= $data['title'] ?> cover image" />
@ -35,26 +35,25 @@
<p><?= nl2br($data['synopsis']) ?></p> <p><?= nl2br($data['synopsis']) ?></p>
</div> </div>
</section> </section>
<section>
<?php if (count($characters) > 0): ?> <?php if (count($characters) > 0): ?>
<h2>Characters</h2> <h2>Characters</h2>
<div class="flex flex-wrap"> <section class="media-wrap">
<?php foreach($characters as $char): ?> <?php foreach($characters as $char): ?>
<?php if ( ! empty($char['image']['original'])): ?> <?php if ( ! empty($char['image']['original'])): ?>
<div class="character"> <article class="character">
<?php $link = $url->generate('character', ['slug' => $char['slug']]) ?> <?php $link = $url->generate('character', ['slug' => $char['slug']]) ?>
<?= $helper->a($link, $char['name']); ?> <div class="name">
<br /> <?= $helper->a($link, $char['name']); ?>
</div>
<a href="<?= $link ?>"> <a href="<?= $link ?>">
<?= $helper->img($char['image']['original'], [ <?= $helper->img($char['image']['original'], [
'width' => '225' 'width' => '225'
]) ?> ]) ?>
</a> </a>
</div> </article>
<?php endif ?> <?php endif ?>
<?php endforeach ?> <?php endforeach ?>
</div>
<?php endif ?>
</section> </section>
<?php endif ?>
</main> </main>

View File

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

View File

@ -1,6 +1,6 @@
<main> <main>
<?php if ($auth->isAuthenticated()): ?> <?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 endif ?>
<?php if (empty($sections)): ?> <?php if (empty($sections)): ?>
<h3>There's nothing here!</h3> <h3>There's nothing here!</h3>
@ -17,7 +17,9 @@
<th>Rating</th> <th>Rating</th>
<th>Completed Chapters</th> <th>Completed Chapters</th>
<th># of Volumes</th> <th># of Volumes</th>
<th>Attributes</th>
<th>Type</th> <th>Type</th>
<th>Genres</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -25,7 +27,11 @@
<tr id="manga-<?= $item['id'] ?>"> <tr id="manga-<?= $item['id'] ?>">
<?php if($auth->isAuthenticated()): ?> <?php if($auth->isAuthenticated()): ?>
<td> <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> </td>
<?php endif ?> <?php endif ?>
<td class="align_left"> <td class="align_left">
@ -39,7 +45,22 @@
<td><?= $item['user_rating'] ?> / 10</td> <td><?= $item['user_rating'] ?> / 10</td>
<td><?= $item['chapters']['read'] ?> / <?= $item['chapters']['total'] ?></td> <td><?= $item['chapters']['read'] ?> / <?= $item['chapters']['total'] ?></td>
<td><?= $item['volumes']['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><?= $item['manga']['type'] ?></td>
<td class="align_left">
<?= implode(', ', $item['manga']['genres']) ?>
</td>
</tr> </tr>
<?php endforeach ?> <?php endforeach ?>
</tbody> </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"> <section class="flex flex-no-wrap">
<div> <div>
<h2><?= $attributes['name'] ?></h2> <center>
<img src="<?= $attributes['avatar']['original'] ?>" alt="" /> <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 />
<br /> <br />
<table class="media_details"> <table class="media_details">
<tr>
<th colspan="2">General</th>
</tr>
<tr> <tr>
<td>Location</td> <td>Location</td>
<td><?= $attributes['location'] ?></td> <td><?= $attributes['location'] ?></td>
@ -28,6 +39,21 @@
</td> </td>
</tr> </tr>
<?php endif ?> <?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> </table>
</div> </div>
<div> <div>
@ -35,9 +61,72 @@
<dt>About:</dt> <dt>About:</dt>
<dd><?= $escape->html($attributes['bio']) ?></dd> <dd><?= $escape->html($attributes['bio']) ?></dd>
</dl> </dl>
<?php /* <pre><?= json_encode($attributes, \JSON_PRETTY_PRINT) ?></pre> <?php if ( ! empty($favorites)): ?>
<pre><?= json_encode($relationships, \JSON_PRETTY_PRINT) ?></pre> <?php if ( ! empty($favorites['characters'])): ?>
<pre><?= json_encode($included, \JSON_PRETTY_PRINT) ?></pre> */ ?> <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> </div>
</section> </section>
</main> </main>

View File

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

View File

@ -21,7 +21,7 @@
"aura/router": "^3.0", "aura/router": "^3.0",
"aura/session": "^2.0", "aura/session": "^2.0",
"aviat/banker": "^1.0.0", "aviat/banker": "^1.0.0",
"aviat/ion": "dev-master", "aviat/ion": "^2.0.0",
"monolog/monolog": "^1.0", "monolog/monolog": "^1.0",
"psr/http-message": "~1.0", "psr/http-message": "~1.0",
"psr/log": "~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 * @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net> * @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 * @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 3.1 * @version 4.0
* @link https://github.com/timw4mail/HummingBirdAnimeClient * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/ */
namespace Aviat\AnimeClient; namespace Aviat\AnimeClient;
use function Aviat\AnimeClient\loadToml; use function Aviat\AnimeClient\loadToml;
use function Aviat\Ion\_dir;
use Aviat\AnimeClient\AnimeClient;
// Work around the silly timezone error // Work around the silly timezone error
$timezone = ini_get('date.timezone'); $timezone = ini_get('date.timezone');

View File

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

View File

@ -1025,7 +1025,7 @@ a:hover, a:active {
Base list styles Base list styles
------------------------------------------------------------------------------*/ ------------------------------------------------------------------------------*/
.media { .media, .character, .small_character {
position:relative; position:relative;
vertical-align:top; vertical-align:top;
display:inline-block; display:inline-block;
@ -1035,7 +1035,9 @@ a:hover, a:active {
margin:0.25em 0.125em; margin:0.25em 0.125em;
} }
.media > img { .media > img,
.character > img,
.small_character > img {
width: 100%; width: 100%;
} }
@ -1076,7 +1078,9 @@ a:hover, a:active {
top: 0; top: 0;
} }
.media:hover > .name, .small_character:hover > .name,
.character:hover > .name,
.media:hover > .name,
.media:hover > .media_metadata > div, .media:hover > .media_metadata > div,
.media:hover > .medium_metadata > div, .media:hover > .medium_metadata > div,
.media:hover > .table .row .media:hover > .table .row
@ -1094,7 +1098,11 @@ a:hover, a:active {
display:block; 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 .media > .name a small
{ {
background:none; background:none;
@ -1250,17 +1258,20 @@ a:hover, a:active {
.details { .details {
margin:15px auto 0 auto; margin:15px auto 0 auto;
margin: 1.5rem auto 0 auto; margin: 1.5rem auto 0 auto;
max-width:930px;
max-width:93rem;
padding:10px; padding:10px;
padding:1rem; padding:1rem;
font-size:inherit; font-size:inherit;
} }
.details.fixed {
max-width:930px;
max-width:93rem;
}
.details .cover { .details .cover {
display: block; display: block;
width: 284px; width: 284px;
height: 402px; /* height: 402px; */
} }
.details h2 { .details h2 {
@ -1295,6 +1306,58 @@ a:hover, a:active {
text-align:left; 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 Viewport-based styles
-----------------------------------------------------------------------------*/ -----------------------------------------------------------------------------*/

View File

@ -297,7 +297,7 @@ a:hover, a:active {
Base list styles Base list styles
------------------------------------------------------------------------------*/ ------------------------------------------------------------------------------*/
.media { .media, .character, .small_character {
position:relative; position:relative;
vertical-align:top; vertical-align:top;
display:inline-block; display:inline-block;
@ -307,7 +307,9 @@ a:hover, a:active {
margin: var(--normal-padding); margin: var(--normal-padding);
} }
.media > img { .media > img,
.character > img,
.small_character > img {
width: 100%; width: 100%;
} }
@ -347,7 +349,8 @@ a:hover, a:active {
position:absolute; position:absolute;
top: 0; top: 0;
} }
.small_character:hover > .name,
.character:hover > .name,
.media:hover > .name, .media:hover > .name,
.media:hover > .media_metadata > div, .media:hover > .media_metadata > div,
.media:hover > .medium_metadata > div, .media:hover > .medium_metadata > div,
@ -364,6 +367,10 @@ a:hover, a:active {
display:block; display:block;
} }
.small_character > .name a,
.small_character > .name a small,
.character > .name a,
.character > .name a small,
.media > .name a, .media > .name a,
.media > .name a small .media > .name a small
{ {
@ -510,15 +517,18 @@ a:hover, a:active {
-----------------------------------------------------------------------------*/ -----------------------------------------------------------------------------*/
.details { .details {
margin: 1.5rem auto 0 auto; margin: 1.5rem auto 0 auto;
max-width:93rem;
padding:1rem; padding:1rem;
font-size:inherit; font-size:inherit;
} }
.details.fixed {
max-width:93rem;
}
.details .cover { .details .cover {
display: block; display: block;
width: 284px; width: 284px;
height: 402px; /* height: 402px; */
} }
.details h2 { .details h2 {
@ -549,6 +559,55 @@ a:hover, a:active {
text-align:left; 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 Viewport-based styles
-----------------------------------------------------------------------------*/ -----------------------------------------------------------------------------*/

View File

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

View File

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

View File

@ -40,6 +40,201 @@ class JsonAPI {
*/ */
protected $data = []; 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 public static function inlineRawIncludes(array &$data, string $key): array
{ {
foreach($data['data'] as $i => &$item) foreach($data['data'] as $i => &$item)
@ -118,27 +313,7 @@ class JsonAPI {
*/ */
public static function lightlyOrganizeIncludes(array $includes): array public static function lightlyOrganizeIncludes(array $includes): array
{ {
$organized = []; return static::organizeIncluded($includes);
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;
} }
/** /**

View File

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

View File

@ -19,7 +19,12 @@ namespace Aviat\AnimeClient\API\Kitsu;
use function Amp\{all, wait}; use function Amp\{all, wait};
use Amp\Artax\{Client, Request}; 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\{ use Aviat\AnimeClient\API\Enum\{
AnimeWatchingStatus\Title, AnimeWatchingStatus\Title,
AnimeWatchingStatus\Kitsu as KitsuWatchingStatus, AnimeWatchingStatus\Kitsu as KitsuWatchingStatus,
@ -73,7 +78,6 @@ class Model {
*/ */
protected $mangaListTransformer; protected $mangaListTransformer;
/** /**
* Constructor * Constructor
* *
@ -88,6 +92,34 @@ class Model {
$this->mangaListTransformer = new MangaListTransformer(); $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 * Get the userid for a username from Kitsu
* *
@ -132,7 +164,7 @@ class Model {
$data = $this->getRequest('/characters', [ $data = $this->getRequest('/characters', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'slug' => $slug 'name' => $slug
], ],
// 'include' => 'primaryMedia,castings' // 'include' => 'primaryMedia,castings'
] ]
@ -149,45 +181,86 @@ class Model {
*/ */
public function getUserData(string $username): array public function getUserData(string $username): array
{ {
$userId = $this->getUserIdByUsername($username); // $userId = $this->getUserIdByUsername($username);
$data = $this->getRequest("/users/{$userId}", [ $data = $this->getRequest("/users", [
'query' => [ '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; return $data;
} }
/** /**
* Get the access token from the Kitsu API * Search for an anime or manga
* *
* @param string $username * @param string $type - 'anime' or 'manga'
* @param string $password * @param string $query - name of the item to search for
* @return bool|string * @return array
*/ */
public function authenticate(string $username, string $password) public function search(string $type, string $query): array
{ {
$response = $this->getResponse('POST', K::AUTH_URL, [ $options = [
'headers' => [], 'query' => [
'form_params' => [ 'filter' => [
'grant_type' => 'password', 'text' => $query
'username' => $username, ],
'password' => $password '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 * Get information about a particular anime
* *
@ -220,202 +293,6 @@ class Model {
return $this->animeTransformer->transform($baseData); 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 * 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 * @return array
*/ */
public function getFullOrganizedMangaList(): array public function getFullAnimeList(array $options = [
'include' => 'anime.mappings'
]): array
{ {
$statuses = KitsuReadingStatus::getConstList(); $status = $options['filter']['status'] ?? '';
$output = []; $count = $this->getAnimeListCount($status);
foreach ($statuses as $status) $size = 100;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{ {
$mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status]; $offset = $i * $size;
$output[$mappedStatus] = $this->getMangaList($status); $requester->addRequest($this->getPagedAnimeList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response->getBody());
$output = array_merge_recursive($output, $data);
} }
return $output; 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 * Get the manga list for the configured user
* *
@ -489,7 +543,7 @@ class Model {
'media_type' => 'Manga', 'media_type' => 'Manga',
'status' => $status, 'status' => $status,
], ],
'include' => 'media', 'include' => 'media,media.genres,media.mappings',
'page' => [ 'page' => [
'offset' => $offset, 'offset' => $offset,
'limit' => $limit 'limit' => $limit
@ -503,9 +557,16 @@ class Model {
if ( ! $cacheItem->isHit()) if ( ! $cacheItem->isHit())
{ {
$data = $this->getRequest('library-entries', $options); $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->set($transformed);
$cacheItem->save(); $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 $status - Optional status to filter by
* @param string $query - name of the item to search for * @return int
* @return array
*/ */
public function search(string $type, string $query): array public function getMangaListCount(string $status = '') : int
{ {
$options = [ $options = [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'text' => $query 'user_id' => $this->getUserIdByUsername(),
'media_type' => 'Manga'
], ],
'page' => [ 'page' => [
'offset' => 0, 'limit' => 1
'limit' => 20
], ],
'sort' => '-updated_at'
] ]
]; ];
$raw = $this->getRequest($type, $options); if ( ! empty($status))
foreach ($raw['data'] as &$item)
{ {
$item['attributes']['titles'] = K::filterTitles($item['attributes']); $options['query']['filter']['status'] = $status;
array_shift($item['attributes']['titles']);
} }
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 * Create a list item
* *
@ -665,6 +839,9 @@ class Model {
'filter' => [ 'filter' => [
'slug' => $slug 'slug' => $slug
], ],
'fields' => [
'characters' => 'slug,name,image'
],
'include' => ($type === 'anime') 'include' => ($type === 'anime')
? 'genres,mappings,streamingLinks,animeCharacters.character' ? 'genres,mappings,streamingLinks,animeCharacters.character'
: 'genres,mappings,mangaCharacters.character,castings.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; $untransformed['data']['rating'] = $item['user_rating'] / 2;
} }

View File

@ -35,22 +35,42 @@ class MangaListTransformer extends AbstractTransformer {
*/ */
public function transform($item) 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'])) $rating = (is_numeric($item['attributes']['rating']))
? intval(2 * $item['attributes']['rating']) ? intval(2 * $item['attributes']['rating'])
: '-'; : '-';
$totalChapters = ($manga['attributes']['chapterCount'] > 0) $totalChapters = ($manga['chapterCount'] > 0)
? $manga['attributes']['chapterCount'] ? $manga['chapterCount']
: '-'; : '-';
$totalVolumes = ($manga['attributes']['volumeCount'] > 0) $totalVolumes = ($manga['volumeCount'] > 0)
? $manga['attributes']['volumeCount'] ? $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 = [ $map = [
'id' => $item['id'], 'id' => $item['id'],
'mal_id' => $MALid,
'chapters' => [ 'chapters' => [
'read' => $item['attributes']['progress'], 'read' => $item['attributes']['progress'],
'total' => $totalChapters 'total' => $totalChapters
@ -60,13 +80,13 @@ class MangaListTransformer extends AbstractTransformer {
'total' => $totalVolumes 'total' => $totalVolumes
], ],
'manga' => [ 'manga' => [
'titles' => Kitsu::filterTitles($manga['attributes']), 'titles' => Kitsu::filterTitles($manga),
'alternate_title' => NULL, 'alternate_title' => NULL,
'slug' => $manga['attributes']['slug'], 'slug' => $manga['slug'],
'url' => 'https://kitsu.io/manga/' . $manga['attributes']['slug'], 'url' => 'https://kitsu.io/manga/' . $manga['slug'],
'type' => $manga['attributes']['mangaType'], 'type' => $manga['mangaType'],
'image' => $manga['attributes']['posterImage']['small'], 'image' => $manga['posterImage']['small'],
'genres' => [], //$manga['genres'], 'genres' => $genres,
], ],
'reading_status' => $item['attributes']['status'], 'reading_status' => $item['attributes']['status'],
'notes' => $item['attributes']['notes'], 'notes' => $item['attributes']['notes'],
@ -90,16 +110,21 @@ class MangaListTransformer extends AbstractTransformer {
$map = [ $map = [
'id' => $item['id'], 'id' => $item['id'],
'mal_id' => $item['mal_id'],
'data' => [ 'data' => [
'status' => $item['status'], 'status' => $item['status'],
'progress' => (int)$item['chapters_read'], 'progress' => (int)$item['chapters_read'],
'reconsuming' => $rereading, 'reconsuming' => $rereading,
'reconsumeCount' => (int)$item['reread_count'], 'reconsumeCount' => (int)$item['reread_count'],
'notes' => $item['notes'], 'notes' => $item['notes'],
'rating' => $item['new_rating'] / 2
], ],
]; ];
if (is_numeric($item['new_rating']))
{
$map['data']['rating'] = $item['new_rating'] / 2;
}
return $map; return $map;
} }
} }

View File

@ -30,7 +30,14 @@ class ListItem {
use ContainerAware; use ContainerAware;
use MALTrait; 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']; $id = $data['id'];
$createData = [ $createData = [
@ -42,17 +49,24 @@ class ListItem {
$config = $this->container->get('config'); $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) ->setFormFields($createData)
->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password'])) ->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password']))
->getFullRequest(); ->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'); $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([ ->setFormFields([
'id' => $id 'id' => $id
]) ])
@ -67,7 +81,15 @@ class ListItem {
return []; 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'); $config = $this->container->get('config');
@ -76,7 +98,7 @@ class ListItem {
->addField('id', $id) ->addField('id', $id)
->addField('data', $xml); ->addField('data', $xml);
return $this->requestBuilder->newRequest('POST', "animelist/update/{$id}.xml") return $this->requestBuilder->newRequest('POST', "{$type}list/update/{$id}.xml")
->setFormFields([ ->setFormFields([
'id' => $id, 'id' => $id,
'data' => $xml 'data' => $xml

View File

@ -64,21 +64,6 @@ trait MALTrait {
return $this; 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 * Create a request object
* *

View File

@ -17,10 +17,13 @@
namespace Aviat\AnimeClient\API\MAL; namespace Aviat\AnimeClient\API\MAL;
use Amp\Artax\Request; use Amp\Artax\Request;
use Aviat\AnimeClient\API\MAL\ListItem; use Aviat\AnimeClient\API\MAL\{
use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer; ListItem,
Transformer\AnimeListTransformer,
Transformer\MangaListTransformer
};
use Aviat\AnimeClient\API\XML; use Aviat\AnimeClient\API\XML;
use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus; use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\Ion\Di\ContainerAware; use Aviat\Ion\Di\ContainerAware;
/** /**
@ -48,27 +51,81 @@ class Model {
public function __construct(ListItem $listItem) public function __construct(ListItem $listItem)
{ {
$this->animeListTransformer = new AnimeListTransformer(); $this->animeListTransformer = new AnimeListTransformer();
$this->mangaListTransformer = new MangaListTransformer();
$this->listItem = $listItem; $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
{ {
$createData = [ if ($type === 'anime')
'id' => $data['id'], {
'data' => [ $createData = [
'status' => AnimeWatchingStatus::KITSU_TO_MAL[$data['status']] 'id' => $data['id'],
] 'data' => [
]; 'status' => AnimeWatchingStatus::KITSU_TO_MAL[$data['status']]
]
];
}
elseif ($type === 'manga')
{
$createData = [
'id' => $data['id'],
'data' => [
'status' => MangaReadingStatus::KITSU_TO_MAL[$data['status']]
]
];
}
return $this->listItem->create($createData); return $this->listItem->create($createData, $type);
} }
public function getFullList(): array 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'); $config = $this->container->get('config');
$userName = $config->get(['mal', 'username']); $userName = $config->get(['mal', 'username']);
@ -78,26 +135,11 @@ class Model {
], ],
'query' => [ 'query' => [
'u' => $userName, 'u' => $userName,
'status' => 'all' 'status' => 'all',
'type' => $type
] ]
]); ]);
return $list['myanimelist']['anime']; return $list['myanimelist'][$type];
}
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);
} }
} }

View File

@ -24,26 +24,14 @@ use Aviat\Ion\Transformer\AbstractTransformer;
*/ */
class AnimeListTransformer extends AbstractTransformer { class AnimeListTransformer extends AbstractTransformer {
/** /**
* Transform MAL episode data to Kitsu episode data * Identity transformation
* *
* @param array $item * @param array $item
* @return array * @return array
*/ */
public function transform($item) public function transform($item)
{ {
$rewatching = (array_key_exists('rewatching', $item) && $item['rewatching']); return $item;
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']
]
];
} }
/** /**

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 * and url route segments
*/ */
class MangaReadingStatus extends Enum { class MangaReadingStatus extends Enum {
const MAL_TO_KITSU = [ const KITSU_TO_MAL = [
Kitsu::READING => MAL::READING, Kitsu::READING => MAL::READING,
Kitsu::PLAN_TO_READ => MAL::PLAN_TO_READ, Kitsu::PLAN_TO_READ => MAL::PLAN_TO_READ,
Kitsu::COMPLETED => MAL::COMPLETED, Kitsu::COMPLETED => MAL::COMPLETED,
@ -37,12 +37,17 @@ class MangaReadingStatus extends Enum {
Kitsu::DROPPED => MAL::DROPPED 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::READING => Kitsu::READING,
MAL::PLAN_TO_READ => Kitsu::PLAN_TO_READ,
MAL::COMPLETED => Kitsu::COMPLETED, MAL::COMPLETED => Kitsu::COMPLETED,
MAL::ON_HOLD => Kitsu::ON_HOLD, 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 = [ const KITSU_TO_TITLE = [
@ -50,7 +55,7 @@ class MangaReadingStatus extends Enum {
Kitsu::PLAN_TO_READ => Title::PLAN_TO_READ, Kitsu::PLAN_TO_READ => Title::PLAN_TO_READ,
Kitsu::COMPLETED => Title::COMPLETED, Kitsu::COMPLETED => Title::COMPLETED,
Kitsu::ON_HOLD => Title::ON_HOLD, Kitsu::ON_HOLD => Title::ON_HOLD,
Kitsu::DROPPED => Title::DROPPED Kitsu::DROPPED => Title::DROPPED,
]; ];
const ROUTE_TO_KITSU = [ const ROUTE_TO_KITSU = [
@ -58,7 +63,7 @@ class MangaReadingStatus extends Enum {
Route::READING => Kitsu::READING, Route::READING => Kitsu::READING,
Route::COMPLETED => Kitsu::COMPLETED, Route::COMPLETED => Kitsu::COMPLETED,
Route::DROPPED => Kitsu::DROPPED, Route::DROPPED => Kitsu::DROPPED,
Route::ON_HOLD => Kitsu::ON_HOLD Route::ON_HOLD => Kitsu::ON_HOLD,
]; ];
const ROUTE_TO_TITLE = [ const ROUTE_TO_TITLE = [
@ -67,7 +72,7 @@ class MangaReadingStatus extends Enum {
Route::READING => Title::READING, Route::READING => Title::READING,
Route::COMPLETED => Title::COMPLETED, Route::COMPLETED => Title::COMPLETED,
Route::DROPPED => Title::DROPPED, Route::DROPPED => Title::DROPPED,
Route::ON_HOLD => Title::ON_HOLD Route::ON_HOLD => Title::ON_HOLD,
]; ];
const TITLE_TO_KITSU = [ const TITLE_TO_KITSU = [
@ -75,6 +80,6 @@ class MangaReadingStatus extends Enum {
Title::READING => Kitsu::READING, Title::READING => Kitsu::READING,
Title::COMPLETED => Kitsu::COMPLETED, Title::COMPLETED => Kitsu::COMPLETED,
Title::DROPPED => Kitsu::DROPPED, 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__)); define('SRC_DIR', realpath(__DIR__));
const SESSION_SEGMENT = 'Aviat\AnimeClient\Auth'; const SESSION_SEGMENT = 'Aviat\AnimeClient\Auth';
const DEFAULT_CONTROLLER = 'Aviat\AnimeClient\Controller\Index';
const DEFAULT_CONTROLLER_NAMESPACE = 'Aviat\AnimeClient\Controller'; 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 DEFAULT_CONTROLLER_METHOD = 'index';
const NOT_FOUND_METHOD = 'notFound'; const NOT_FOUND_METHOD = 'notFound';
const ERROR_MESSAGE_METHOD = 'errorPage'; const ERROR_MESSAGE_METHOD = 'errorPage';
const SRC_DIR = SRC_DIR; 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 * Load configuration options from .toml files
* *

View File

@ -19,8 +19,16 @@ namespace Aviat\AnimeClient\Command;
use function Amp\{all, wait}; use function Amp\{all, wait};
use Amp\Artax\Client; use Amp\Artax\Client;
use Aviat\AnimeClient\API\{JsonAPI, Mapping\AnimeWatchingStatus}; use Aviat\AnimeClient\API\{
use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer as ALT; JsonAPI,
ParallelAPIRequest,
Mapping\AnimeWatchingStatus,
Mapping\MangaReadingStatus
};
use Aviat\AnimeClient\API\MAL\Transformer\{
AnimeListTransformer as ALT,
MangaListTransformer as MLT
};
use Aviat\Ion\Json; use Aviat\Ion\Json;
/** /**
@ -55,63 +63,71 @@ class SyncKitsuWithMal extends BaseCommand {
$this->kitsuModel = $this->container->get('kitsu-model'); $this->kitsuModel = $this->container->get('kitsu-model');
$this->malModel = $this->container->get('mal-model'); $this->malModel = $this->container->get('mal-model');
$malCount = count($this->getMALAnimeList()); $this->syncAnime();
$kitsuCount = $this->getKitsuAnimeListPageCount(); $this->syncManga();
}
$this->echoBox("Number of MAL list items: {$malCount}"); public function syncAnime()
$this->echoBox("Number of Kitsu list items: {$kitsuCount}"); {
$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(); $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'])) if ( ! empty($data['addToMAL']))
{ {
$this->echoBox("Adding missing list items to MAL"); $this->echoBox("Adding missing anime list items to MAL");
$this->createMALAnimeListItems($data['addToMAL']); $this->createMALListItems($data['addToMAL'], 'anime');
} }
}
public function getKitsuAnimeList() $this->echoBox('Number of anime items that need to be added to Kitsu: ' . count($data['addToKitsu']));
{
$count = $this->getKitsuAnimeListPageCount();
$size = 100;
$pages = ceil($count / $size);
$requests = []; if ( ! empty($data['addToKitsu']))
// Set up requests
for ($i = 0; $i < $pages; $i++)
{ {
$offset = $i * $size; $this->echoBox("Adding missing anime list items to Kitsu");
$requests[] = $this->kitsuModel->getPagedAnimeList($size, $offset); $this->createKitusListItems($data['addToKitsu'], 'anime');
} }
$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;
} }
public function getMALAnimeList() public function syncManga()
{ {
return $this->malModel->getFullList(); $malCount = count($this->malModel->getMangaList());
$kitsuCount = $this->kitsuModel->getMangaListCount();
$this->echoBox("Number of MAL manga list items: {$malCount}");
$this->echoBox("Number of Kitsu manga list items: {$kitsuCount}");
$data = $this->diffMangaLists();
$this->echoBox("Number of manga items that need to be added to MAL: " . count($data['addToMAL']));
if ( ! empty($data['addToMAL']))
{
$this->echoBox("Adding missing manga list items to MAL");
$this->createMALListItems($data['addToMAL'], 'manga');
}
$this->echoBox('Number of manga items that need to be added to Kitsu: ' . count($data['addToKitsu']));
if ( ! empty($data['addToKitsu']))
{
$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 = []; $output = [];
foreach($includes as $id => $mapping) foreach($includes as $id => $mapping)
{ {
if ($mapping['externalSite'] === 'myanimelist/anime') if ($mapping['externalSite'] === "myanimelist/{$type}")
{ {
$output[$id] = $mapping; $output[$id] = $mapping;
} }
@ -122,7 +138,7 @@ class SyncKitsuWithMal extends BaseCommand {
public function formatMALAnimeList() public function formatMALAnimeList()
{ {
$orig = $this->getMALAnimeList(); $orig = $this->malModel->getAnimeList();
$output = []; $output = [];
foreach($orig as $item) foreach($orig as $item)
@ -137,7 +153,37 @@ class SyncKitsuWithMal extends BaseCommand {
? $item['times_rewatched'] ? $item['times_rewatched']
: 0, : 0,
// 'notes' => , // '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()) 'updatedAt' => (new \DateTime())
->setTimestamp((int)$item['my_last_updated']) ->setTimestamp((int)$item['my_last_updated'])
->format(\DateTime::W3C), ->format(\DateTime::W3C),
@ -186,9 +232,84 @@ class SyncKitsuWithMal extends BaseCommand {
return $output; 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() public function diffAnimeLists()
@ -201,10 +322,26 @@ class SyncKitsuWithMal extends BaseCommand {
$malList = $this->formatMALAnimeList(); $malList = $this->formatMALAnimeList();
$itemsToAddToMAL = []; $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) 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 // Eventually, compare the list entries, and determine which
// needs to be updated // needs to be updated
@ -230,34 +367,60 @@ class SyncKitsuWithMal extends BaseCommand {
return [ return [
'addToMAL' => $itemsToAddToMAL, '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(); $transformer = new ALT();
$requests = []; $requester = new ParallelAPIRequest();
foreach($itemsToAdd as $item) foreach($itemsToAdd as $item)
{ {
$data = $transformer->untransform($item); $data = $transformer->untransform($item);
$requests[] = $this->malModel->createFullListItem($data); $requester->addRequest($this->malModel->createFullListItem($data, $type));
} }
$promiseArray = (new Client())->requestMulti($requests); $responses = $requester->makeRequests();
$responses = wait(all($promiseArray));
foreach($responses as $key => $response) foreach($responses as $key => $response)
{ {
$id = $itemsToAdd[$key]['mal_id']; $id = $itemsToAdd[$key]['mal_id'];
if ($response->getBody() === 'Created') 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 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 const Aviat\AnimeClient\SESSION_SEGMENT;
use function Aviat\AnimeClient\_dir; use function Aviat\Ion\_dir;
use Aviat\AnimeClient\API\JsonAPI; use Aviat\AnimeClient\API\JsonAPI;
use Aviat\Ion\Di\{ContainerAware, ContainerInterface}; use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
@ -31,7 +31,66 @@ use InvalidArgumentException;
* @property Response object $response * @property Response object $response
*/ */
class Controller { 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 * Constructor
@ -55,6 +114,7 @@ class Controller {
'config' => $this->config 'config' => $this->config
]); ]);
$this->url = $auraUrlGenerator;
$this->urlGenerator = $urlGenerator; $this->urlGenerator = $urlGenerator;
$session = $container->get('session'); $session = $container->get('session');
@ -72,81 +132,267 @@ class Controller {
} }
/** /**
* Show the user profile page * Redirect to the previous page
* *
* @return void * @return void
*/ */
public function me() public function redirectToPrevious()
{ {
$username = $this->config->get(['kitsu_username']); $previous = $this->session->getFlash('previous');
$model = $this->container->get('kitsu-model'); $this->redirect($previous, 303);
$data = $model->getUserData($username); }
$included = JsonAPI::lightlyOrganizeIncludes($data['included']);
$relationships = JsonAPI::fillRelationshipsFromIncludes($data['data']['relationships'], $included); /**
$this->outputHTML('me', [ * Set the current url in the session as the target of a future redirect
'title' => 'About' . $this->config->get('whose_list'), *
'attributes' => $data['data']['attributes'], * @param string|null $url
'relationships' => $relationships, * @return void
'included' => $included */
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 * @return void
*/ */
public function login(string $status = '') protected function outputHTML(string $template, array $data = [], $view = NULL, int $code = 200)
{ {
$message = ''; if (is_null($view))
$view = new HtmlView($this->container);
if ($status !== '')
{ {
$message = $this->showMessage($view, 'error', $status); $view = new HtmlView($this->container);
} }
// Set the redirect url $view->setStatusCode($code);
$this->setSessionRedirect(); $this->renderFullPage($view, $template, $data);
$this->outputHTML('login', [
'title' => 'Api login',
'message' => $message
], $view);
} }
/** /**
* Attempt login authentication * Output a JSON Response
* *
* @param mixed $data
* @param int $code - the http status code
* @return void * @return void
*/ */
public function loginAction() protected function outputJSON($data = 'Empty response', int $code = 200)
{ {
$auth = $this->container->get('auth'); (new JsonView($this->container))
$post = $this->request->getParsedBody(); ->setStatusCode($code)
if ($auth->authenticate($post['password'])) ->setOutput($data)
{ ->send();
$this->sessionRedirect();
return;
}
$this->setFlashMessage('Invalid username or password.');
$this->redirect($this->urlGenerator->url('login'), 303);
} }
/** /**
* Deauthorize the current user * Redirect to the selected page
* *
* @param string $url
* @param int $code
* @return void * @return void
*/ */
public function logout() protected function redirect(string $url, int $code)
{ {
$auth = $this->container->get('auth'); $http = new HttpView($this->container);
$auth->logout(); $http->redirect($url, $code);
$this->redirectToDefaultRoute();
} }
} }
// End of BaseController.php // End of BaseController.php

View File

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

View File

@ -41,18 +41,6 @@ class Collection extends BaseController {
*/ */
private $animeModel; 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 * Constructor
* *
@ -62,7 +50,6 @@ class Collection extends BaseController {
{ {
parent::__construct($container); parent::__construct($container);
$this->urlGenerator = $container->get('url-generator');
$this->animeModel = $container->get('anime-model'); $this->animeModel = $container->get('anime-model');
$this->animeCollectionModel = $container->get('anime-collection-model'); $this->animeCollectionModel = $container->get('anime-collection-model');
$this->baseData = array_merge($this->baseData, [ $this->baseData = array_merge($this->baseData, [
@ -118,10 +105,11 @@ class Collection extends BaseController {
$this->setSessionRedirect(); $this->setSessionRedirect();
$action = (is_null($id)) ? "Add" : "Edit"; $action = (is_null($id)) ? "Add" : "Edit";
$urlAction = strtolower($action);
$this->outputHTML('collection/' . strtolower($action), [ $this->outputHTML('collection/' . $urlAction, [
'action' => $action, 'action' => $action,
'action_url' => $this->urlGenerator->fullUrl('collection/' . strtolower($action)), 'action_url' => $this->url->generate("collection.{$urlAction}.post"),
'title' => $this->formatTitle( 'title' => $this->formatTitle(
$this->config->get('whose_list') . "'s Anime Collection", $this->config->get('whose_list') . "'s Anime Collection",
$action $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() public function addForm()
{ {
$raw_status_list = MangaReadingStatus::getConstList(); $statuses = MangaReadingStatus::KITSU_TO_TITLE;
$statuses = [];
foreach ($raw_status_list as $status_item)
{
$statuses[$status_item] = (string)$this->string($status_item)
->underscored()
->humanize()
->titleize();
}
$this->setSessionRedirect(); $this->setSessionRedirect();
$this->outputHTML('manga/add', [ $this->outputHTML('manga/add', [
@ -116,7 +106,7 @@ class Manga extends Controller {
$this->config->get('whose_list') . "'s Manga List", $this->config->get('whose_list') . "'s Manga List",
'Add' 'Add'
), ),
'action_url' => $this->urlGenerator->url('manga/add'), 'action_url' => $this->url->generate('manga.add.post'),
'status_list' => $statuses 'status_list' => $statuses
]); ]);
} }
@ -169,8 +159,9 @@ class Manga extends Controller {
'title' => $title, 'title' => $title,
'status_list' => MangaReadingStatus::KITSU_TO_TITLE, 'status_list' => MangaReadingStatus::KITSU_TO_TITLE,
'item' => $item, 'item' => $item,
'action' => $this->container->get('url-generator') 'action' => $this->url->generate('update.post', [
->url('/manga/update_form'), 'controller' => 'manga'
]),
]); ]);
} }
@ -221,7 +212,7 @@ class Manga extends Controller {
*/ */
public function update() 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()); $data = Json::decode((string)$this->request->getBody());
} }
@ -245,7 +236,8 @@ class Manga extends Controller {
{ {
$body = $this->request->getParsedBody(); $body = $this->request->getParsedBody();
$id = $body['id']; $id = $body['id'];
$response = $this->model->deleteLibraryItem($id); $malId = $body['mal_id'];
$response = $this->model->deleteLibraryItem($id, $malId);
if ($response) 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 SRC_DIR
}; };
use function Aviat\AnimeClient\_dir; use function Aviat\Ion\_dir;
use Aviat\Ion\Di\ContainerInterface; use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Friend; use Aviat\Ion\Friend;

View File

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

View File

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

View File

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

View File

@ -16,9 +16,13 @@
namespace Aviat\AnimeClient\Model; namespace Aviat\AnimeClient\Model;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Title; use Aviat\AnimeClient\API\{
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus; Enum\MangaReadingStatus\Title,
Mapping\MangaReadingStatus,
ParallelAPIRequest
};
use Aviat\Ion\Di\ContainerInterface; use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Json;
/** /**
* Model for handling requests dealing with the manga list * Model for handling requests dealing with the manga list
@ -46,6 +50,9 @@ class Manga extends API
{ {
$this->kitsuModel = $container->get('kitsu-model'); $this->kitsuModel = $container->get('kitsu-model');
$this->malModel = $container->get('mal-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); 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 * Get information about a specific list item
* for editing/updating that item * for editing/updating that item
@ -100,6 +96,35 @@ class Manga extends API
return $this->kitsuModel->getListItem($itemId); 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 * Update a list entry
* *
@ -108,18 +133,44 @@ class Manga extends API
*/ */
public function updateLibraryItem(array $data): array 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 * @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->container = $container;
$this->config = $container->get('config'); $this->config = $container->get('config');
$baseRoutes = $this->config->get('routes'); $this->routes = $this->config->get('routes');
$this->routes = $baseRoutes['routes']; $this->routeConfig = $this->config->get('route_config');
$this->routeConfig = $baseRoutes['route_config'];
} }
/** /**
@ -72,11 +71,9 @@ class RoutingBase {
*/ */
public function __get($key) public function __get($key)
{ {
$routingConfig =& $this->routeConfig; if (array_key_exists($key, $this->routeConfig))
if (array_key_exists($key, $routingConfig))
{ {
return $routingConfig[$key]; return $this->routeConfig[$key];
} }
} }

View File

@ -108,32 +108,5 @@ class UrlGenerator extends RoutingBase {
throw new InvalidArgumentException("Invalid default type: '{$type}'"); 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 // End of UrlGenerator.php

View File

@ -32,10 +32,21 @@ class MangaListTransformerTest extends AnimeClientTestCase {
public function setUp() public function setUp()
{ {
parent::setUp(); parent::setUp();
$kitsuModel = $this->container->get('kitsu-model');
$this->dir = AnimeClientTestCase::TEST_DATA_DIR . '/Kitsu'; $this->dir = AnimeClientTestCase::TEST_DATA_DIR . '/Kitsu';
// Prep for transform
$rawBefore = Json::decodeFile("{$this->dir}/mangaListBeforeTransform.json"); $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->afterTransform = Json::decodeFile("{$this->dir}/mangaListAfterTransform.json");
$this->transformer = new MangaListTransformer(); $this->transformer = new MangaListTransformer();
@ -54,7 +65,8 @@ class MangaListTransformerTest extends AnimeClientTestCase {
public function testUntransform() public function testUntransform()
{ {
$input = [ $input = [
'id' => "15084773", 'id' => '15084773',
'mal_id' => '26769',
'chapters_read' => 67, 'chapters_read' => 67,
'manga' => [ 'manga' => [
'titles' => ["Bokura wa Minna Kawaisou"], 'titles' => ["Bokura wa Minna Kawaisou"],
@ -75,6 +87,7 @@ class MangaListTransformerTest extends AnimeClientTestCase {
$actual = $this->transformer->untransform($input); $actual = $this->transformer->untransform($input);
$expected = [ $expected = [
'id' => '15084773', 'id' => '15084773',
'mal_id' => '26769',
'data' => [ 'data' => [
'status' => 'current', 'status' => 'current',
'progress' => 67, '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 * @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() public function setUp()
{ {
parent::setUp(); parent::setUp();
$this->model = $this->container->get('mal-model');
$this->controller = new class {
use ControllerTrait;
};
} }
public function testFormatTitle() public function testGetListItem()
{ {
$this->assertEquals( $this->assertEquals([], $this->model->getListItem('foo'));
$this->controller->formatTitle('foo', 'bar', 'baz'),
'foo &middot; bar &middot; baz'
);
} }
} }

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 const Aviat\AnimeClient\SRC_DIR;
use function Aviat\AnimeClient\_dir; use function Aviat\Ion\_dir;
use Aura\Web\WebFactory; use Aura\Web\WebFactory;
use Aviat\Ion\Json; use Aviat\Ion\Json;
@ -95,13 +95,15 @@ class AnimeClientTestCase extends TestCase {
'file' => ':memory:', 'file' => ':memory:',
] ]
], ],
'route_config' => [
'asset_path' => '/assets'
],
'routes' => [ '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)); $this->assertTrue(is_object($this->BaseController));
} }
public function dataGet() public function testFormatTitle()
{ {
return [ $this->assertEquals(
'response' => [ $this->BaseController->formatTitle('foo', 'bar', 'baz'),
'key' => 'response', 'foo &middot; bar &middot; baz'
], );
'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);
} }
} }

View File

@ -72,39 +72,37 @@ class DispatcherTest extends AnimeClientTestCase {
{ {
$defaultConfig = [ $defaultConfig = [
'routes' => [ 'routes' => [
'routes' => [ 'login_form' => [
'login_form' => [ 'path' => '/login',
'path' => '/login', 'action' => 'login',
'action' => 'login', 'verb' => 'get'
'verb' => 'get' ],
], 'watching' => [
'watching' => [ 'path' => '/anime/watching{/view}',
'path' => '/anime/watching{/view}', 'action' => 'anime_list',
'action' => 'anime_list', 'params' => [
'params' => [ 'type' => 'currently-watching',
'type' => 'currently-watching', ],
], 'tokens' => [
'tokens' => [ 'view' => '[a-z_]+'
'view' => '[a-z_]+' ]
] ],
], 'plan_to_read' => [
'plan_to_read' => [ 'path' => '/manga/plan_to_read{/view}',
'path' => '/manga/plan_to_read{/view}', 'action' => 'manga_list',
'action' => 'manga_list', 'params' => [
'params' => [ 'type' => 'Plan to Read',
'type' => 'Plan to Read', ],
], 'tokens' => [
'tokens' => [ 'view' => '[a-z_]+'
'view' => '[a-z_]+' ]
]
],
], ],
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
'default_list' => 'anime'
]
], ],
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
'default_list' => 'anime'
]
]; ];
$data = [ $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_anime']['config']['route_config']['default_list'] = 'manga';
$data['manga_default_routing_manga']['config']['routes']['route_config']['default_list'] = 'manga'; $data['manga_default_routing_manga']['config']['route_config']['default_list'] = 'manga';
return $data; return $data;
} }
@ -169,36 +167,34 @@ class DispatcherTest extends AnimeClientTestCase {
public function testDefaultRoute() public function testDefaultRoute()
{ {
$config = [ $config = [
'route_config' => [
'anime_path' => 'anime',
'manga_path' => 'manga',
'default_anime_list_path' => "watching",
'default_manga_list_path' => 'all',
'default_list' => 'manga'
],
'routes' => [ 'routes' => [
'route_config' => [ 'login_form' => [
'anime_path' => 'anime', 'path' => '/login',
'manga_path' => 'manga', 'action' => ['login'],
'default_anime_list_path' => "watching", 'verb' => 'get'
'default_manga_list_path' => 'all',
'default_list' => 'manga'
], ],
'routes' => [ 'index' => [
'login_form' => [ 'path' => '/',
'path' => '/login', 'action' => ['redirect'],
'action' => ['login'], 'params' => [
'verb' => 'get' 'url' => '', // Determined by config
], 'code' => '301'
'index' => [ ]
'path' => '/', ],
'action' => ['redirect'], 'index' => [
'params' => [ 'path' => '/',
'url' => '', // Determined by config 'action' => ['redirect'],
'code' => '301' 'params' => [
] 'url' => '', // Determined by config
], 'code' => '301',
'index' => [ 'type' => 'manga'
'path' => '/',
'action' => ['redirect'],
'params' => [
'url' => '', // Determined by config
'code' => '301',
'type' => 'manga'
]
] ]
] ]
] ]
@ -218,45 +214,43 @@ class DispatcherTest extends AnimeClientTestCase {
'controller_list_sanity_check' => [ 'controller_list_sanity_check' => [
'config' => [ 'config' => [
'routes' => [ 'routes' => [
'routes' => [
], ],
'route_config' => [ 'route_config' => [
'anime_path' => 'anime', 'anime_path' => 'anime',
'manga_path' => 'manga', 'manga_path' => 'manga',
'default_anime_list_path' => "watching", 'default_anime_list_path' => "watching",
'default_manga_list_path' => 'all', 'default_manga_list_path' => 'all',
'default_list' => 'manga' 'default_list' => 'manga'
], ],
]
], ],
'expected' => [ 'expected' => [
'anime' => 'Aviat\AnimeClient\Controller\Anime', 'anime' => 'Aviat\AnimeClient\Controller\Anime',
'manga' => 'Aviat\AnimeClient\Controller\Manga', 'manga' => 'Aviat\AnimeClient\Controller\Manga',
'collection' => 'Aviat\AnimeClient\Controller\Collection', 'collection' => 'Aviat\AnimeClient\Controller\Collection',
'character' => 'Aviat\AnimeClient\Controller\Character', 'character' => 'Aviat\AnimeClient\Controller\Character',
'index' => 'Aviat\AnimeClient\Controller\Index',
] ]
], ],
'empty_controller_list' => [ 'empty_controller_list' => [
'config' => [ 'config' => [
'routes' => [ 'routes' => [
'routes' => [
], ],
'route_config' => [ 'route_config' => [
'anime_path' => 'anime', 'anime_path' => 'anime',
'manga_path' => 'manga', 'manga_path' => 'manga',
'default_anime_path' => "/anime/watching", 'default_anime_path' => "/anime/watching",
'default_manga_path' => '/manga/all', 'default_manga_path' => '/manga/all',
'default_list' => 'manga' 'default_list' => 'manga'
], ],
]
], ],
'expected' => [ 'expected' => [
'anime' => 'Aviat\AnimeClient\Controller\Anime', 'anime' => 'Aviat\AnimeClient\Controller\Anime',
'manga' => 'Aviat\AnimeClient\Controller\Manga', 'manga' => 'Aviat\AnimeClient\Controller\Manga',
'collection' => 'Aviat\AnimeClient\Controller\Collection', 'collection' => 'Aviat\AnimeClient\Controller\Collection',
'character' => 'Aviat\AnimeClient\Controller\Character', 'character' => 'Aviat\AnimeClient\Controller\Character',
'index' => 'Aviat\AnimeClient\Controller\Index',
] ]
] ]
]; ];

View File

@ -49,60 +49,4 @@ class UrlGeneratorTest extends AnimeClientTestCase {
$result = $urlGenerator->assetUrl(...$args); $result = $urlGenerator->assetUrl(...$args);
$this->assertEquals($expected, $result); $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", "id": "15084773",
"mal_id": "26769",
"chapters": { "chapters": {
"read": 67, "read": 67,
"total": "-" "total": "-"
@ -15,7 +16,7 @@
"url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou", "url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou",
"type": "manga", "type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999",
"genres": [] "genres": ["Comedy", "Romance", "School", "Slice of Life", "Thriller"]
}, },
"reading_status": "current", "reading_status": "current",
"notes": "", "notes": "",
@ -24,6 +25,7 @@
"user_rating": 9 "user_rating": 9
}, { }, {
"id": "15085607", "id": "15085607",
"mal_id": "16",
"chapters": { "chapters": {
"read": 17, "read": 17,
"total": 120 "total": 120
@ -39,7 +41,7 @@
"url": "https:\/\/kitsu.io\/manga\/love-hina", "url": "https:\/\/kitsu.io\/manga\/love-hina",
"type": "manga", "type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/47\/small.jpg?1434249493", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/47\/small.jpg?1434249493",
"genres": [] "genres": ["Comedy", "Ecchi", "Harem", "Romance", "Sports"]
}, },
"reading_status": "current", "reading_status": "current",
"notes": "", "notes": "",
@ -48,6 +50,7 @@
"user_rating": 7 "user_rating": 7
}, { }, {
"id": "15084529", "id": "15084529",
"mal_id": "35003",
"chapters": { "chapters": {
"read": 16, "read": 16,
"total": "-" "total": "-"
@ -63,7 +66,7 @@
"url": "https:\/\/kitsu.io\/manga\/yamada-kun-to-7-nin-no-majo", "url": "https:\/\/kitsu.io\/manga\/yamada-kun-to-7-nin-no-majo",
"type": "manga", "type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/11777\/small.jpg?1438784325", "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", "reading_status": "current",
"notes": "", "notes": "",
@ -72,6 +75,7 @@
"user_rating": 9 "user_rating": 9
}, { }, {
"id": "15312827", "id": "15312827",
"mal_id": "78523",
"chapters": { "chapters": {
"read": 68, "read": 68,
"total": "-" "total": "-"
@ -87,7 +91,7 @@
"url": "https:\/\/kitsu.io\/manga\/relife", "url": "https:\/\/kitsu.io\/manga\/relife",
"type": "manga", "type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/27175\/small.jpg?1464379411", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/27175\/small.jpg?1464379411",
"genres": [] "genres": ["Romance", "School", "Slice of Life"]
}, },
"reading_status": "current", "reading_status": "current",
"notes": "", "notes": "",
@ -95,33 +99,10 @@
"reread": 0, "reread": 0,
"user_rating": "-" "user_rating": "-"
}, { }, {
"id": "15084772", "id": "15084769",
"mal_id": "60815",
"chapters": { "chapters": {
"read": 28, "read": 43,
"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,
"total": "-" "total": "-"
}, },
"volumes": { "volumes": {
@ -129,111 +110,15 @@
"total": "-" "total": "-"
}, },
"manga": { "manga": {
"titles": ["Shishunki Bitter Change"], "titles": ["Joshikausei"],
"alternate_title": null, "alternate_title": null,
"slug": "shishunki-bitter-change", "slug": "joshikausei",
"url": "https:\/\/kitsu.io\/manga\/shishunki-bitter-change", "url": "https:\/\/kitsu.io\/manga\/joshikausei",
"type": "manga", "type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25512\/small.jpg?1434305092", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25491\/small.jpg?1434305043",
"genres": [] "genres": ["Comedy", "School", "Slice of Life"]
}, },
"reading_status": "planned", "reading_status": "current",
"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",
"notes": "", "notes": "",
"rereading": false, "rereading": false,
"reread": 0, "reread": 0,

File diff suppressed because it is too large Load Diff