diff --git a/src/Entries/MinuteEntries.php b/src/Entries/MinuteEntries.php new file mode 100644 index 0000000000..5a39f73f98 --- /dev/null +++ b/src/Entries/MinuteEntries.php @@ -0,0 +1,28 @@ +whereIn('collection', Collection::all()->filter->dated()->map->handle()->all()) + ->whereDate('date', $this->minute->format('Y-m-d')) + ->where(function ($query) { + $query->where(function ($query) { + $query + ->whereTime('date', '>=', $this->minute->format('H:i').':00') + ->whereTime('date', '<=', $this->minute->format('H:i').':59'); + }); + })->get(); + } +} diff --git a/src/Events/Concerns/ListensForContentEvents.php b/src/Events/Concerns/ListensForContentEvents.php index 807ae5a856..f2889e84b3 100644 --- a/src/Events/Concerns/ListensForContentEvents.php +++ b/src/Events/Concerns/ListensForContentEvents.php @@ -27,6 +27,7 @@ trait ListensForContentEvents \Statamic\Events\CollectionTreeDeleted::class, \Statamic\Events\EntryDeleted::class, \Statamic\Events\EntrySaved::class, + \Statamic\Events\EntryScheduleReached::class, \Statamic\Events\FieldsetDeleted::class, \Statamic\Events\FieldsetReset::class, \Statamic\Events\FieldsetSaved::class, diff --git a/src/Events/EntryScheduleReached.php b/src/Events/EntryScheduleReached.php new file mode 100644 index 0000000000..15196ca238 --- /dev/null +++ b/src/Events/EntryScheduleReached.php @@ -0,0 +1,20 @@ +entry = $entry; + } + + public function commitMessage() + { + return __('Entry schedule reached', [], config('statamic.git.locale')); + } +} diff --git a/src/Jobs/HandleEntrySchedule.php b/src/Jobs/HandleEntrySchedule.php new file mode 100644 index 0000000000..3317bd53d8 --- /dev/null +++ b/src/Jobs/HandleEntrySchedule.php @@ -0,0 +1,32 @@ +entries()->each(fn ($entry) => EntryScheduleReached::dispatch($entry)); + } + + private function entries(): Collection + { + // We want to target the PREVIOUS minute because we can be sure that any entries that + // were scheduled for then would now be considered published. If we were targeting + // the current minute and the entry has defined a time with seconds later in the + // same minute, it may still be considered scheduled when it gets dispatched. + $minute = now()->subMinute(); + + return (new MinuteEntries($minute))(); + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 1648e2adfa..bf2fe3cbb1 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace Statamic\Providers; +use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\AboutCommand; use Illuminate\Foundation\Http\Middleware\TrimStrings; use Illuminate\Http\Request; @@ -15,6 +16,7 @@ use Statamic\Facades\Stache; use Statamic\Facades\Token; use Statamic\Fields\FieldsetRecursionStack; +use Statamic\Jobs\HandleEntrySchedule; use Statamic\Sites\Sites; use Statamic\Statamic; use Statamic\Tokens\Handlers\LivePreview; @@ -97,6 +99,8 @@ public function boot() TrimStrings::skipWhen(fn (Request $request) => $request->is(config('statamic.cp.route').'/*')); $this->addAboutCommandInfo(); + + $this->app->make(Schedule::class)->job(new HandleEntrySchedule)->everyMinute(); } public function register() diff --git a/src/Search/UpdateItemIndexes.php b/src/Search/UpdateItemIndexes.php index 3057e80338..3077760e48 100644 --- a/src/Search/UpdateItemIndexes.php +++ b/src/Search/UpdateItemIndexes.php @@ -8,6 +8,7 @@ use Statamic\Events\AssetSaved; use Statamic\Events\EntryDeleted; use Statamic\Events\EntrySaved; +use Statamic\Events\EntryScheduleReached; use Statamic\Events\TermDeleted; use Statamic\Events\TermSaved; use Statamic\Events\UserDeleted; @@ -20,6 +21,7 @@ public function subscribe($event) { $event->listen(EntrySaved::class, self::class.'@update'); $event->listen(EntryDeleted::class, self::class.'@delete'); + $event->listen(EntryScheduleReached::class, self::class.'@update'); $event->listen(AssetSaved::class, self::class.'@update'); $event->listen(AssetDeleted::class, self::class.'@delete'); $event->listen(UserSaved::class, self::class.'@update'); diff --git a/src/StaticCaching/Invalidate.php b/src/StaticCaching/Invalidate.php index c22f4f6879..c12ae79218 100644 --- a/src/StaticCaching/Invalidate.php +++ b/src/StaticCaching/Invalidate.php @@ -11,6 +11,7 @@ use Statamic\Events\CollectionTreeSaved; use Statamic\Events\EntryDeleting; use Statamic\Events\EntrySaved; +use Statamic\Events\EntryScheduleReached; use Statamic\Events\FormDeleted; use Statamic\Events\FormSaved; use Statamic\Events\GlobalSetDeleted; @@ -32,6 +33,7 @@ class Invalidate implements ShouldQueue AssetDeleted::class => 'invalidateAsset', EntrySaved::class => 'invalidateEntry', EntryDeleting::class => 'invalidateEntry', + EntryScheduleReached::class => 'invalidateEntry', TermSaved::class => 'invalidateTerm', TermDeleted::class => 'invalidateTerm', GlobalSetSaved::class => 'invalidateGlobalSet', diff --git a/tests/Data/Entries/ScheduledEntriesTest.php b/tests/Data/Entries/ScheduledEntriesTest.php new file mode 100644 index 0000000000..1b3b0fa2d0 --- /dev/null +++ b/tests/Data/Entries/ScheduledEntriesTest.php @@ -0,0 +1,94 @@ + $fields) { + $bps[$collection] = Blueprint::makeFromFields($fields)->setHandle($collection); + } + + foreach ($collections as $collection => $fields) { + Collection::make($collection)->dated(true)->save(); + Blueprint::shouldReceive('in')->with('collections/'.$collection)->andReturn(collect(['test' => $bps[$collection]])); + } + } + + private function getEntryIdsForMinute($minute) + { + $minute = Carbon::parse($minute); + + return (new MinuteEntries($minute))()->map->id->all(); + } + + #[Test] + public function it_gets_entries_scheduled_for_given_minute() + { + $this->makeCollectionsWithBlueprints([ + 'time_with_seconds' => [ + 'date' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => true], + ], + 'time_without_seconds' => [ + 'date' => ['type' => 'date', 'time_enabled' => true, 'time_seconds_enabled' => false], + ], + 'dated' => [ + 'date' => ['type' => 'date', 'time_enabled' => false], + ], + 'undated' => [], + ]); + + collect([ + '01' => '2023-09-12-121400', // day before + '02' => '2023-09-12-121420', // day before + '03' => '2023-09-12-121421', // day before + '04' => '2023-09-13-121300', // minute before + '05' => '2023-09-13-121320', // minute before + '06' => '2023-09-13-121321', // minute before + '07' => '2023-09-13-121400', // target minute + '08' => '2023-09-13-121420', // target second + '09' => '2023-09-13-121421', // target minute + '10' => '2023-09-13-121500', // minute after + '11' => '2023-09-13-121520', // minute after + '12' => '2023-09-13-121521', // minute after + '13' => '2023-09-14-121400', // day after + '14' => '2023-09-14-121420', // day after + '15' => '2023-09-14-121421', // day after + ])->each(fn ($date, $id) => EntryFactory::id($id)->collection('time_with_seconds')->date($date)->create()); + + collect([ + '16' => '2023-09-12-1214', // day before + '17' => '2023-09-13-1213', // minute before + '18' => '2023-09-13-1214', // target minute + '19' => '2023-09-13-1215', // minute after + '20' => '2023-09-14-1214', // day after + ])->each(fn ($date, $id) => EntryFactory::id($id)->collection('time_without_seconds')->date($date)->create()); + + collect([ + '21' => '2023-09-12', // day before + '22' => '2023-09-13', // target minute's day + '23' => '2023-09-14', // day after + ])->each(fn ($date, $id) => EntryFactory::id($id)->collection('dated')->date($date)->create()); + + EntryFactory::id('24')->collection('undated')->create(); + + $this->assertEquals(['07', '08', '09', '18'], $this->getEntryIdsForMinute('2023-09-13 12:14:20')); + $this->assertEquals(['07', '08', '09', '18'], $this->getEntryIdsForMinute('2023-09-13 12:14:00')); + $this->assertEquals(['07', '08', '09', '18'], $this->getEntryIdsForMinute('2023-09-13 12:14:25')); + $this->assertEquals(['22'], $this->getEntryIdsForMinute('2023-09-13 00:00:00')); + } +}