feat: add anime dates & mod notes (#1213)

This commit is contained in:
Kyrch
2026-07-09 14:04:08 -03:00
committed by GitHub
parent 19b340bf75
commit f036cd2d1b
22 changed files with 648 additions and 52 deletions
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Actions\Models\Wiki\Anime;
use App\Actions\ActionResult;
use App\Enums\Actions\ActionStatus;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
class AnimeDateAction
{
/**
* @param Collection<int, Anime> $anime
*/
public function handle(Collection $anime): ActionResult
{
$anilistIds = $anime
->map(fn (Anime $anime) => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id)
->filter()
->unique()
->values()
->all();
$query = <<<'GRAPHQL'
query AnimeDates($ids: [Int]) {
Page(perPage: 20) {
media(id_in: $ids, type: ANIME) {
id
startDate {
year
month
day
}
endDate {
year
month
day
}
}
}
}
GRAPHQL;
$response = Http::post('https://graphql.anilist.co', [
'query' => $query,
'variables' => [
'ids' => $anilistIds,
],
])
->throw();
if (! $response->ok() || $response->json('errors')) {
return new ActionResult(ActionStatus::FAILED, $response->json('errors.0.message'));
}
foreach ($response->json('data.Page.media') as $media) {
$animeToUpdate = $anime->first(
fn (Anime $anime): bool => $anime->resources->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value)->first()?->external_id === Arr::integer($media, 'id')
);
$animeToUpdate?->update([
'start_date' => Arr::get($media, 'startDate'),
'end_date' => Arr::get($media, 'endDate'),
]);
}
return new ActionResult(ActionStatus::PASSED);
}
}
@@ -196,11 +196,9 @@ class BackfillAudioAction extends BackfillAction
$orderByNameQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_NAME]);
$orderBySeasonQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_SEASON]);
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_YEAR]);
$orderByformatQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_FORMAT]);
$orderByYearQuery = $sortRelation->getRelationExistenceQuery($sortRelation->getQuery(), $builder, [Anime::ATTRIBUTE_START_DATE]);
return $builder->whereHas(AnimeTheme::RELATION_VIDEOS, fn (Builder $relationBuilder) => $relationBuilder->whereKey($this->getModel()))
->orderBy($orderByformatQuery->toBase())
->orderBy($orderByYearQuery->toBase())
->orderBy($orderBySeasonQuery->toBase())
->orderBy($orderByNameQuery->toBase())
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Casts;
use App\ValueObjects\FuzzyDate;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use InvalidArgumentException;
class AsFuzzyDate implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ?FuzzyDate
{
return FuzzyDate::fromString($value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (blank($value) || $value === '00000000') {
return null;
}
if ($value instanceof FuzzyDate) {
return $value->toString();
}
if (is_string($value)) {
return FuzzyDate::fromString($value)?->toString();
}
if (is_array($value)) {
return new FuzzyDate(
(int) Arr::get($value, 'year') ?: null,
(int) Arr::get($value, 'month') ?: null,
(int) Arr::get($value, 'day') ?: null,
)->toString();
}
throw new InvalidArgumentException('Invalid FuzzyDate value.');
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Models;
use App\Actions\ActionResult;
use App\Actions\Models\Wiki\Anime\AnimeDateAction;
use App\Console\Commands\BaseCommand;
use App\Enums\Models\Wiki\ResourceSite;
use App\Models\Wiki\Anime;
use App\Models\Wiki\ExternalResource;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Validator as ValidatorFacade;
use Illuminate\Support\Sleep;
#[Signature('anime:update-dates')]
#[Description('Updates anime dates')]
class UpdateAnimeDateCommand extends BaseCommand
{
public function handle(): int
{
$failed = false;
Anime::query()
->where(function (Builder $query): void {
$query
->whereNull(Anime::ATTRIBUTE_START_DATE)
->orWhereRaw('RIGHT('.Anime::ATTRIBUTE_START_DATE.', 2) = ?', ['00'])
->orWhereNull(Anime::ATTRIBUTE_END_DATE)
->orWhereRaw('RIGHT('.Anime::ATTRIBUTE_END_DATE.', 2) = ?', ['00']);
})
->with([
Anime::RELATION_RESOURCES => fn (Relation $query) => $query->where(ExternalResource::ATTRIBUTE_SITE, ResourceSite::ANILIST->value),
])
->orderBy(Anime::ATTRIBUTE_ID)
->chunk(20, function (Collection $anime) use (&$failed) {
$ids = $anime->pluck(Anime::ATTRIBUTE_ID)->values()->implode(', ');
$this->info('Anime IDs: '.$ids);
$action = new AnimeDateAction();
$result = Anime::withoutEvents(fn () => Anime::withoutTimestamps(fn (): ActionResult => $action->handle($anime)));
if ($result->hasFailed()) {
$this->error('Action failed: '.$result->getMessage());
$failed = true;
return false;
}
Sleep::sleep(5);
});
return $failed ? 1 : 0;
}
protected function validator(): Validator
{
return ValidatorFacade::make($this->options(), []);
}
}
+7
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Console;
use App\Console\Commands\Models\SyncLikeAggregatesCommand;
use App\Console\Commands\Models\UpdateAnimeDateCommand;
use App\Console\Commands\Repositories\Storage\Admin\DumpReconcileCommand;
use App\Console\Commands\Storage\Admin\AdminDumpCommand;
use App\Console\Commands\Storage\Admin\AuthDumpCommand;
@@ -144,6 +145,12 @@ class Kernel extends ConsoleKernel
->storeOutput()
->daily();
$schedule->command(UpdateAnimeDateCommand::class)
->withoutOverlapping()
->runInBackground()
->storeOutput()
->dailyAt('23:30');
$schedule->command(UpdateDisposableDomainsCommand::class)
->withoutOverlapping()
->runInBackground()
@@ -9,10 +9,12 @@ use App\Enums\Models\Wiki\AnimeFormat;
use App\Enums\Models\Wiki\AnimeSeason;
use App\GraphQL\Filter\EnumFilter;
use App\GraphQL\Filter\Filter;
use App\GraphQL\Filter\FuzzyDateFilter;
use App\GraphQL\Filter\IntFilter;
use App\GraphQL\Filter\StringFilter;
use App\GraphQL\Filter\TimestampFilter;
use App\Models\Wiki\Anime;
use GraphQL\Type\Definition\Deprecated;
enum AnimeFilterableColumns implements EnumFilterableColumns
{
@@ -20,7 +22,10 @@ enum AnimeFilterableColumns implements EnumFilterableColumns
case NAME;
case FORMAT;
case SEASON;
case START_DATE;
case END_DATE;
case SYNOPSIS;
#[Deprecated('Use the `START_DATE` filter instead.')]
case YEAR;
case CREATED_AT;
case UPDATED_AT;
@@ -32,6 +37,8 @@ enum AnimeFilterableColumns implements EnumFilterableColumns
self::NAME => new StringFilter($this->name, Anime::ATTRIBUTE_NAME),
self::FORMAT => new EnumFilter($this->name, AnimeFormat::class, Anime::ATTRIBUTE_FORMAT),
self::SEASON => new EnumFilter($this->name, AnimeSeason::class, Anime::ATTRIBUTE_SEASON),
self::START_DATE => new FuzzyDateFilter($this->name, Anime::ATTRIBUTE_START_DATE),
self::END_DATE => new FuzzyDateFilter($this->name, Anime::ATTRIBUTE_END_DATE),
self::YEAR => new IntFilter($this->name, Anime::ATTRIBUTE_YEAR),
self::SYNOPSIS => new StringFilter($this->name, Anime::ATTRIBUTE_SYNOPSIS),
self::CREATED_AT => new TimestampFilter($this->name, Anime::ATTRIBUTE_CREATED_AT),
+11
View File
@@ -10,6 +10,7 @@ use App\GraphQL\Sort\FieldSortCriteria;
use App\GraphQL\Sort\RandomSortCriteria;
use App\GraphQL\Sort\SortCriteria;
use App\Models\Wiki\Anime;
use GraphQL\Type\Definition\Deprecated;
enum AnimeSort implements EnumSort
{
@@ -17,7 +18,13 @@ enum AnimeSort implements EnumSort
case ID_DESC;
case NAME;
case NAME_DESC;
case START_DATE;
case START_DATE_DESC;
case END_DATE;
case END_DATE_DESC;
#[Deprecated('Use the `START_DATE` sort instead.')]
case YEAR;
#[Deprecated('Use the `START_DATE_DESC` sort instead.')]
case YEAR_DESC;
case CREATED_AT;
case CREATED_AT_DESC;
@@ -34,6 +41,10 @@ enum AnimeSort implements EnumSort
self::NAME_DESC => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_NAME, SortDirection::DESC),
self::YEAR => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_YEAR),
self::YEAR_DESC => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_YEAR, SortDirection::DESC),
self::START_DATE => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_START_DATE),
self::START_DATE_DESC => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_START_DATE, SortDirection::DESC),
self::END_DATE => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_END_DATE),
self::END_DATE_DESC => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_END_DATE, SortDirection::DESC),
self::CREATED_AT => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_CREATED_AT),
self::CREATED_AT_DESC => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_CREATED_AT, SortDirection::DESC),
self::UPDATED_AT => new FieldSortCriteria($this->name, Anime::ATTRIBUTE_UPDATED_AT),
+109 -15
View File
@@ -27,10 +27,12 @@ use App\Filament\Resources\Wiki\Anime\RelationManagers\SynonymAnimeRelationManag
use App\Filament\Resources\Wiki\Anime\RelationManagers\ThemeAnimeRelationManager;
use App\Models\Wiki\Anime;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Textarea;
use Filament\QueryBuilder\Constraints\NumberConstraint;
use Filament\QueryBuilder\Constraints\SelectConstraint;
use Filament\QueryBuilder\Constraints\TextConstraint;
use Filament\Resources\RelationManagers\RelationGroup;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
@@ -99,16 +101,6 @@ class AnimeResource extends BaseResource
->label(__('filament.fields.anime.slug.name'))
->helperText(__('filament.fields.anime.slug.help')),
TextInput::make(Anime::ATTRIBUTE_YEAR)
->label(__('filament.fields.anime.year.name'))
->helperText(__('filament.fields.anime.year.help'))
->required()
->integer()
->length(4)
->default(date('Y'))
->minValue(1960)
->maxValue(intval(date('Y')) + 1),
Select::make(Anime::ATTRIBUTE_SEASON)
->label(__('filament.fields.anime.season.name'))
->helperText(__('filament.fields.anime.season.help'))
@@ -125,11 +117,74 @@ class AnimeResource extends BaseResource
->options(AnimeFormat::class)
->required(),
Fieldset::make(Anime::ATTRIBUTE_START_DATE)
->label(__('filament.fields.anime.start_date.name'))
->columns([
'xl' => 3,
])
->statePath('start_date')
->formatStateUsing(fn (?Anime $record) => $record?->start_date?->toArray())
->schema([
TextInput::make('year')
->label(__('filament.fields.base.year'))
->required()
->integer()
->default(intval(date('Y')))
->minValue(1960)
->maxValue(intval(date('Y')) + 4),
TextInput::make('month')
->label(__('filament.fields.base.month'))
->integer()
->minValue(1)
->maxValue(12),
TextInput::make('day')
->label(__('filament.fields.base.day'))
->integer()
->minValue(1)
->maxValue(31),
]),
Fieldset::make(Anime::ATTRIBUTE_END_DATE)
->label(__('filament.fields.anime.end_date.name'))
->columns([
'xl' => 3,
])
->statePath('end_date')
->formatStateUsing(fn (?Anime $record) => $record->end_date?->toArray())
->schema([
TextInput::make('year')
->label(__('filament.fields.base.year'))
->integer()
->length(4)
->minValue(1960)
->maxValue(intval(date('Y')) + 4),
TextInput::make('month')
->label(__('filament.fields.base.month'))
->integer()
->minValue(1)
->maxValue(12),
TextInput::make('day')
->label(__('filament.fields.base.day'))
->integer()
->minValue(1)
->maxValue(31),
]),
MarkdownEditor::make(Anime::ATTRIBUTE_SYNOPSIS)
->label(__('filament.fields.anime.synopsis.name'))
->helperText(__('filament.fields.anime.synopsis.help'))
->columnSpan(2)
->maxLength(65535),
Textarea::make(Anime::ATTRIBUTE_MOD_NOTES)
->label(__('filament.fields.anime.mod_notes.name'))
->helperText(__('filament.fields.anime.mod_notes.help'))
->columnSpan(2)
->maxLength(65535),
])
->columns(2);
}
@@ -152,8 +207,8 @@ class AnimeResource extends BaseResource
->limit(20)
->tooltip(fn (string $state): string => $state),
TextColumn::make(Anime::ATTRIBUTE_YEAR)
->label(__('filament.fields.anime.year.name')),
TextColumn::make('year')
->label(__('filament.fields.base.year')),
TextColumn::make(Anime::ATTRIBUTE_SEASON)
->label(__('filament.fields.anime.season.name'))
@@ -184,9 +239,6 @@ class AnimeResource extends BaseResource
->label(__('filament.fields.anime.slug.name'))
->limit(60),
TextEntry::make(Anime::ATTRIBUTE_YEAR)
->label(__('filament.fields.anime.year.name')),
TextEntry::make(Anime::ATTRIBUTE_SEASON)
->label(__('filament.fields.anime.season.name'))
->formatStateUsing(fn (AnimeSeason $state): string => $state->localizeStyled())
@@ -196,10 +248,52 @@ class AnimeResource extends BaseResource
->label(__('filament.fields.anime.format.name'))
->formatStateUsing(fn (AnimeFormat $state): ?string => $state->localize()),
Fieldset::make(Anime::ATTRIBUTE_START_DATE)
->label(__('filament.fields.anime.start_date.name'))
->columns([
'xl' => 3,
])
->columnSpanFull()
->statePath('start_date')
->formatStateUsing(fn (?Anime $record) => $record?->start_date?->toArray())
->schema([
TextEntry::make('year')
->label(__('filament.fields.base.year')),
TextEntry::make('month')
->label(__('filament.fields.base.month')),
TextEntry::make('day')
->label(__('filament.fields.base.day')),
]),
Fieldset::make(Anime::ATTRIBUTE_END_DATE)
->label(__('filament.fields.anime.end_date.name'))
->columns([
'xl' => 3,
])
->columnSpanFull()
->statePath('end_date')
->formatStateUsing(fn (?Anime $record) => $record->end_date?->toArray())
->schema([
TextEntry::make('year')
->label(__('filament.fields.base.year')),
TextEntry::make('month')
->label(__('filament.fields.base.month')),
TextEntry::make('day')
->label(__('filament.fields.base.day')),
]),
TextEntry::make(Anime::ATTRIBUTE_SYNOPSIS)
->label(__('filament.fields.anime.synopsis.name'))
->markdown()
->columnSpanFull(),
TextEntry::make(Anime::ATTRIBUTE_MOD_NOTES)
->label(__('filament.fields.anime.mod_notes.name'))
->columnSpanFull(),
])
->columns(3),
+34
View File
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\GraphQL\Filter;
use Illuminate\Support\Arr;
class FuzzyDateFilter extends Filter
{
/**
* Convert filter values if needed.
*/
protected function convertFilterValues(array $filterValues): array
{
return Arr::map(
$filterValues,
fn (string|int $filterValue): ?int => filter_var($filterValue, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE),
);
}
/**
* Get the validation rules for the filter.
*/
protected function getRules(): array
{
return [
'required',
'integer',
'min:0',
'max:99991231',
];
}
}
+22 -12
View File
@@ -20,19 +20,28 @@ class AnimeYearsQuery
/** @param array{} $args */
public function __invoke(null $_, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): Collection
{
$year = Arr::get($args, 'year');
$years = Arr::wrap(Arr::get($args, 'year'));
$fieldSelection = $resolveInfo->getFieldSelection(1);
Validator::make(['year' => $year], ['year' => new AnimeYearRule($fieldSelection)])
Validator::make(['year' => $years], ['year' => new AnimeYearRule($fieldSelection)])
->validate();
return Anime::query()
->distinct([Anime::ATTRIBUTE_YEAR, Anime::ATTRIBUTE_SEASON])
->orderBy(Anime::ATTRIBUTE_YEAR)
->when($year !== null, fn (Builder $query) => $query->whereIn(Anime::ATTRIBUTE_YEAR, $year))
->get([Anime::ATTRIBUTE_YEAR, Anime::ATTRIBUTE_SEASON])
->groupBy(Anime::ATTRIBUTE_YEAR)
->whereNotNull(Anime::ATTRIBUTE_START_DATE)
->whereNotNull(Anime::ATTRIBUTE_SEASON)
->when($years !== null, function (Builder $query) use ($years): void {
$query->where(function (Builder $query) use ($years): void {
foreach ($years as $singleYear) {
$query->orWhereBetween(Anime::ATTRIBUTE_START_DATE, [
"{$singleYear}0000",
"{$singleYear}1231",
]);
}
});
})
->get([Anime::ATTRIBUTE_START_DATE, Anime::ATTRIBUTE_SEASON])
->groupBy(fn (Anime $anime): ?int => $anime->start_date?->year)
->map(fn (Collection $items, int $year): array => [
'year' => $year,
@@ -40,19 +49,20 @@ class AnimeYearsQuery
->map(fn (Anime $anime): array => [
'season' => $anime->season,
'seasonLocalized' => $anime->season->localize(),
'year' => $year, // Needed to query animes on the 'seasons' field.
'year' => $year,
])
->unique(Anime::ATTRIBUTE_SEASON)
->unique('season')
->values()
->toArray(),
])->values();
])
->values();
}
/** @param array{} $args */
public function resolveSeasonField(array $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): ?array
{
$season = Arr::get($args, 'season');
$year = Arr::get($root, 'year');
$year = Arr::integer($root, 'year');
$seasons = collect(Arr::get($root, 'seasons'));
@@ -71,7 +81,7 @@ class AnimeYearsQuery
public function resolveAnimeField(array $root, array $args, GraphQLContext $context, ResolveInfo $resolveInfo): Paginator
{
$season = Arr::get($root, 'season');
$year = Arr::get($root, 'year');
$year = Arr::integer($root, 'year');
$builder = Anime::query()
// season filter applies only on the 'season' field.
+51
View File
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\GraphQL\Scalars;
use App\ValueObjects\FuzzyDate;
use GraphQL\Error\Error;
use GraphQL\Language\AST\IntValueNode;
use GraphQL\Language\AST\Node;
use GraphQL\Language\AST\StringValueNode;
use GraphQL\Type\Definition\ScalarType;
class FuzzyDateInt extends ScalarType
{
/**
* Serializes an internal value to include in a response.
*/
public function serialize(mixed $value): ?int
{
return $this->normalize($value);
}
/**
* Parses an externally provided value from query variables.
*/
public function parseValue(mixed $value): ?int
{
return $this->normalize($value);
}
/**
* Parses an externally provided literal value hardcoded in the GraphQL query.
*
* @param \GraphQL\Language\AST\ValueNode&Node $valueNode
* @param array<string, mixed>|null $variables
*/
public function parseLiteral(Node $valueNode, ?array $variables = null): ?int
{
if (! $valueNode instanceof IntValueNode && ! $valueNode instanceof StringValueNode) {
throw new Error("FuzzyDateInt cannot represent non-integer value: {$valueNode->kind}", $valueNode);
}
return $this->normalize($valueNode->value);
}
private function normalize(mixed $value): int
{
return (int) FuzzyDate::fromString(str_pad((string) $value, 8, '0', STR_PAD_RIGHT))->toString();
}
}
+25 -1
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models\Wiki;
use App\Casts\AsFuzzyDate;
use App\Concerns\Models\SoftDeletes;
use App\Concerns\Models\Submitable;
use App\Contracts\Models\HasImages;
@@ -22,17 +23,21 @@ use App\Http\Resources\Pivot\Wiki\Resource\AnimeStudioJsonResource;
use App\Models\BaseModel;
use App\Models\List\External\ExternalEntry;
use App\Models\Wiki\Anime\AnimeTheme;
use App\Observers\Wiki\AnimeObserver;
use App\Pivots\Morph\Imageable;
use App\Pivots\Morph\Resourceable;
use App\Pivots\Wiki\AnimeSeries;
use App\Pivots\Wiki\AnimeStudio;
use App\Scout\Elasticsearch\Models\Wiki\AnimeElasticModel;
use App\Scout\Typesense\Models\Wiki\AnimeTypesenseModel;
use App\ValueObjects\FuzzyDate;
use Database\Factories\Wiki\AnimeFactory;
use Deprecated;
use Elastic\ScoutDriverPlus\Searchable;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -47,14 +52,17 @@ use RuntimeException;
/**
* @property int $anime_id
* @property Collection<int, AnimeTheme> $animethemes
* @property FuzzyDate|null $end_date
* @property Collection<int, ExternalEntry> $externalentries
* @property AnimeFormat|null $format
* @property Collection<int, Image> $images
* @property string|null $mod_notes
* @property string $name
* @property Collection<int, ExternalResource> $resources
* @property AnimeSeason|null $season
* @property Collection<int, Series> $series
* @property string $slug
* @property FuzzyDate|null $start_date
* @property Collection<int, Studio> $studios
* @property Collection<int, Synonym> $synonyms
* @property string|null $synopsis
@@ -62,6 +70,7 @@ use RuntimeException;
*
* @method static AnimeFactory factory(...$parameters)
*/
#[ObservedBy(AnimeObserver::class)]
#[Table(Anime::TABLE, Anime::ATTRIBUTE_ID)]
class Anime extends BaseModel implements Auditable, HasImages, HasResources, HasSynonyms, SoftDeletable
{
@@ -74,10 +83,13 @@ class Anime extends BaseModel implements Auditable, HasImages, HasResources, Has
final public const string TABLE = 'anime';
final public const string ATTRIBUTE_ID = 'anime_id';
final public const string ATTRIBUTE_END_DATE = 'end_date';
final public const string ATTRIBUTE_FORMAT = 'format';
final public const string ATTRIBUTE_MOD_NOTES = 'mod_notes';
final public const string ATTRIBUTE_NAME = 'name';
final public const string ATTRIBUTE_SEASON = 'season';
final public const string ATTRIBUTE_SLUG = 'slug';
final public const string ATTRIBUTE_START_DATE = 'start_date';
final public const string ATTRIBUTE_SYNOPSIS = 'synopsis';
final public const string ATTRIBUTE_YEAR = 'year';
@@ -121,13 +133,15 @@ class Anime extends BaseModel implements Auditable, HasImages, HasResources, Has
* @var list<string>
*/
protected $fillable = [
Anime::ATTRIBUTE_END_DATE,
Anime::ATTRIBUTE_FORMAT,
Anime::ATTRIBUTE_MOD_NOTES,
Anime::ATTRIBUTE_NAME,
Anime::ATTRIBUTE_SEASON,
Anime::ATTRIBUTE_SLUG,
Anime::ATTRIBUTE_START_DATE,
Anime::ATTRIBUTE_SYNOPSIS,
Anime::ATTRIBUTE_YEAR,
'media_format',
];
/**
@@ -138,15 +152,25 @@ class Anime extends BaseModel implements Auditable, HasImages, HasResources, Has
protected function casts(): array
{
return [
Anime::ATTRIBUTE_END_DATE => AsFuzzyDate::class,
Anime::ATTRIBUTE_FORMAT => AnimeFormat::class,
Anime::ATTRIBUTE_MOD_NOTES => 'string',
Anime::ATTRIBUTE_NAME => 'string',
Anime::ATTRIBUTE_SEASON => AnimeSeason::class,
Anime::ATTRIBUTE_SLUG => 'string',
Anime::ATTRIBUTE_START_DATE => AsFuzzyDate::class,
Anime::ATTRIBUTE_SYNOPSIS => 'string',
Anime::ATTRIBUTE_YEAR => 'int',
];
}
protected function year(): Attribute
{
return Attribute::make(
get: fn ($value) => $this->start_date->year ?? $value,
);
}
/**
* Modify the query used to retrieve models when making all of the models searchable.
*/
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Observers\Wiki;
use App\Models\Wiki\Anime;
class AnimeObserver
{
/**
* Handle the Anime "creating" event.
*/
public function creating(Anime $anime): void
{
$anime->year = $anime->start_date?->year;
}
/**
* Handle the Anime "updating" event.
*/
public function updating(Anime $anime): void
{
$anime->year = $anime->start_date?->year;
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ class Typesense extends Search
): Collection|Paginator {
$model = $schema->model();
/** @var ScoutBuilder $builder */
/** @var \Laravel\Scout\Builder $builder */
/** @phpstan-ignore-next-line */
$builder = $model::search($this->criteria->getTerm());
$scope = ScopeParser::parse($schema->type());
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\ValueObjects;
use InvalidArgumentException;
class FuzzyDate
{
public function __construct(
public ?int $year = null,
public ?int $month = null,
public ?int $day = null,
) {
$this->validate();
}
public static function fromString(?string $value): ?FuzzyDate
{
if (blank($value) || $value === '00000000') {
return null;
}
throw_unless(preg_match('/^\d{8}$/', $value), InvalidArgumentException::class, 'FuzzyDate must be an 8-digit string.');
return new FuzzyDate(
(int) substr($value, 0, 4) ?: null,
(int) substr($value, 4, 2) ?: null,
(int) substr($value, 6, 2) ?: null,
);
}
public function toString(): string
{
return str_pad((string) ($this->year ?? 0), 4, '0', STR_PAD_LEFT)
.str_pad((string) ($this->month ?? 0), 2, '0', STR_PAD_LEFT)
.str_pad((string) ($this->day ?? 0), 2, '0', STR_PAD_LEFT);
}
public function toArray(): array
{
return [
'year' => $this->year,
'month' => $this->month,
'day' => $this->day,
];
}
private function validate(): void
{
throw_if($this->year !== null && ($this->year < 1 || $this->year > 9999), InvalidArgumentException::class, 'Invalid year.');
throw_if($this->month !== null && ($this->month < 1 || $this->month > 12), InvalidArgumentException::class, 'Invalid month.');
throw_if($this->day !== null && ($this->day < 1 || $this->day > 31), InvalidArgumentException::class, 'Invalid day.');
throw_if($this->day !== null && $this->month === null, InvalidArgumentException::class, 'Month is required when day is present.');
throw_if($this->month !== null && $this->year === null, InvalidArgumentException::class, 'Year is required when month is present.');
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasColumns('anime', ['start_date', 'end_date'])) {
Schema::table('anime', function (Blueprint $table) {
$table->char('start_date', 8)->nullable()->after('year');
$table->char('end_date', 8)->nullable()->after('start_date');
$table->text('mod_notes')->nullable()->after('synopsis');
});
}
}
};
+16 -4
View File
@@ -29,7 +29,13 @@ type Anime @model(class: "App\\Models\\Wiki\\Anime") {
synopsis: String
"The premiere year of the anime"
year: Int
year: Int @deprecated(reason: "Use the `startDate.year` field instead.")
"The premiere date of the anime, with a fuzzy date representation"
startDate: FuzzyDate @rename(attribute: "start_date")
"The end date of the anime, with a fuzzy date representation"
endDate: FuzzyDate @rename(attribute: "end_date")
"The URL for the anime page on the website"
siteUrl: String! @field(resolver: "App\\GraphQL\\Types\\AnimeType@resolveSiteUrlAttribute")
@@ -128,9 +134,15 @@ extend type Query {
season: AnimeSeason @eq
season_in: [AnimeSeason!] @in(key: "season")
slug: String @eq
year: Int @eq
year_lesser: Int @where(operator: "<", key: "year")
year_greater: Int @where(operator: ">", key: "year")
year: Int @eq @deprecated(reason: "Use the `startDate` filter instead.")
year_lesser: Int @where(operator: "<", key: "year") @deprecated(reason: "Use the `startDate_lesser` filter instead.")
year_greater: Int @where(operator: ">", key: "year") @deprecated(reason: "Use the `startDate_greater` filter instead.")
startDate: FuzzyDateInt @eq(key: "start_date")
startDate_lesser: FuzzyDateInt @where(operator: "<", key: "start_date")
startDate_greater: FuzzyDateInt @where(operator: ">", key: "start_date")
endDate: FuzzyDateInt @eq(key: "end_date")
endDate_lesser: FuzzyDateInt @where(operator: "<", key: "end_date")
endDate_greater: FuzzyDateInt @where(operator: ">", key: "end_date")
where: _ @whereConditions(columnsEnum: AnimeFilterableColumns, handler: "App\\GraphQL\\WhereConditions\\WhereConditionsHandler")
sort: [AnimeSort!] @sort
): [Anime!]! @paginate
+12 -6
View File
@@ -25,9 +25,12 @@ type AnimeYearSeasons {
format_in: [AnimeFormat!] @in(key: "format")
season: AnimeSeason @eq
season_in: [AnimeSeason!] @in(key: "season")
year: Int @eq
year_lesser: Int @where(operator: "<", key: "year")
year_greater: Int @where(operator: ">", key: "year")
year: Int @eq @deprecated(reason: "Use the `startDate` filter instead.")
year_lesser: Int @where(operator: "<", key: "year") @deprecated(reason: "Use the `startDate_lesser` filter instead.")
year_greater: Int @where(operator: ">", key: "year") @deprecated(reason: "Use the `startDate_greater` filter instead.")
startDate: FuzzyDateInt @eq(key: "start_date")
startDate_lesser: FuzzyDateInt @where(operator: "<", key: "start_date")
startDate_greater: FuzzyDateInt @where(operator: ">", key: "start_date")
where: _ @whereConditions(columnsEnum: AnimeFilterableColumns, handler: "App\\GraphQL\\WhereConditions\\WhereConditionsHandler")
sort: [AnimeSort!] @sort
first: Int
@@ -50,9 +53,12 @@ type AnimeYearSeason {
format_in: [AnimeFormat!] @in(key: "format")
season: AnimeSeason @eq
season_in: [AnimeSeason!] @in(key: "season")
year: Int @eq
year_lesser: Int @where(operator: "<", key: "year")
year_greater: Int @where(operator: ">", key: "year")
year: Int @eq @deprecated(reason: "Use the `startDate` filter instead.")
year_lesser: Int @where(operator: "<", key: "year") @deprecated(reason: "Use the `startDate_lesser` filter instead.")
year_greater: Int @where(operator: ">", key: "year") @deprecated(reason: "Use the `startDate_greater` filter instead.")
startDate: FuzzyDateInt @eq(key: "start_date")
startDate_lesser: FuzzyDateInt @where(operator: "<", key: "start_date")
startDate_greater: FuzzyDateInt @where(operator: ">", key: "start_date")
sort: [AnimeSort!] @sort
first: Int
page: Int = 1
+6 -3
View File
@@ -28,9 +28,12 @@ type Series @model(class: "App\\Models\\Wiki\\Series") {
format_in: [AnimeFormat!] @in(key: "format")
season: AnimeSeason @eq
season_in: [AnimeSeason!] @in(key: "season")
year: Int @eq
year_lesser: Int @where(operator: "<", key: "year")
year_greater: Int @where(operator: ">", key: "year")
year: Int @eq @deprecated(reason: "Use the `startDate` filter instead.")
year_lesser: Int @where(operator: "<", key: "year") @deprecated(reason: "Use the `startDate_lesser` filter instead.")
year_greater: Int @where(operator: ">", key: "year") @deprecated(reason: "Use the `startDate_greater` filter instead.")
startDate: FuzzyDateInt @eq(key: "start_date")
startDate_lesser: FuzzyDateInt @where(operator: "<", key: "start_date")
startDate_greater: FuzzyDateInt @where(operator: ">", key: "start_date")
where: _ @whereConditions(columnsEnum: AnimeFilterableColumns, handler: "App\\GraphQL\\WhereConditions\\WhereConditionsHandler")
sort: [AnimeSort!] @sort
): [Anime!]! @belongsToManyCustom(type: CONNECTION, edgeType: "SeriesAnimeEdge")
+6 -3
View File
@@ -28,9 +28,12 @@ type Studio @model(class: "App\\Models\\Wiki\\Studio") {
format_in: [AnimeFormat!] @in(key: "format")
season: AnimeSeason @eq
season_in: [AnimeSeason!] @in(key: "season")
year: Int @eq
year_lesser: Int @where(operator: "<", key: "year")
year_greater: Int @where(operator: ">", key: "year")
year: Int @eq @deprecated(reason: "Use the `startDate` filter instead.")
year_lesser: Int @where(operator: "<", key: "year") @deprecated(reason: "Use the `startDate_lesser` filter instead.")
year_greater: Int @where(operator: ">", key: "year") @deprecated(reason: "Use the `startDate_greater` filter instead.")
startDate: FuzzyDateInt @eq(key: "start_date")
startDate_lesser: FuzzyDateInt @where(operator: "<", key: "start_date")
startDate_greater: FuzzyDateInt @where(operator: ">", key: "start_date")
where: _ @whereConditions(columnsEnum: AnimeFilterableColumns, handler: "App\\GraphQL\\WhereConditions\\WhereConditionsHandler")
sort: [AnimeSort!] @sort
): [Anime!]! @belongsToManyCustom(type: CONNECTION, edgeType: "StudioAnimeEdge")
+21
View File
@@ -22,11 +22,32 @@ dealing with really large numbers to be on the safe side.
"""
scalar Mixed @scalar(class: "MLL\\GraphQLScalars\\MixedScalar")
"""
A partial date encoded as an integer in YYYYMMDD format.
Unknown parts are represented with 00:
- 20260709 = July 9, 2026
- 20260700 = July 2026
- 20260000 = 2026
Null means the date is unknown.
"""
scalar FuzzyDateInt @scalar(class: "App\\GraphQL\\Scalars\\FuzzyDateInt")
"Represents a response containing a message."
type MessageResponse {
message: String!
}
"""
A fuzzy date in YYYYMMDD format.
"""
type FuzzyDate {
year: Int
month: Int
day: Int
}
type Mutation @guard {
ToggleLike(
entryId: Int
+19 -4
View File
@@ -568,6 +568,18 @@ return [
],
],
'anime' => [
'end_date' => [
'help' => 'The date in which the Anime finished airing.',
'name' => 'End Date',
],
'format' => [
'help' => 'The Format of the Anime. By default, we will use the Type Field on the MAL page.',
'name' => 'Format',
],
'mod_notes' => [
'help' => 'Any additional information not included in other fields that may be useful for moderators.',
'name' => 'Moderator Notes',
],
'name' => [
'help' => 'The display title of the Anime. By default, we will use the same title as MAL. Ex: "Bakemonogatari", "Code Geass: Hangyaku no Lelouch", "Dungeon ni Deai wo Motomeru no wa Machigatteiru Darou ka".',
'name' => 'Name',
@@ -580,6 +592,10 @@ return [
'help' => 'Used as the URL Slug / Model Route Key. By default, this should be the Name lowercased and "_" replacing spaces. Shortenings/Abbreviations are also accepted. Ex: "monogatari", "code_geass", "danmachi".',
'name' => 'Slug',
],
'start_date' => [
'help' => 'The date in which the Anime premiered.',
'name' => 'Start Date',
],
'synopsis' => [
'help' => 'The brief description of the Anime.',
'name' => 'Synopsis',
@@ -588,10 +604,6 @@ return [
'help' => 'The Year in which the Anime premiered. By default, we will use the Premiered Field on the MAL page.',
'name' => 'Year',
],
'format' => [
'help' => 'The Format of the Anime. By default, we will use the Type Field on the MAL page.',
'name' => 'Format',
],
],
'artist' => [
'groups' => [
@@ -676,11 +688,14 @@ return [
'base' => [
'attached_at' => 'Attached At',
'created_at' => 'Created At',
'day' => 'Day',
'deleted_at' => 'Deleted At',
'file_properties' => 'File Properties',
'id' => 'ID',
'month' => 'Month',
'timestamps' => 'Timestamps',
'updated_at' => 'Updated At',
'year' => 'Year',
],
'dump' => [
'path' => 'Path',