Decouple and generalise

This commit is contained in:
Timothy Warren 2015-09-17 23:11:18 -04:00
parent c788cf5d87
commit 602759b471
50 changed files with 2876 additions and 395 deletions

View File

@ -9,64 +9,74 @@ use \Whoops\Handler\PrettyPageHandler;
use \Whoops\Handler\JsonResponseHandler;
use \Aura\Web\WebFactory;
use \Aura\Router\RouterFactory;
use \Aura\Di\Container as DiContainer;
use \Aura\Di\Factory as DiFactory;
use \Aura\Session\SessionFactory;
use Aviat\Ion\Di\Container;
require _dir(SRC_DIR, '/functions.php');
// -----------------------------------------------------------------------------
// Setup DI container
// -----------------------------------------------------------------------------
$container = new Container();
$di = function() {
$container = new Container();
// -----------------------------------------------------------------------------
// Setup error handling
// -----------------------------------------------------------------------------
$whoops = new \Whoops\Run();
// -------------------------------------------------------------------------
// Setup error handling
// -------------------------------------------------------------------------
$whoops = new \Whoops\Run();
// Set up default handler for general errors
$defaultHandler = new PrettyPageHandler();
$whoops->pushHandler($defaultHandler);
// Set up default handler for general errors
$defaultHandler = new PrettyPageHandler();
$whoops->pushHandler($defaultHandler);
// Set up json handler for ajax errors
$jsonHandler = new JsonResponseHandler();
$jsonHandler->onlyForAjaxRequests(true);
$whoops->pushHandler($jsonHandler);
// Set up json handler for ajax errors
$jsonHandler = new JsonResponseHandler();
$jsonHandler->onlyForAjaxRequests(true);
$whoops->pushHandler($jsonHandler);
$whoops->register();
//$whoops->register();
$container->set('error-handler', $defaultHandler);
$container->set('error-handler', $defaultHandler);
// -----------------------------------------------------------------------------
// Injected Objects
// -----------------------------------------------------------------------------
// -------------------------------------------------------------------------
// Injected Objects
// -------------------------------------------------------------------------
// Create Config Object
$config = new Config();
$container->set('config', $config);
// Create Config Object
$config = new Config();
$container->set('config', $config);
// Create Aura Router Object
$router_factory = new RouterFactory();
$aura_router = $router_factory->newInstance();
$container->set('aura-router', $aura_router);
// Create Aura Router Object
$aura_router = (new RouterFactory())->newInstance();
$container->set('aura-router', $aura_router);
// Create Request/Response Objects
$web_factory = new WebFactory([
'_GET' => $_GET,
'_POST' => $_POST,
'_COOKIE' => $_COOKIE,
'_SERVER' => $_SERVER,
'_FILES' => $_FILES
]);
$container->set('request', $web_factory->newRequest());
$container->set('response', $web_factory->newResponse());
// Create Request/Response Objects
$web_factory = new WebFactory([
'_GET' => $_GET,
'_POST' => $_POST,
'_COOKIE' => $_COOKIE,
'_SERVER' => $_SERVER,
'_FILES' => $_FILES
]);
$container->set('request', $web_factory->newRequest());
$container->set('response', $web_factory->newResponse());
// -----------------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------------
$container->set('url-generator', new UrlGenerator($container));
// Create session Object
$session = (new SessionFactory())->newInstance($_COOKIE);
$container->set('session', $session);
$router = new Router($container);
$router->dispatch();
$container->set('url-generator', new UrlGenerator($container));
// -------------------------------------------------------------------------
// Router
// -------------------------------------------------------------------------
$router = new Router($container);
$container->set('router', $router);
return $container;
};
$di()->get('router')->dispatch();
// End of bootstrap.php

View File

@ -5,6 +5,9 @@
// You shouldn't generally need to change anything below this line
// ----------------------------------------------------------------------------
$base_config = [
// Template file path
'view_path' => _dir(APP_DIR, 'views'),
// Cache paths
'data_cache_path' => _dir(APP_DIR, 'cache'),
'img_cache_path' => _dir(ROOT_DIR, 'public/images'),

View File

@ -5,9 +5,6 @@ return [
'default_namespace' => '\\Aviat\\AnimeClient\\Controller',
'default_controller' => '\\Aviat\\AnimeClient\\Controller\\Anime',
'default_method' => 'index'
],
'configuration' => [
],
// Routes on all controllers
'common' => [
@ -71,70 +68,19 @@ return [
'action' => ['redirect'],
'params' => [
'url' => '', // Determined by config
'code' => '301'
'code' => '301',
'type' => 'anime'
]
],
'search' => [
'path' => '/anime/search',
'action' => ['search'],
],
'all' => [
'path' => '/anime/all{/view}',
'anime_list' => [
'path' => '/anime/{type}{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'all',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'watching' => [
'path' => '/anime/watching{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'watching',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'plan_to_watch' => [
'path' => '/anime/plan_to_watch{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'plan_to_watch',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'on_hold' => [
'path' => '/anime/on_hold{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'on_hold',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'dropped' => [
'path' => '/anime/dropped{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'dropped',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'completed' => [
'path' => '/anime/completed{/view}',
'action' => ['anime_list'],
'params' => [
'type' => 'completed',
],
'tokens' => [
'type' => '[a-z_]+',
'view' => '[a-z_]+'
]
],
@ -149,63 +95,11 @@ return [
'type' => 'manga'
]
],
'all' => [
'path' => '/manga/all{/view}',
'manga_list' => [
'path' => '/manga/{type}{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'all',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'reading' => [
'path' => '/manga/reading{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'reading',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'plan_to_read' => [
'path' => '/manga/plan_to_read{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'plan_to_read',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'on_hold' => [
'path' => '/manga/on_hold{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'on_hold',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'dropped' => [
'path' => '/manga/dropped{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'dropped',
],
'tokens' => [
'view' => '[a-z_]+'
]
],
'completed' => [
'path' => '/manga/completed{/view}',
'action' => ['manga_list'],
'params' => [
'type' => 'completed',
],
'tokens' => [
'type' => '[a-z_]+',
'view' => '[a-z_]+'
]
]

View File

@ -4,17 +4,17 @@
<?php else: ?>
<?php foreach ($sections as $name => $items): ?>
<section class="status">
<h2><?= $name ?></h2>
<h2><?= $escape->html($name) ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<article class="media" id="a-<?= $item['anime']['id'] ?>">
<?php if (is_logged_in()): ?>
<button class="plus_one" hidden>+1 Episode</button>
<?php endif ?>
<img src="<?= $item['anime']['cover_image'] ?>" />
<?= $helper->img($item['anime']['cover_image']); ?>
<div class="name">
<a href="<?= $item['anime']['url'] ?>">
<?= $item['anime']['title'] ?>
<a href="<?= $escape->attr($item['anime']['url']) ?>">
<?= $escape->html($item['anime']['title']) ?>
<?= ($item['anime']['alternate_title'] != "") ? "<br />({$item['anime']['alternate_title']})" : ""; ?>
</a>
</div>
@ -27,9 +27,9 @@
</div>
</div>
<div class="row">
<div class="media_type"><?= $item['anime']['show_type'] ?></div>
<div class="airing_status"><?= $item['anime']['status'] ?></div>
<div class="age_rating"><?= $item['anime']['age_rating'] ?></div>
<div class="media_type"><?= $escape->html($item['anime']['show_type']) ?></div>
<div class="airing_status"><?= $escape->html($item['anime']['status']) ?></div>
<div class="age_rating"><?= $escape->html($item['anime']['age_rating']) ?></div>
</div>
</div>
</article>

View File

@ -3,16 +3,16 @@
<head>
<title><?= $title ?></title>
<meta charset="utf-8" />
<link rel="stylesheet" href="<?= $urlGenerator->asset_url('css.php?g=base') ?>" />
<link rel="stylesheet" href="<?= $escape->attr($urlGenerator->asset_url('css.php?g=base')) ?>" />
<script>
var BASE_URL = "<?= $urlGenerator->base_url($url_type) ?>";
var CONTROLLER = "<?= $url_type ?>";
</script>
</head>
<body class="<?= $url_type ?> list">
<body class="<?= $escape->attr($url_type) ?> list">
<h1 class="flex flex-align-end flex-wrap">
<span class="flex-no-wrap grow-1">
<a href="<?= $urlGenerator->default_url($url_type) ?>">
<a href="<?= $escape->attr($urlGenerator->default_url($url_type)) ?>">
<?= $config->whose_list ?>'s <?= ucfirst($url_type) ?> <?= (strpos($route_path, 'collection') !== FALSE) ? 'Collection' : 'List' ?>
</a> [<a href="<?= $urlGenerator->default_url($other_type) ?>"><?= ucfirst($other_type) ?> List</a>]
</span>

View File

@ -4,7 +4,7 @@
<?php else: ?>
<?php foreach ($sections as $name => $items): ?>
<section class="status">
<h2><?= $name ?></h2>
<h2><?= $escape->html($name) ?></h2>
<section class="media-wrap">
<?php foreach($items as $item): ?>
<article class="media" id="manga-<?= $item['id'] ?>">
@ -14,10 +14,10 @@
<button class="plus_one_volume">+1 Volume</button>
</div>
<?php endif ?>
<img src="<?= $item['manga']['poster_image'] ?>" />
<img src="<?= $escape->attr($item['manga']['poster_image']) ?>" />
<div class="name">
<a href="https://hummingbird.me/manga/<?= $item['manga_id'] ?>">
<?= $item['manga']['romaji_title'] ?>
<?= $escape->html($item['manga']['romaji_title']) ?>
<?= (isset($item['manga']['english_title'])) ? "<br />({$item['manga']['english_title']})" : ""; ?>
</a>
</div>
@ -38,9 +38,6 @@
</div>
</div>
</div>
<?php /*<div class="medium_metadata">
<div class="media_type"><?= $item['manga']['manga_type'] ?></div>
</div> */ ?>
</article>
<?php endforeach ?>
</section>
@ -49,5 +46,5 @@
<?php endif ?>
</main>
<?php if (is_logged_in()): ?>
<script src="<?= $config->asset_url('js.php?g=edit') ?>"></script>
<script src="<?= $urlGenerator->asset_url('js.php?g=edit') ?>"></script>
<?php endif ?>

View File

@ -1,5 +1,5 @@
<div class="message <?= $stat_class ?>">
<div class="message <?= $escape->attr($stat_class) ?>">
<span class="icon"></span>
<?= $message ?>
<?= $escape->html($message) ?>
<span class="close" onclick="this.parentElement.style.display='none'">x</span>
</div>

View File

@ -3,6 +3,7 @@
"description": "A self-hosted anime/manga client for hummingbird.",
"license":"MIT",
"require": {
"container-interop/container-interop": "1.*",
"guzzlehttp/guzzle": "5.3.*",
"filp/whoops": "dev-php7#fe32a402b086b21360e82013e8a0355575c7c6f4",
"aura/router": "2.2.*",
@ -12,6 +13,7 @@
"aviat4ion/query": "2.5.*",
"robmorgan/phinx": "0.4.*",
"abeautifulsite/simpleimage": "2.5.*",
"szymach/c-pchart": "1.*"
"szymach/c-pchart": "1.*",
"league/fractal": "0.12.0"
}
}

View File

@ -2,7 +2,6 @@
/**
* Here begins everything!
*/
session_start();
// Work around the silly timezone error
@ -30,9 +29,6 @@ function _dir()
return implode(DIRECTORY_SEPARATOR, func_get_args());
}
// Set up composer autoloader
require _dir(ROOT_DIR, '/vendor/autoload.php');
/**
* Set up autoloaders
*
@ -50,7 +46,8 @@ spl_autoload_register(function ($class) {
}
});
// Do dependency injection, and go!
// Dependency setup
require _dir(ROOT_DIR, '/vendor/autoload.php');
require _dir(APP_DIR, 'bootstrap.php');
// End of index.php

View File

@ -0,0 +1,50 @@
<?php
namespace Aviat\AnimeClient\Auth;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Model\Anime as AnimeModel;
class HummingbirdAuth {
use \Aviat\Ion\Di\ContainerAware;
/**
* Anime API Model
*
* @var AnimeModel
*/
protected $model;
/**
* Session object
*
* @var Aura\Session\Segment
*/
protected $session;
/**
* Constructor
*
* @param ContainerInterface $container
*/
public function __construct(ContainerInterface $container)
{
$this->setContainer($container);
$this->session = $container->get('sesion')
->getSegment(__NAMESPACE__);
$this->model = new AnimeModel($container);
}
public function authenticate($username, $password)
{
}
public function get_auth_token()
{
}
}
// End of HummingbirdAuth.php

View File

@ -2,10 +2,14 @@
namespace Aviat\AnimeClient;
use Aviat\Ion\Di\Container as BaseContainer;
use Aviat\Ion\Di\ContainerInterface;
/**
* Dependency container
*/
class Container extends \Aviat\Ion\Base\Container {
class Container
extends BaseContainer {
}
// End of Container.php

View File

@ -4,23 +4,26 @@
*/
namespace Aviat\AnimeClient;
use Aura\Web\ResponseSender;
use \Aviat\Ion\Di\ContainerInterface;
use \Aviat\Ion\View\HttpView;
use \Aviat\Ion\View\HtmlView;
use \Aviat\Ion\View\JsonView;
/**
* Base class for controllers, defines output methods
* Controller base, defines output methods
*/
class Controller {
use \Aviat\Ion\Di\ContainerAware;
/**
* The global configuration object
* @var object $config
*/
protected $config;
/**
* Request object
* @var object $request
*/
protected $request;
/**
* Response object
* @var object $response
@ -54,26 +57,15 @@ class Controller {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
$this->setContainer($container);
$urlGenerator = $container->get('url-generator');
$this->config = $container->get('config');
$this->base_data['config'] = $this->config;
$this->base_data['urlGenerator'] = $container->get('url-generator');
$this->request = $container->get('request');
$this->response = $container->get('response');
$this->urlGenerator = $container->get('url-generator');
}
/**
* Destructor
*
* @codeCoverageIgnore
*/
public function __destruct()
{
$this->output();
$this->base_data['urlGenerator'] = $urlGenerator;
$this->urlGenerator = $urlGenerator;
}
/**
@ -84,7 +76,7 @@ class Controller {
*/
public function __get($key)
{
$allowed = ['request', 'response', 'config'];
$allowed = ['response', 'config'];
if (in_array($key, $allowed))
{
@ -97,78 +89,94 @@ class Controller {
/**
* Get the string output of a partial template
*
* @codeCoverageIgnore
* @param HTMLView $view
* @param string $template
* @param array|object $data
* @return string
*/
public function load_partial($template, $data=[])
public function load_partial($view, $template, $data=[])
{
$errorHandler = $this->container->get('error-handler');
$router = $this->container->get('router');
if (isset($this->base_data))
{
$data = array_merge($this->base_data, $data);
}
global $router, $defaultHandler;
$route = $router->get_route();
$data['route_path'] = ($route) ? $router->get_route()->path : "";
$defaultHandler->addDataTable('Template Data', $data);
$template_path = _dir(APP_DIR, 'views', "{$template}.php");
$errorHandler->addDataTable('Template Data', $data);
$template_path = _dir($this->config->__get('view_path'), "{$template}.php");
if ( ! is_file($template_path))
{
throw new \InvalidArgumentException("Invalid template : {$path}");
throw new \InvalidArgumentException("Invalid template : {$template}");
}
ob_start();
extract($data);
include _dir(APP_DIR, 'views', 'header.php');
include $template_path;
include _dir(APP_DIR, 'views', 'footer.php');
$buffer = ob_get_contents();
ob_end_clean();
return $view->render_template($template_path, $data);
}
return $buffer;
/**
* Render a template with header and footer
*
* @param HTMLView $view
* @param string $template
* @param array|object $data
* @return void
*/
public function render_full_page($view, $template, $data)
{
$view->appendOutput($this->load_partial($view, 'header', $data));
$view->appendOutput($this->load_partial($view, $template, $data));
$view->appendOutput($this->load_partial($view, 'footer', $data));
}
/**
* Output a template to HTML, using the provided data
*
* @codeCoverageIgnore
* @param string $template
* @param array|object $data
* @return void
*/
public function outputHTML($template, $data=[])
{
$buffer = $this->load_partial($template, $data);
$view = new HtmlView($this->container);
$this->render_full_page($view, $template, $data);
}
$this->response->content->setType('text/html');
$this->response->content->set($buffer);
/**
* Output a JSON Response
*
* @param mixed $data
* @return void
*/
public function outputJSON($data=[])
{
$view = new JsonView($this->container);
$view->setOutput($data);
}
/**
* Redirect to the selected page
*
* @codeCoverageIgnore
* @param string $url
* @param string $path
* @param int $code
* @param string $type
* @return void
*/
public function redirect($url, $code, $type="anime")
public function redirect($path, $code, $type="anime")
{
$url = $this->urlGenerator->full_url($url, $type);
$url = $this->urlGenerator->full_url($path, $type);
$http = new HttpView($this->container);
$this->response->redirect->to($url, $code);
$http->redirect($url, $code);
}
/**
* Add a message box to the page
*
* @codeCoverageIgnore
* @param string $type
* @param string $message
* @return string
@ -184,7 +192,6 @@ class Controller {
/**
* Clear the api session
*
* @codeCoverageIgnore
* @return void
*/
public function logout()
@ -196,7 +203,6 @@ class Controller {
/**
* Show the login form
*
* @codeCoverageIgnore
* @param string $status
* @return void
*/
@ -222,53 +228,22 @@ class Controller {
*/
public function login_action()
{
$request = $this->container->get('request');
if (
$this->model->authenticate(
$this->config->hummingbird_username,
$this->request->post->get('password')
$request->post->get('password')
)
)
{
$this->response->redirect->afterPost($this->urlGenerator->full_url('', $this->base_data['url_type']));
$this->response->redirect->afterPost(
$this->urlGenerator->full_url('', $this->base_data['url_type'])
);
return;
}
$this->login("Invalid username or password.");
}
/**
* Send the appropriate response
*
* @codeCoverageIgnore
* @return void
*/
private function output()
{
// send status
@header($this->response->status->get(), true, $this->response->status->getCode());
// headers
foreach($this->response->headers->get() as $label => $value)
{
@header("{$label}: {$value}");
}
// cookies
foreach($this->response->cookies->get() as $name => $cookie)
{
@setcookie(
$name,
$cookie['value'],
$cookie['expire'],
$cookie['path'],
$cookie['domain'],
$cookie['secure'],
$cookie['httponly']
);
}
// send the actual response
echo $this->response->content->get();
}
}
// End of BaseController.php

View File

@ -5,9 +5,8 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Config;
use Aviat\AnimeClient\Model\Anime as AnimeModel;
use Aviat\AnimeClient\Model\AnimeCollection as AnimeCollectionModel;
@ -53,12 +52,10 @@ class Anime extends BaseController {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$config = $container->get('config');
if ($this->config->show_anime_collection === FALSE)
{
unset($this->nav_routes['Collection']);

View File

@ -5,7 +5,7 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Config;
use Aviat\AnimeClient\UrlGenerator;
@ -54,7 +54,7 @@ class Collection extends BaseController {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);

View File

@ -4,7 +4,7 @@
*/
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Config;
use Aviat\AnimeClient\Model\Manga as MangaModel;
@ -45,7 +45,7 @@ class Manga extends Controller {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$config = $container->get('config');
@ -86,7 +86,7 @@ class Manga extends Controller {
'on_hold' => 'On Hold'
];
$title = $this->config->whose_list . "' Manga List &middot; {$map[$status]}";
$title = $this->config->whose_list . "'s Manga List &middot; {$map[$status]}";
$view_map = [
'' => 'cover',
@ -99,7 +99,7 @@ class Manga extends Controller {
$this->outputHTML('manga/' . $view_map[$view], [
'title' => $title,
'sections' => $data
'sections' => $data,
]);
}
}

View File

@ -2,7 +2,7 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Controller;
class Stats extends Controller {

View File

@ -4,6 +4,7 @@
*/
namespace Aviat\AnimeClient;
use Aviat\Ion\Di\ContainerInterface;
use abeautifulsite\SimpleImage;
/**
@ -28,7 +29,7 @@ class Model {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
$this->container = $container;
$this->config = $container->get('config');

View File

@ -6,12 +6,13 @@ namespace Aviat\AnimeClient\Model;
use \GuzzleHttp\Client;
use \GuzzleHttp\Cookie\CookieJar;
use \Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Model as BaseModel;
/**
* Base model for api interaction
*/
class API extends \Aviat\AnimeClient\Model {
class API extends BaseModel {
/**
* Base url for making api requests
@ -36,7 +37,7 @@ class API extends \Aviat\AnimeClient\Model {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->cookieJar = new CookieJar();

View File

@ -138,7 +138,7 @@ class Anime extends API {
*/
public function search($name)
{
global $defaultHandler;
$errorHandler = $this->container->get('error-handler');
$config = [
'query' => [
@ -147,7 +147,7 @@ class Anime extends API {
];
$response = $this->client->get('search/anime', $config);
$defaultHandler->addDataTable('anime_search_response', (array)$response);
$errorHandler->addDataTable('anime_search_response', (array)$response);
if ($response->getStatusCode() != 200)
{
@ -165,7 +165,7 @@ class Anime extends API {
*/
private function _get_list($status="all")
{
global $defaultHandler;
$errorHandler = $this->container->get('error-handler');
$cache_file = "{$this->config->data_cache_path}/anime-{$status}.json";
@ -180,7 +180,7 @@ class Anime extends API {
$response = $this->client->get("users/{$this->config->hummingbird_username}/library", $config);
$defaultHandler->addDataTable('anime_list_response', (array)$response);
$errorHandler->addDataTable('anime_list_response', (array)$response);
if ($response->getStatusCode() != 200)
{

View File

@ -5,8 +5,8 @@
namespace Aviat\AnimeClient\Model;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Model\DB;
use Aviat\AnimeClient\Container;
use Aviat\AnimeClient\Model\Anime as AnimeModel;
/**
@ -31,7 +31,7 @@ class AnimeCollection extends DB {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);

View File

@ -4,12 +4,13 @@
*/
namespace Aviat\AnimeClient\Model;
use Aviat\AnimeClient\Container;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Model as BaseModel;
/**
* Base model for database interaction
*/
class DB extends \Aviat\AnimeClient\Model {
class DB extends BaseModel {
/**
* The query builder object
* @var object $db
@ -27,7 +28,7 @@ class DB extends \Aviat\AnimeClient\Model {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->db_config = $this->config->database;

View File

@ -76,7 +76,7 @@ class Manga extends API {
*/
private function _get_list($status="all")
{
global $defaultHandler;
$errorHandler = $this->container->get('error-handler');
$cache_file = _dir($this->config->data_cache_path, 'manga.json');
@ -89,7 +89,7 @@ class Manga extends API {
$response = $this->client->get('manga_library_entries', $config);
$defaultHandler->addDataTable('response', (array)$response);
$errorHandler->addDataTable('response', (array)$response);
if ($response->getStatusCode() != 200)
{

View File

@ -5,19 +5,22 @@
namespace Aviat\AnimeClient\Model;
use Avait\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Model\DB;
use Aviat\AnimeClient\Container;
use StatsChartsTrait;
/**
* Base Model for stats about lists and collection(s)
*/
class Stats extends DB {
use StatsChartsTrait;
/**
* Constructor
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->chartSetup();

View File

@ -7,6 +7,8 @@ namespace Aviat\AnimeClient;
use Aura\Web\Request;
use Aura\Web\Response;
use Aviat\Ion\Di\ContainerInterface;
/**
* Basic routing/ dispatch
*/
@ -35,7 +37,7 @@ class Router extends RoutingBase {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->router = $container->get('aura-router');
@ -100,6 +102,12 @@ class Router extends RoutingBase {
else
{
list($controller_name, $action_method) = $route->params['action'];
if (is_null($controller_name))
{
throw new \LogicException("Missing controller");
}
$params = (isset($route->params['params'])) ? $route->params['params'] : [];
if ( ! empty($route->tokens))
@ -205,7 +213,8 @@ class Router extends RoutingBase {
array_unshift($route['action'], $controller_class);
// Select the appropriate router method based on the http verb
$add = (array_key_exists('verb', $route)) ? "add" . ucfirst(strtolower($route['verb'])) : "addGet";
$add = (array_key_exists('verb', $route))
? "add" . ucfirst(strtolower($route['verb'])) : "addGet";
// Add the route to the router object
if ( ! array_key_exists('tokens', $route))

View File

@ -5,6 +5,8 @@
*/
namespace Aviat\AnimeClient;
use Aviat\Ion\Di\ContainerInterface;
/**
* Base for routing/url classes
*/
@ -32,7 +34,7 @@ class RoutingBase {
*
* @param Container $container
*/
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
$this->container = $container;
$this->config = $container->get('config');

View File

@ -0,0 +1,15 @@
<?php
namespace Aviat\AnimeClient\Transformer\Hummingbird;
use League\Fractal;
class AnimeListTransformer extends Fractal\TransformerAbstract {
public function transform($item)
{
}
}
// End of AnimeListTransformer.php

View File

@ -0,0 +1,78 @@
<?php
namespace Aviat\AnimeClient\Transformer\Hummingbird;
/**
* Merges the two separate manga lists together
*/
class MangaListsZipper {
/**
* List of manga information
*
* @var array
*/
protected $manga_series_list = [];
/**
* List of manga tracking information
*
* @var array
*/
protected $manga_tracking_list = [];
/**
* Create the transformer
*
* @param array $merge_lists The raw manga data
*/
public function __construct(array $merge_lists)
{
$this->manga_series_list = $merge_lists['manga'];
$this->manga_tracking_list = $merge_lists['manga_library_entries'];
}
/**
* Do the transformation, and return the output
*
* @return array
*/
public function transform()
{
$this->index_manga_entries();
$output = [];
foreach($this->manga_tracking_list as &$entry)
{
$id = $entry['manga_id'];
$entry['manga'] = $this->manga_series_list[$id];
unset($entry['manga_id']);
$output[] = $entry;
}
return $output;
}
/**
* Index manga series by the id
*
* @return void
*/
protected function index_manga_entries()
{
$orig_list = $this->manga_series_list;
$indexed_list = [];
foreach($orig_list as $manga)
{
$id = $manga['id'];
$indexed_list[$id] = $manga;
}
$this->manga_series_list = $indexed_list;
}
}
// End of ManagListsZipper.php

View File

@ -1,54 +0,0 @@
<?php
namespace Aviat\Ion\Base;
/**
* Dependency container
*/
class Container {
/**
* Array with class instances
*
* @var array
*/
protected $container = [];
/**
* Constructor
*
* @param array $values (optional)
*/
public function __construct(array $values = [])
{
$this->container = $values;
}
/**
* Get a value
*
* @param string $key
* @retun mixed
*/
public function get($key)
{
if (array_key_exists($key, $this->container))
{
return $this->container[$key];
}
}
/**
* Add a value to the container
*
* @param string $key
* @param mixed $value
* @return Container
*/
public function set($key, $value)
{
$this->container[$key] = $value;
return $this;
}
}
// End of Container.php

View File

@ -0,0 +1,82 @@
<?php
namespace Aviat\Ion\Di;
use ArrayObject;
use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException;
/**
* Dependency container
*/
class Container implements ContainerInterface {
/**
* Array with class instances
*
* @var array
*/
protected $container = [];
/**
* Constructor
*
* @param array $values (optional)
*/
public function __construct(array $values = [])
{
$this->container = new ArrayObject($values);
}
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @throws NotFoundException No entry was found for this identifier.
* @throws ContainerException Error while retrieving the entry.
*
* @return mixed Entry.
*/
public function get($id)
{
if ( ! is_string($id))
{
throw new Exception\ContainerException("Id must be a string");
}
if ($this->has($id))
{
return $this->container[$id];
}
throw new Exception\NotFoundException("Item {$id} does not exist in container.");
}
/**
* Add a value to the container
*
* @param string $id
* @param mixed $value
* @return Container
*/
public function set($id, $value)
{
$this->container[$id] = $value;
return $this;
}
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* @param string $id Identifier of the entry to look for.
*
* @return boolean
*/
public function has($id)
{
return $this->container->offsetExists($id);
}
}
// End of Container.php

View File

@ -0,0 +1,36 @@
<?php
namespace Aviat\Ion\Di;
trait ContainerAware {
/**
* Di Container
*
* @var ContainerInterface
*/
protected $container;
/**
* Set the container for the current object
*
* @param ContainerInterface $container
* @return $this
*/
public function setContainer(ContainerInterface $container)
{
$this->container = $container;
return $this;
}
/**
* Get the container object
*
* @return ContainerInterface
*/
public function getContainer()
{
return $this->container;
}
}
// End of ContainerAware.php

View File

@ -0,0 +1,23 @@
<?php
namespace Aviat\Ion\Di;
interface ContainerAwareInterface {
/**
* Set the container for the current object
*
* @param ContainerInterface $container
* @return void
*/
public function setContainer(ContainerInterface $container);
/**
* Get the container object
*
* @return ContainerInterface
*/
public function getContainer();
}
// End of ContainerAwareInterface.php

View File

@ -0,0 +1,15 @@
<?php
namespace Aviat\Ion\Di;
interface ContainerInterface extends \Interop\Container\ContainerInterface {
/**
* Add a value to the container
*
* @param string $key
* @param mixed $value
* @return ContainerInterface
*/
public function set($key, $value);
}

View File

@ -0,0 +1,10 @@
<?php
namespace Aviat\Ion\Di\Exception;
class ContainerException
extends \Exception
implements \Interop\Container\Exception\ContainerException {
}
// End of ContainerException.php

View File

@ -0,0 +1,10 @@
<?php
namespace Aviat\Ion\Di\Exception;
class NotFoundException
extends ContainerException
implements \Interop\Container\Exception\NotFoundException {
}
// End of NotFoundException.php

105
src/Aviat/Ion/View.php Normal file
View File

@ -0,0 +1,105 @@
<?php
namespace Aviat\Ion;
use Aviat\Ion\Di\ContainerInterface;
abstract class View {
use Di\ContainerAware;
/**
* DI Container
*
* @var ContainerInterface
*/
protected $container;
/**
* HTTP response Object
*
* @var Aura\Web\Response
*/
protected $response;
/**
* Response mime type
*
* @var string
*/
protected $contentType = '';
/**
* String of response to be output
*
* @var string
*/
protected $output = '';
/**
* Constructor
*
* @param ContainerInterface $container
*/
public function __construct(ContainerInterface $container)
{
$this->setContainer($container);
$this->response = $container->get('response');
}
/**
* Send output to client
*/
public function __destruct()
{
$this->output();
}
/**
* Set the output string
*
* @param string $string
* @return View
*/
public function setOutput($string)
{
$this->output = $string;
return $this;
}
/**
* Append additional output
*
* @param string $string
* @return View
*/
public function appendOutput($string)
{
$this->output .= $string;
return $this;
}
/**
* Get the current output string
*
* @return string
*/
public function getOutput()
{
return $this->output;
}
/**
* Send the appropriate response
*
* @return void
*/
protected function output()
{
$content =& $this->response->content;
$content->set($this->output);
$content->setType($this->contentType);
$content->setCharset('utf-8');
}
}
// End of View.php

View File

@ -0,0 +1,50 @@
<?php
namespace Aviat\Ion\View;
use Aura\Html\HelperLocatorFactory;
use Aviat\Ion\View\HttpView;
use Aviat\Ion\Di\ContainerInterface;
class HtmlView extends HttpView {
protected $helper;
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
$this->helper = (new HelperLocatorFactory)->newInstance();
}
/**
* Response mime type
*
* @var string
*/
protected $contentType = 'text/html';
/**
* Render a basic html Template
*
* @param string $path
* @param array $data
* @return string
*/
public function render_template($path, $data)
{
$buffer = "";
$data['helper'] = $this->helper;
$data['escape'] = $this->helper->escape();
ob_start();
extract($data);
include $path;
$buffer = ob_get_contents();
ob_end_clean();
return $buffer;
}
}
// End of HtmlView.php

View File

@ -0,0 +1,36 @@
<?php
namespace Aviat\Ion\View;
use Aura\Web\ResponseSender;
use Aviat\Ion\View as BaseView;
class HttpView extends BaseView {
/**
* Do a redirect
*
* @param string $url
* @param int $code
* @return void
*/
public function redirect($url, $code)
{
$this->response->redirect->to($url, $code);
}
/**
* Send the appropriate response
*
* @return void
*/
protected function output()
{
parent::output();
$sender = new ResponseSender($this->response);
$sender->__invoke();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Aviat\Ion\View;
use Aviat\Ion\View\HttpView;
class JsonView extends HttpView {
/**
* Response mime type
*
* @var string
*/
protected $contentType = 'application/json';
/**
* Set the output string
*
* @param mixed $string
* @return View
*/
public function setOutput($string)
{
if ( ! is_string($string))
{
$string = json_encode($string);
}
return parent::setOutput($string);
}
}
// End of JsonView.php

View File

@ -1,7 +1,6 @@
<?php
use Aviat\AnimeClient\Model as BaseModel;
use Aviat\AnimeClient\Container;
class BaseModelTest extends AnimeClient_TestCase {

View File

@ -31,9 +31,6 @@ class ControllerTest extends AnimeClient_TestCase {
public function dataGet()
{
return [
'request' => [
'key' => 'request',
],
'response' => [
'key' => 'response',
],

View File

@ -1,11 +1,12 @@
<?php
use Aviat\Ion\Di\ContainerInterface;
use Aviat\AnimeClient\Container;
use Aviat\AnimeClient\Model\API as BaseApiModel;
class MockBaseApiModel extends BaseApiModel {
public function __construct(Container $container)
public function __construct(ContainerInterface $container)
{
parent::__construct($container);
}

View File

@ -74,7 +74,6 @@ class RouterTest extends AnimeClient_TestCase {
'action' => ['anime_list'],
'params' => [
'type' => 'currently-watching',
'title' => WHOSE . " Anime List &middot; Watching"
],
'tokens' => [
'view' => '[a-z_]+'
@ -87,7 +86,6 @@ class RouterTest extends AnimeClient_TestCase {
'action' => ['manga_list'],
'params' => [
'type' => 'Plan to Read',
'title' => WHOSE . " Manga List &middot; Plan to Read"
],
'tokens' => [
'view' => '[a-z_]+'

View File

@ -0,0 +1,24 @@
<?php
use Aviat\AnimeClient\Transformer\Hummingbird\MangaListsZipper;
class MangaListsZipperTest extends AnimeClient_TestCase {
protected $start_file = __DIR__ . '/../../test_data/manga_list/manga.json';
protected $res_file = __DIR__ . '/../../test_data/manga_list/manga-zippered.json';
public function setUp()
{
$json = json_decode(file_get_contents($this->start_file), TRUE);
$this->mangaListsZipper = new MangaListsZipper($json);
}
public function testTransform()
{
$zippered_json = json_decode(file_get_contents($this->res_file), TRUE);
$transformed = $this->mangaListsZipper->transform();
$this->assertEquals($zippered_json, $transformed);
}
}

View File

@ -5,6 +5,7 @@
use Aviat\AnimeClient\Config;
use Aviat\AnimeClient\Container;
use Aviat\AnimeClient\UrlGenerator;
// -----------------------------------------------------------------------------
// Mock the default error handler
@ -14,8 +15,6 @@ class MockErrorHandler {
public function addDataTable($name, Array $values) {}
}
$defaultHandler = new MockErrorHandler();
// -----------------------------------------------------------------------------
// Define a base testcase class
// -----------------------------------------------------------------------------
@ -41,9 +40,12 @@ class AnimeClient_TestCase extends PHPUnit_Framework_TestCase {
]);
$container = new Container([
'config' => $config
'config' => $config,
'error-handler' => new MockErrorHandler()
]);
$container->set('url-generator', new UrlGenerator($container));
$this->container = $container;
}
}
@ -52,12 +54,6 @@ class AnimeClient_TestCase extends PHPUnit_Framework_TestCase {
// Autoloaders
// -----------------------------------------------------------------------------
// Define WHOSE constant
define('WHOSE', "Foo's");
// Define base path constants
define('ROOT_DIR', realpath(__DIR__ . DIRECTORY_SEPARATOR . "/../"));
/**
* Joins paths together. Variadic to take an
* arbitrary number of arguments
@ -69,10 +65,14 @@ function _dir()
return implode(DIRECTORY_SEPARATOR, func_get_args());
}
// Define base path constants
define('ROOT_DIR', realpath(__DIR__ . DIRECTORY_SEPARATOR . "/../"));
define('APP_DIR', _dir(ROOT_DIR, 'app'));
define('CONF_DIR', _dir(APP_DIR, 'config'));
define('SRC_DIR', _dir(ROOT_DIR, 'src'));
define('BASE_DIR', _dir(SRC_DIR, 'Base'));
require _dir(ROOT_DIR, '/vendor/autoload.php');
require _dir(SRC_DIR, 'functions.php');
/**
* Set up autoloaders
@ -80,24 +80,16 @@ define('BASE_DIR', _dir(SRC_DIR, 'Base'));
* @codeCoverageIgnore
* @return void
*/
function _setup_autoloaders()
{
require _dir(ROOT_DIR, '/vendor/autoload.php');
spl_autoload_register(function ($class) {
$class_parts = explode('\\', $class);
$ns_path = SRC_DIR . '/' . implode('/', $class_parts) . ".php";
spl_autoload_register(function ($class) {
$class_parts = explode('\\', $class);
$ns_path = SRC_DIR . '/' . implode('/', $class_parts) . ".php";
if (file_exists($ns_path))
{
require_once($ns_path);
return;
}
});
}
// Setup autoloaders
_setup_autoloaders();
require(_dir(SRC_DIR, 'functions.php'));
if (file_exists($ns_path))
{
require_once($ns_path);
return;
}
});
// Pre-define some superglobals
$_SESSION = [];

View File

@ -0,0 +1,797 @@
[
{
"id": 9131610,
"episodes_watched": 2,
"last_watched": "2015-09-17T16:52:19.028Z",
"updated_at": "2015-09-17T16:52:19.029Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 7190,
"mal_id": 14967,
"slug": "boku-wa-tomodachi-ga-sukunai-next",
"status": "Finished Airing",
"url": "https://hummingbird.me/anime/boku-wa-tomodachi-ga-sukunai-next",
"title": "Boku wa Tomodachi ga Sukunai NEXT",
"alternate_title": "Haganai NEXT",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/007/190/large/0.jpg?1417468876",
"synopsis": "The Neighbor's Club—a club founded for the purpose of making friends,where misfortunate boys and girls with few friends live out their regrettable lives.\r\nAlthough Yozora Mikazuki faced a certain incident at the end of summer,the daily life of the Neighbor's Club goes on as usual.A strange nun,members of the student council and other new faces make an appearance,causing Kodaka Hasegawa's life to grow even busier.\r\nWhile they all enjoy going to the amusement park,playing games,celebrating birthdays,and challenging the\"school festival\"—a symbol of the school life normal people live—the relations amongst the members slowly begins to change...\r\nLet the next stage begin,on this unfortunate coming-of-age love comedy!!\r\n(Source:ANN)",
"show_type": "TV",
"started_airing": "2013-01-11",
"finished_airing": "2013-03-29",
"community_rating": 3.8820340732555,
"age_rating": "R17+",
"genres": [
{
"name": "Comedy"
},
{
"name": "Romance"
},
{
"name": "School"
},
{
"name": "Harem"
}
]
},
"rating": {
"type": "advanced",
"value": null
}
},
{
"id": 10177172,
"episodes_watched": 11,
"last_watched": "2015-09-14T23:49:37.044Z",
"updated_at": "2015-09-14T23:49:37.045Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10350,
"mal_id": 29785,
"slug": "jitsu-wa-watashi-wa",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/jitsu-wa-watashi-wa",
"title": "Jitsu wa Watashi wa",
"alternate_title": "Actually, I Am…",
"episode_count": 13,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/350/large/ndmkhu.jpg?1431603318",
"synopsis": "Asahi Kuromine has a crush on a cute girl named Youko Shiragami. Shiragami just happens to be a vampire. Asahi cannot keep a secret, but he is determined to keep Shiragami's secret anyway.\r\n\r\n(Source: ANN)",
"show_type": "TV",
"started_airing": "2015-07-07",
"finished_airing": null,
"community_rating": 3.768867119294,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Fantasy"
},
{
"name": "Romance"
},
{
"name": "School"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 9131615,
"episodes_watched": 8,
"last_watched": "2015-09-12T18:14:16.370Z",
"updated_at": "2015-09-12T18:14:16.371Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 9095,
"mal_id": 27525,
"slug": "fate-kaleid-liner-prisma-illya-2wei-herz",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/fate-kaleid-liner-prisma-illya-2wei-herz",
"title": "Fate/kaleid liner Prisma☆Illya 2wei Herz!",
"alternate_title": null,
"episode_count": 10,
"episode_length": 23,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/009/095/large/Q0l30yH.jpg?1427031275",
"synopsis": "Third season of Fate/kaleid Liner Prisma Illya.",
"show_type": "TV",
"started_airing": "2015-07-24",
"finished_airing": null,
"community_rating": 3.7841266617022,
"age_rating": "PG13",
"genres": [
{
"name": "Action"
},
{
"name": "Comedy"
},
{
"name": "Magic"
},
{
"name": "Fantasy"
},
{
"name": "Mahou Shoujo"
}
]
},
"rating": {
"type": "advanced",
"value": "4.5"
}
},
{
"id": 10426033,
"episodes_watched": 8,
"last_watched": "2015-08-01T23:26:21.869Z",
"updated_at": "2015-08-01T23:26:21.870Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 475,
"mal_id": 516,
"slug": "keroro-gunsou",
"status": "Finished Airing",
"url": "https://hummingbird.me/anime/keroro-gunsou",
"title": "Keroro Gunsou",
"alternate_title": "Sergeant Frog",
"episode_count": 358,
"episode_length": 23,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/000/475/large/475.jpg?1416242348",
"synopsis": "Keroro is a frog-like alien sent from his home planet on a mission to conquer Earth. But when his cover is blown, his battalion abandons him and he ends up in the home of the Hinata family. There, he's forced to do household chores and sleep in a dark basement that was once supposedly a prison cell haunted by the ghost of an innocent girl. He even spends his free time assembling Gundam model kits. During his stay, Keroro meets up with subordinates who were also stranded during their failed invasion. \n(Source: ANN)",
"show_type": "TV",
"started_airing": "2004-04-03",
"finished_airing": "2011-04-04",
"community_rating": 3.8815806455204,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Sci-Fi"
}
]
},
"rating": {
"type": "advanced",
"value": "3.5"
}
},
{
"id": 10299917,
"episodes_watched": 11,
"last_watched": "2015-09-14T23:55:59.297Z",
"updated_at": "2015-09-14T23:55:59.298Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10352,
"mal_id": 29786,
"slug": "shimoneta-to-iu-gainen-ga-sonzai-shinai-taikutsu-na-sekai",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/shimoneta-to-iu-gainen-ga-sonzai-shinai-taikutsu-na-sekai",
"title": "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai",
"alternate_title": "SHIMONETA: A Boring World Where the Concept of Dirty Jokes Doesn't Exist",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/352/large/shimoneta02.jpg?1433942845",
"synopsis": "Who is the panty-masked villainess spreading obscenity in a country where even the mildest off-color musing can land you in jail?\r\n\r\nWhen the student council president of the most elite public morals school in the country has a feeling that the lewd is coming from within the walls, she recruits Tanukichi, a recent transfer student, to her upstanding moral squad.\r\n\r\nLittle does she know hes already been blackmailed by Ayame, her own vice president who is secretly the panty-masked bandit, into committing mass acts of public obscenity in the name of SOX—a brigade of sorts—dedicated to spreading the good news of being lewd.\r\n\r\n(Source: FUNimation)",
"show_type": "TV",
"started_airing": "2015-07-04",
"finished_airing": null,
"community_rating": 4.0207233212975,
"age_rating": "R17+",
"genres": [
{
"name": "Comedy"
},
{
"name": "School"
},
{
"name": "Ecchi"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10299832,
"episodes_watched": 10,
"last_watched": "2015-09-14T01:56:45.778Z",
"updated_at": "2015-09-14T01:56:45.778Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10621,
"mal_id": 30123,
"slug": "akagami-no-shirayuki-hime",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/akagami-no-shirayuki-hime",
"title": "Akagami no Shirayuki-hime",
"alternate_title": "Snow White with the Red Hair",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/621/large/e5ac7bdd2b175b6aee20d8f8528147731432360559_full.jpg?1432401413",
"synopsis": "In the kingdom of Tanbarun lives Shirayuki, an independent and strong-willed young woman. Her resourceful intelligence has led her become a skilled pharmacist, but her most defining trait is her shock of beautiful apple-red hair. Her dazzling mane gets her noticed by the prince of the kingdom, but instead of romancing her, he demands she be his concubine. Shirayuki refuses, chops off her lovely locks, and runs away to the neighboring kingdom of Clarines. There, she befriends a young man named Zen, who, SURPRISE, is also a prince, although with a much better temperament than the previous one. Watch as Shirayuki finds her place in the new kingdom, and in Zens heart.\r\n\r\n(Source: FUNimation)",
"show_type": "TV",
"started_airing": "2015-07-07",
"finished_airing": "2015-09-22",
"community_rating": 4.1011684647044,
"age_rating": "PG13",
"genres": [
{
"name": "Drama"
},
{
"name": "Fantasy"
},
{
"name": "Romance"
},
{
"name": "Historical"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10299826,
"episodes_watched": 11,
"last_watched": "2015-09-12T13:36:42.211Z",
"updated_at": "2015-09-12T13:36:42.212Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10069,
"mal_id": 28819,
"slug": "oku-sama-ga-seito-kaichou",
"status": "Finished Airing",
"url": "https://hummingbird.me/anime/oku-sama-ga-seito-kaichou",
"title": "Oku-sama ga Seito Kaichou!",
"alternate_title": "My Wife is the Student Council President!",
"episode_count": 12,
"episode_length": 8,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/069/large/173744.jpg?1432381972",
"synopsis": "The story begins with Izumi Hayato running to be student body president. But when a beautiful girl swings in promising the liberalization of love while flinging condoms into the audience, he ends up losing to her and becoming the vice president. At the student council meeting, the newly-elected president invites herself over to Izumi's house, where she promptly announces she is to become Izumi's wife thanks to an agreement—facilitated by alcohol—made between their parents when they were only 3.\r\n\r\n(Source: MAL Scanlations)",
"show_type": "TV",
"started_airing": "2015-07-02",
"finished_airing": "2015-09-17",
"community_rating": 3.3822121645568,
"age_rating": "R17+",
"genres": [
{
"name": "Comedy"
},
{
"name": "Romance"
},
{
"name": "School"
},
{
"name": "Ecchi"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10271011,
"episodes_watched": 11,
"last_watched": "2015-09-14T01:26:22.895Z",
"updated_at": "2015-09-14T01:26:22.895Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10029,
"mal_id": 28497,
"slug": "rokka-no-yuusha",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/rokka-no-yuusha",
"title": "Rokka no Yuusha",
"alternate_title": "Rokka: Braves of the Six Flowers",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/029/large/rokkanoyuusha.jpg?1436386289",
"synopsis": "Legend says, when the Evil God awakens from the deepest of darkness, the god of fate will summon Six Braves and grant them with the power to save the world. \r\n \r\nAdlet, who claims to be the strongest on the face of this earth, is chosen as one of the “Brave Six Flowers,” and sets out on a battle to prevent the resurrection of the Evil God. However, it turns out that there are Seven Braves who gathered at the promised land... \r\n \r\nThe Seven Braves notice there must be one enemy among themselves, and feelings of suspicion toward each other spreads throughout the group, with Adlet being the one who gets suspected first and foremost. \r\n \r\nThus begins an overwhelming fantasy adventure that brings upon mystery after mystery!\r\n\r\n(Source: Crunchyroll)",
"show_type": "TV",
"started_airing": "2015-07-05",
"finished_airing": "2015-09-20",
"community_rating": 3.9872922407283,
"age_rating": "PG13",
"genres": [
{
"name": "Action"
},
{
"name": "Adventure"
},
{
"name": "Mystery"
},
{
"name": "Magic"
},
{
"name": "Fantasy"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10296163,
"episodes_watched": 10,
"last_watched": "2015-09-12T18:38:06.334Z",
"updated_at": "2015-09-12T18:38:06.335Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 9726,
"mal_id": 27831,
"slug": "durarara-x2-ten",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/durarara-x2-ten",
"title": "Durarara!!x2 Ten",
"alternate_title": "Durarara!! x2 The Second Arc",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/009/726/large/durararax2tenv2.jpg?1435341790",
"synopsis": "Ikebukuro, a city teeming with the most peculiar characters and the twisted schemes they indulge in. In the aftermath of the assault against the information broker, signs of new disorder begin to develop like ripples across the water. Holding his own ideals, the young man who gains the powers of both the “Dollars” and the “Blue Squares” treads the path to total annihilation. Someone struggles to save their best friend while a psychopath creeps up on a popular idol. Slowly but surely a new threat gains power within the citys shadows…\n\nPaths cross and trouble brews as the plot thickens in this complicated web of conspiracies.\n\n(Source: Aniplex USA)",
"show_type": "TV",
"started_airing": "2015-07-04",
"finished_airing": "2015-09-26",
"community_rating": 4.3105326468495,
"age_rating": "R17+",
"genres": [
{
"name": "Action"
},
{
"name": "Mystery"
},
{
"name": "Supernatural"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10295958,
"episodes_watched": 11,
"last_watched": "2015-09-17T01:54:16.472Z",
"updated_at": "2015-09-17T01:54:16.472Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10748,
"mal_id": 30307,
"slug": "monster-musume-no-iru-nichijou",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/monster-musume-no-iru-nichijou",
"title": "Monster Musume no Iru Nichijou",
"alternate_title": "Everyday Life with Monster Girls",
"episode_count": 12,
"episode_length": 23,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/748/large/hcxfyjp1_l-anime-monster-musume-no-iru-nichijou-en-promotion-video.jpg?1434653745",
"synopsis": "Monsters—they're real, and they want to date us! Three years ago, the world learned that harpies, centaurs, catgirls, and all manners of fabulous creatures are not merely fiction; they are flesh and blood—not to mention scale, feather, horn, and fang. Thanks to the \"Cultural Exchange Between Species Act,\" these once-mythical creatures have assimilated into society, or at least, they're trying.\r\n\r\nWhen a hapless human named Kurusu Kimihito is inducted as a \"volunteer\" into the government exchange program, his world is turned upside down. A snake-like lamia named Miia comes to live with him, and it is Kurusu's job to take care of her and make sure she integrates into his everyday life. Unfortunately for Kurusu, Miia is undeniably sexy, and the law against interspecies breeding is very strict. Even worse, when a ravishing centaur girl and a flirtatious harpy move in, what's a full-blooded young man with raging hormones to do?!\r\n\r\n(Source: Seven Seas Entertainment)",
"show_type": "TV",
"started_airing": "2015-07-08",
"finished_airing": "2015-09-23",
"community_rating": 3.8451747238313,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Fantasy"
},
{
"name": "Romance"
},
{
"name": "Ecchi"
},
{
"name": "Harem"
}
]
},
"rating": {
"type": "advanced",
"value": "3.5"
}
},
{
"id": 10295719,
"episodes_watched": 11,
"last_watched": "2015-09-12T14:20:43.922Z",
"updated_at": "2015-09-12T14:20:43.923Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10085,
"mal_id": 28907,
"slug": "gate-jieitai-kanochi-nite-kaku-tatakaeri",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/gate-jieitai-kanochi-nite-kaku-tatakaeri",
"title": "Gate: Jieitai Kanochi nite, Kaku Tatakaeri",
"alternate_title": "GATE",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/085/large/85a5d8cc2972ae422158be7069076be41435868848_full.jpg?1435924413",
"synopsis": "In August of 20XX, a portal to a parallel world, known as the \"Gate,\" suddenly appeared in Ginza, Tokyo. Monsters and troops poured out of the portal, turning the shopping district into a bloody inferno.\r\n\r\nThe Japan Ground-Self Defence Force immediately took action and pushed the fantasy creatures back to the \"Gate.\" To facilitate negotiations and prepare for future fights, the JGSDF dispatched the Third Reconnaissance Team to the \"Special Region\" at the other side of the Gate.\r\n\r\nYouji Itami, a JSDF officer as well as a 33-year-old otaku, was appointed as the leader of the Team. Amid attacks from enemy troops the team visited a variety of places and learnt a lot about the local culture and geography.\r\n\r\nThanks to their efforts in humanitarian relief, although with some difficulties they were gradually able to reach out to the locals. They even had a cute elf, a sorceress and a demigoddess in their circle of new friends. On the other hand, the major powers outside the Gate such as the United States, China, and Russia were extremely interested in the abundant resources available in the Special Region. They began to exert diplomatic pressure over Japan.\r\n\r\nA suddenly appearing portal to an unknown world—to the major powers it may be no more than a mere asset for toppling the international order. But to our protagonists it is an invaluable opportunity to broaden knowledge, friendship, and ultimately their perspective towards the world.\r\n\r\n(Source: Baka-Tsuki)",
"show_type": "TV",
"started_airing": "2015-07-04",
"finished_airing": null,
"community_rating": 4.10315369511,
"age_rating": null,
"genres": [
{
"name": "Action"
},
{
"name": "Adventure"
},
{
"name": "Fantasy"
},
{
"name": "Military"
}
]
},
"rating": {
"type": "advanced",
"value": "4.5"
}
},
{
"id": 10215731,
"episodes_watched": 11,
"last_watched": "2015-09-12T13:45:12.780Z",
"updated_at": "2015-09-12T13:45:12.780Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10765,
"mal_id": 30384,
"slug": "miss-monochrome-the-animation-2nd-season",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/miss-monochrome-the-animation-2nd-season",
"title": "Miss Monochrome: The Animation 2nd Season",
"alternate_title": null,
"episode_count": 13,
"episode_length": 8,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/765/large/20150511_summer02.jpg?1432381052",
"synopsis": "The Ultra Super Pictures Special Stage event announced at AnimeJapan 2015 on Saturday that the Miss Monochrome television anime series will receive a second season. The season will run within the 30-minute Ultra Super Anime Time block beginning on July 3, 2015.\r\n\r\n(Source: ANN)",
"show_type": "TV",
"started_airing": "2015-07-03",
"finished_airing": null,
"community_rating": 3.6555205723713,
"age_rating": "G",
"genres": [
{
"name": "Slice of Life"
},
{
"name": "Music"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 10177163,
"episodes_watched": 11,
"last_watched": "2015-09-12T17:47:16.672Z",
"updated_at": "2015-09-12T17:47:16.672Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10782,
"mal_id": 30383,
"slug": "classroom-crisis",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/classroom-crisis",
"title": "Classroom☆Crisis",
"alternate_title": "",
"episode_count": 12,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/782/large/gUUHG7u.jpg?1432480149",
"synopsis": "Fourth Tokyo--one of Japans new prefectures on Mars. Kirishina City, Fourth Tokyos special economic zone, is home to the Kirishina Corporation, an elite corporation renowned for its aerospace business. The company has been expanding its market share in various industries, while also running a private school, the Kirishina Science and Technology Academy High School. That alone would make it unique, but theres also a high-profile class on campus. \r\n \r\nDevoting themselves to their studies during the day, they then report to the company after school to take part in a crucial project, the development of prototype variants for rockets. This is the Kirishina Corporations Advanced Technological Development Department, Educational Development Class, a.k.a. A-TEC. A-TECs chief, the young engineering genius, Kaito Sera, is also the homeroom teacher of the A- TEC students attending the academy, affectionately (?) known as the Raving Rocket Teacher. \r\n \r\nThe story begins with the arrival of a transfer student to A-TEC. \r\n \r\nThe A-TEC members are ready to welcome their new classmate, but the student in question is kidnapped en route to Mars. Determining that they themselves will have to be the ones to overcome this crisis, Kaito and the A-TEC students embark on an unprecedented rescue mission.\r\n\r\n(Source: Aniplex USA)",
"show_type": "TV",
"started_airing": "2015-07-04",
"finished_airing": null,
"community_rating": 3.3777667556786,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Sci-Fi"
},
{
"name": "Romance"
},
{
"name": "Slice of Life"
},
{
"name": "School"
}
]
},
"rating": {
"type": "advanced",
"value": "4.5"
}
},
{
"id": 10177168,
"episodes_watched": 11,
"last_watched": "2015-09-14T01:01:35.226Z",
"updated_at": "2015-09-14T01:01:35.227Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10103,
"mal_id": 28999,
"slug": "charlotte",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/charlotte",
"title": "Charlotte",
"alternate_title": "",
"episode_count": 13,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/103/large/charlotte.jpg?1434903194",
"synopsis": "In a world where children have a chance to develop special powers upon reaching puberty. Otosaka Yuu is one such child, choosing to live a relatively normal, satisfying life despite possessing an ability to control others bodies for a short period of time. One day, Yuu is suddenly approached by Tomori Nao, another child with special powers. Their meeting sets the stage for a story about growth, their many experiences, and a cruel fate that links the two. A routine life changes to one filled with the unexpected, a promise to return home becoming their only guide down an uncertain road. \r\n\r\n(Source: Aniplex of America)",
"show_type": "TV",
"started_airing": "2015-07-05",
"finished_airing": "2015-09-27",
"community_rating": 4.0201120371147,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Drama"
},
{
"name": "Super Power"
},
{
"name": "School"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 9131652,
"episodes_watched": 12,
"last_watched": "2015-09-12T20:06:39.355Z",
"updated_at": "2015-09-12T20:06:39.355Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 8712,
"mal_id": 25879,
"slug": "working-3",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/working-3",
"title": "Working!!!",
"alternate_title": "Wagnaria!!!",
"episode_count": 13,
"episode_length": 23,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/008/712/large/Working-Saison-3-Visual-Art.jpg?1427928008",
"synopsis": "The third season of the Working!! series.",
"show_type": "TV",
"started_airing": "2015-07-05",
"finished_airing": null,
"community_rating": 4.2499808378722,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Slice of Life"
}
]
},
"rating": {
"type": "advanced",
"value": "4.5"
}
},
{
"id": 9719799,
"episodes_watched": 23,
"last_watched": "2015-09-12T13:02:56.219Z",
"updated_at": "2015-09-12T13:02:56.220Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 10016,
"mal_id": 28297,
"slug": "ore-monogatari",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/ore-monogatari",
"title": "Ore Monogatari!!",
"alternate_title": "My Love Story!!",
"episode_count": 24,
"episode_length": 22,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/010/016/large/98d170b9e221550a05ea0309462510041423372549_full.jpg?1429885511",
"synopsis": "Gouda Takeo is a freshman in high school. (Both estimates) Weight: 120kg, Height: 2 meters. He spends his days peacefully with his super-popular-with-girls, yet insensitive childhood friend, Sunakawa. One morning, on the train to school, Takeo saves a girl, Yamato, from being molested by a pervert. Could this be the beginning of spring for Takeo?\n\n(Source: MU)",
"show_type": "TV",
"started_airing": "2015-04-09",
"finished_airing": "2015-09-24",
"community_rating": 4.2192057339959,
"age_rating": "PG13",
"genres": [
{
"name": "Comedy"
},
{
"name": "Romance"
}
]
},
"rating": {
"type": "advanced",
"value": "4.0"
}
},
{
"id": 9131608,
"episodes_watched": 24,
"last_watched": "2015-09-13T11:36:06.608Z",
"updated_at": "2015-09-13T11:36:06.609Z",
"rewatched_times": 0,
"notes": null,
"notes_present": null,
"status": "currently-watching",
"private": false,
"rewatching": false,
"anime": {
"id": 9142,
"mal_id": 27663,
"slug": "baby-steps-2",
"status": "Currently Airing",
"url": "https://hummingbird.me/anime/baby-steps-2",
"title": "Baby Steps 2nd Season",
"alternate_title": "",
"episode_count": 0,
"episode_length": 24,
"cover_image": "https://static.hummingbird.me/anime/poster_images/000/009/142/large/0WCindC.jpg?1428540276",
"synopsis": "Season 2 of Baby Steps. ",
"show_type": "TV",
"started_airing": "2015-04-05",
"finished_airing": null,
"community_rating": 4.2037554731763,
"age_rating": "PG13",
"genres": [
{
"name": "Sports"
},
{
"name": "Romance"
},
{
"name": "School"
}
]
},
"rating": {
"type": "advanced",
"value": "4.5"
}
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff