From f036cd2d1b1139861e9c4fc2c08117981f0692f6 Mon Sep 17 00:00:00 2001 From: Kyrch Date: Thu, 9 Jul 2026 14:04:08 -0300 Subject: [PATCH] feat: add anime dates & mod notes (#1213) --- .../Models/Wiki/Anime/AnimeDateAction.php | 75 +++++++++++ .../Wiki/Video/Audio/BackfillAudioAction.php | 4 +- app/Casts/AsFuzzyDate.php | 44 +++++++ .../Models/UpdateAnimeDateCommand.php | 69 ++++++++++ app/Console/Kernel.php | 7 + .../Filter/Wiki/AnimeFilterableColumns.php | 7 + app/Enums/GraphQL/Sort/Wiki/AnimeSort.php | 11 ++ app/Filament/Resources/Wiki/AnimeResource.php | 124 +++++++++++++++--- app/GraphQL/Filter/FuzzyDateFilter.php | 34 +++++ app/GraphQL/Queries/AnimeYearsQuery.php | 34 +++-- app/GraphQL/Scalars/FuzzyDateInt.php | 51 +++++++ app/Models/Wiki/Anime.php | 26 +++- app/Observers/Wiki/AnimeObserver.php | 26 ++++ app/Scout/Typesense/Typesense.php | 2 +- app/ValueObjects/FuzzyDate.php | 62 +++++++++ .../2026_07_09_030526_add_anime_dates.php | 24 ++++ graphql/Models/Wiki/anime.graphql | 20 ++- graphql/Models/Wiki/animeyear.graphql | 18 ++- graphql/Models/Wiki/series.graphql | 9 +- graphql/Models/Wiki/studio.graphql | 9 +- graphql/schema.graphql | 21 +++ lang/en/filament.php | 23 +++- 22 files changed, 648 insertions(+), 52 deletions(-) create mode 100644 app/Actions/Models/Wiki/Anime/AnimeDateAction.php create mode 100644 app/Casts/AsFuzzyDate.php create mode 100644 app/Console/Commands/Models/UpdateAnimeDateCommand.php create mode 100644 app/GraphQL/Filter/FuzzyDateFilter.php create mode 100644 app/GraphQL/Scalars/FuzzyDateInt.php create mode 100644 app/Observers/Wiki/AnimeObserver.php create mode 100644 app/ValueObjects/FuzzyDate.php create mode 100644 database/migrations/2026_07_09_030526_add_anime_dates.php diff --git a/app/Actions/Models/Wiki/Anime/AnimeDateAction.php b/app/Actions/Models/Wiki/Anime/AnimeDateAction.php new file mode 100644 index 000000000..71ba74576 --- /dev/null +++ b/app/Actions/Models/Wiki/Anime/AnimeDateAction.php @@ -0,0 +1,75 @@ + $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); + } +} diff --git a/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php index 609f60c36..5ffd87629 100644 --- a/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php +++ b/app/Actions/Models/Wiki/Video/Audio/BackfillAudioAction.php @@ -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()) diff --git a/app/Casts/AsFuzzyDate.php b/app/Casts/AsFuzzyDate.php new file mode 100644 index 000000000..e0bac9328 --- /dev/null +++ b/app/Casts/AsFuzzyDate.php @@ -0,0 +1,44 @@ +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.'); + } +} diff --git a/app/Console/Commands/Models/UpdateAnimeDateCommand.php b/app/Console/Commands/Models/UpdateAnimeDateCommand.php new file mode 100644 index 000000000..0ea9ed283 --- /dev/null +++ b/app/Console/Commands/Models/UpdateAnimeDateCommand.php @@ -0,0 +1,69 @@ +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(), []); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 0df76ced1..ae35bbc3f 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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() diff --git a/app/Enums/GraphQL/Filter/Wiki/AnimeFilterableColumns.php b/app/Enums/GraphQL/Filter/Wiki/AnimeFilterableColumns.php index 0a3d09a18..734eea21b 100644 --- a/app/Enums/GraphQL/Filter/Wiki/AnimeFilterableColumns.php +++ b/app/Enums/GraphQL/Filter/Wiki/AnimeFilterableColumns.php @@ -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), diff --git a/app/Enums/GraphQL/Sort/Wiki/AnimeSort.php b/app/Enums/GraphQL/Sort/Wiki/AnimeSort.php index f5b9475bd..30034918d 100644 --- a/app/Enums/GraphQL/Sort/Wiki/AnimeSort.php +++ b/app/Enums/GraphQL/Sort/Wiki/AnimeSort.php @@ -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), diff --git a/app/Filament/Resources/Wiki/AnimeResource.php b/app/Filament/Resources/Wiki/AnimeResource.php index ffc6fcb1e..ad7a40e46 100644 --- a/app/Filament/Resources/Wiki/AnimeResource.php +++ b/app/Filament/Resources/Wiki/AnimeResource.php @@ -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), diff --git a/app/GraphQL/Filter/FuzzyDateFilter.php b/app/GraphQL/Filter/FuzzyDateFilter.php new file mode 100644 index 000000000..c7f2c7fc6 --- /dev/null +++ b/app/GraphQL/Filter/FuzzyDateFilter.php @@ -0,0 +1,34 @@ + 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', + ]; + } +} diff --git a/app/GraphQL/Queries/AnimeYearsQuery.php b/app/GraphQL/Queries/AnimeYearsQuery.php index bf80aa14f..36e81cedc 100644 --- a/app/GraphQL/Queries/AnimeYearsQuery.php +++ b/app/GraphQL/Queries/AnimeYearsQuery.php @@ -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. diff --git a/app/GraphQL/Scalars/FuzzyDateInt.php b/app/GraphQL/Scalars/FuzzyDateInt.php new file mode 100644 index 000000000..de2226d2d --- /dev/null +++ b/app/GraphQL/Scalars/FuzzyDateInt.php @@ -0,0 +1,51 @@ +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|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(); + } +} diff --git a/app/Models/Wiki/Anime.php b/app/Models/Wiki/Anime.php index 8d546f897..5ddb47532 100644 --- a/app/Models/Wiki/Anime.php +++ b/app/Models/Wiki/Anime.php @@ -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 $animethemes + * @property FuzzyDate|null $end_date * @property Collection $externalentries * @property AnimeFormat|null $format * @property Collection $images + * @property string|null $mod_notes * @property string $name * @property Collection $resources * @property AnimeSeason|null $season * @property Collection $series * @property string $slug + * @property FuzzyDate|null $start_date * @property Collection $studios * @property Collection $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 */ 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. */ diff --git a/app/Observers/Wiki/AnimeObserver.php b/app/Observers/Wiki/AnimeObserver.php new file mode 100644 index 000000000..a4dd837e3 --- /dev/null +++ b/app/Observers/Wiki/AnimeObserver.php @@ -0,0 +1,26 @@ +year = $anime->start_date?->year; + } + + /** + * Handle the Anime "updating" event. + */ + public function updating(Anime $anime): void + { + $anime->year = $anime->start_date?->year; + } +} diff --git a/app/Scout/Typesense/Typesense.php b/app/Scout/Typesense/Typesense.php index d1f74a117..2698c448d 100644 --- a/app/Scout/Typesense/Typesense.php +++ b/app/Scout/Typesense/Typesense.php @@ -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()); diff --git a/app/ValueObjects/FuzzyDate.php b/app/ValueObjects/FuzzyDate.php new file mode 100644 index 000000000..630b20f8b --- /dev/null +++ b/app/ValueObjects/FuzzyDate.php @@ -0,0 +1,62 @@ +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.'); + } +} diff --git a/database/migrations/2026_07_09_030526_add_anime_dates.php b/database/migrations/2026_07_09_030526_add_anime_dates.php new file mode 100644 index 000000000..bfb9e6a62 --- /dev/null +++ b/database/migrations/2026_07_09_030526_add_anime_dates.php @@ -0,0 +1,24 @@ +char('start_date', 8)->nullable()->after('year'); + $table->char('end_date', 8)->nullable()->after('start_date'); + $table->text('mod_notes')->nullable()->after('synopsis'); + }); + } + } +}; diff --git a/graphql/Models/Wiki/anime.graphql b/graphql/Models/Wiki/anime.graphql index 78de9abbb..9e63ff04c 100644 --- a/graphql/Models/Wiki/anime.graphql +++ b/graphql/Models/Wiki/anime.graphql @@ -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 diff --git a/graphql/Models/Wiki/animeyear.graphql b/graphql/Models/Wiki/animeyear.graphql index ee3d82377..dd6db20d0 100644 --- a/graphql/Models/Wiki/animeyear.graphql +++ b/graphql/Models/Wiki/animeyear.graphql @@ -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 diff --git a/graphql/Models/Wiki/series.graphql b/graphql/Models/Wiki/series.graphql index debcfd2ba..adfe3ca7b 100644 --- a/graphql/Models/Wiki/series.graphql +++ b/graphql/Models/Wiki/series.graphql @@ -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") diff --git a/graphql/Models/Wiki/studio.graphql b/graphql/Models/Wiki/studio.graphql index 009d7ed42..422d335aa 100644 --- a/graphql/Models/Wiki/studio.graphql +++ b/graphql/Models/Wiki/studio.graphql @@ -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") diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 85c339936..65fb4db9c 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -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 diff --git a/lang/en/filament.php b/lang/en/filament.php index 35274c894..ec5ab130c 100644 --- a/lang/en/filament.php +++ b/lang/en/filament.php @@ -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',