From 49f98cdcfdef4011a74a8ef9f24b95c6ad38218c Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:42:22 -0700 Subject: [PATCH 01/18] Add 7.0 location fields update script --- DOCUMENTATION.md | 19 +- src/UpdateScripts/MigrateLocationFields.php | 157 +++++++++ .../MigrateLocationFieldsTest.php | 304 ++++++++++++++++++ 3 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/UpdateScripts/MigrateLocationFields.php create mode 100644 tests/UpdateScripts/MigrateLocationFieldsTest.php diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1cc503a..c035a6d 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -106,7 +106,24 @@ Breaking changes for location fields: - URL sniffing on a string `location` is gone — use `online_url` for join links - Protected `eventUrl()` / `icsAddress()` were replaced by `icsUrl()` / `icsLocation()` -An update script (#196) migrates common legacy handles. Back up content before upgrading. Computed-value mappings and foreign (e.g. Prime) `location` shapes need a separate cutover. +**Back up content before upgrading.** The `MigrateLocationFields` update script rewrites entries in the configured events collections (all sites): + +| Old | New | +|---|---| +| `address` | `location.name` | +| non-URL string `location` | `location.name` | +| URL string `location` | `online_url` | +| `link` | `online_url` | +| top-level `coordinates` | `location.coordinates` | + +Skipped (logged with entry IDs — resolve by hand): + +- `address` and a non-URL string `location` both set +- `link` and a URL-valued `location` both set +- `online_url` already set together with a conflicting `link` or URL-valued `location` +- Array-shaped `location` (e.g. Prime/Simple) — left alone; only a lone `link` may move to `online_url` + +Computed-value mappings are not migrated — update those by hand. ### Single-Day Events diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php new file mode 100644 index 0000000..0bd8e31 --- /dev/null +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -0,0 +1,157 @@ +normalize('7.0'); + $newVersion = $parser->normalize($newVersion); + $oldVersion = $parser->normalize($oldVersion); + + return version_compare($version, $newVersion, '<=') + && version_compare($version, $oldVersion, '>'); + } + + public function update() + { + $skipped = []; + + collect(Events::setting('collections', ['events'])) + ->each(function (string $collection) use (&$skipped) { + Entry::query() + ->where('collection', $collection) + ->get() + ->each(function ($entry) use (&$skipped) { + if ($reason = $this->skipReason($entry->data()->all())) { + $skipped[] = "{$entry->id()} ({$reason})"; + + return; + } + + $this->migrateEntry($entry); + }); + }); + + if ($skipped !== []) { + $this->console()->warn('Skipped entries (resolve by hand):'); + collect($skipped)->each(fn (string $line) => $this->console()->line(" - {$line}")); + } + + $this->console()->info('Migrated event location fields to the 7.0 shape.'); + } + + private function filledString(mixed $value): bool + { + return is_string($value) && $value !== ''; + } + + private function migrateEntry($entry): void + { + $data = $entry->data()->all(); + $location = $data['location'] ?? null; + + // Foreign/Prime group: never reshape location; only lift a lone link. + if (is_array($location)) { + $link = $data['link'] ?? null; + + if (is_string($link) && $link !== '' && ! $this->filledString($data['online_url'] ?? null)) { + $entry->set('online_url', $link)->remove('link')->save(); + } + + return; + } + + $name = $this->resolveName($data); + $coordinates = is_array($data['coordinates'] ?? null) ? $data['coordinates'] : null; + $onlineUrl = $this->resolveOnlineUrl($data); + + if ($name !== null || $coordinates !== null) { + $entry->set('location', array_filter([ + 'name' => $name, + 'coordinates' => $coordinates, + ], fn ($value) => $value !== null)); + } elseif (is_string($location)) { + $entry->remove('location'); + } + + if ($onlineUrl !== null) { + $entry->set('online_url', $onlineUrl); + } + + foreach (['address', 'link', 'coordinates'] as $handle) { + if (array_key_exists($handle, $data)) { + $entry->remove($handle); + } + } + + $entry->save(); + } + + private function resolveName(array $data): ?string + { + if ($this->filledString($data['address'] ?? null)) { + return $data['address']; + } + + $location = $data['location'] ?? null; + + if ($this->filledString($location) && ! Str::isUrl($location)) { + return $location; + } + + return null; + } + + private function resolveOnlineUrl(array $data): ?string + { + if ($this->filledString($data['online_url'] ?? null)) { + return $data['online_url']; + } + + if ($this->filledString($data['link'] ?? null)) { + return $data['link']; + } + + $location = $data['location'] ?? null; + + if ($this->filledString($location) && Str::isUrl($location)) { + return $location; + } + + return null; + } + + private function skipReason(array $data): ?string + { + $location = $data['location'] ?? null; + $hasAddress = $this->filledString($data['address'] ?? null); + $hasLink = $this->filledString($data['link'] ?? null); + $hasOnlineUrl = $this->filledString($data['online_url'] ?? null); + $isStringLocation = $this->filledString($location); + $isUrlLocation = $isStringLocation && Str::isUrl($location); + $isNonUrlStringLocation = $isStringLocation && ! Str::isUrl($location); + + if ($hasAddress && $isNonUrlStringLocation) { + return 'address and non-URL location both set'; + } + + if ($hasLink && $isUrlLocation) { + return 'link and URL-valued location both set'; + } + + if ($hasOnlineUrl && ($hasLink || $isUrlLocation)) { + return 'online_url conflicts with link or URL-valued location'; + } + + return null; + } +} diff --git a/tests/UpdateScripts/MigrateLocationFieldsTest.php b/tests/UpdateScripts/MigrateLocationFieldsTest.php new file mode 100644 index 0000000..9f67a66 --- /dev/null +++ b/tests/UpdateScripts/MigrateLocationFieldsTest.php @@ -0,0 +1,304 @@ +lines = []; + $this->console = Mockery::mock(\Illuminate\Console\Command::class)->shouldIgnoreMissing(); + $this->console->shouldReceive('warn')->zeroOrMoreTimes()->andReturnUsing(function ($message) { + $this->lines[] = $message; + }); + $this->console->shouldReceive('line')->zeroOrMoreTimes()->andReturnUsing(function ($message) { + $this->lines[] = $message; + }); + $this->console->shouldReceive('info')->zeroOrMoreTimes(); + $this->script = new MigrateLocationFields('transformstudios/events', $this->console); +}); + +test('shouldUpdate is true when crossing to 7.0', function (string $new, string $old) { + expect($this->script->shouldUpdate($new, $old))->toBeTrue(); +})->with([ + ['7.0.0', '6.2.0'], + ['7.0.0', '5.4.0'], + ['7.1.0', '6.0.0'], +]); + +test('shouldUpdate is false within a major line', function (string $new, string $old) { + expect($this->script->shouldUpdate($new, $old))->toBeFalse(); +})->with([ + ['6.2.0', '6.1.0'], + ['7.1.0', '7.0.0'], +]); + +test('migrates address to location name', function () { + Entry::make() + ->collection('events') + ->slug('address-event') + ->id('address-id') + ->data([ + 'title' => 'Address Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('address-id'); + + expect($entry->get('location'))->toBe(['name' => '123 Main St']) + ->and($entry->get('address'))->toBeNull(); +}); + +test('migrates non-URL string location to location name', function () { + Entry::make() + ->collection('events') + ->slug('string-location-event') + ->id('string-location-id') + ->data([ + 'title' => 'String Location Event', + 'start_date' => now()->toDateString(), + 'location' => 'City Hall', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('string-location-id'); + + expect($entry->get('location'))->toBe(['name' => 'City Hall']); +}); + +test('migrates URL string location to online_url', function () { + Entry::make() + ->collection('events') + ->slug('url-location-event') + ->id('url-location-id') + ->data([ + 'title' => 'URL Location Event', + 'start_date' => now()->toDateString(), + 'location' => 'https://zoom.us/j/123', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('url-location-id'); + + expect($entry->get('online_url'))->toBe('https://zoom.us/j/123') + ->and($entry->get('location'))->toBeNull(); +}); + +test('migrates link to online_url', function () { + Entry::make() + ->collection('events') + ->slug('link-event') + ->id('link-id') + ->data([ + 'title' => 'Link Event', + 'start_date' => now()->toDateString(), + 'link' => 'https://example.com/join', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('link-id'); + + expect($entry->get('online_url'))->toBe('https://example.com/join') + ->and($entry->get('link'))->toBeNull(); +}); + +test('migrates top-level coordinates under location', function () { + Entry::make() + ->collection('events') + ->slug('coords-event') + ->id('coords-id') + ->data([ + 'title' => 'Coords Event', + 'start_date' => now()->toDateString(), + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ])->save(); + + $this->script->update(); + + $entry = Entry::find('coords-id'); + + expect($entry->get('location'))->toBe([ + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ])->and($entry->get('coordinates'))->toBeNull(); +}); + +test('migrates address link and coordinates together', function () { + Entry::make() + ->collection('events') + ->slug('combo-event') + ->id('combo-id') + ->data([ + 'title' => 'Combo Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + 'link' => 'https://example.com/join', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ])->save(); + + $this->script->update(); + + $entry = Entry::find('combo-id'); + + expect($entry->get('location'))->toBe([ + 'name' => '123 Main St', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ]) + ->and($entry->get('online_url'))->toBe('https://example.com/join') + ->and($entry->get('address'))->toBeNull() + ->and($entry->get('link'))->toBeNull() + ->and($entry->get('coordinates'))->toBeNull(); +}); + +test('skips when address and non-URL location both set', function () { + Entry::make() + ->collection('events') + ->slug('ambiguous-address-event') + ->id('ambiguous-address-id') + ->data([ + 'title' => 'Ambiguous Address Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + 'location' => 'City Hall', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('ambiguous-address-id'); + + expect($entry->get('address'))->toBe('123 Main St') + ->and($entry->get('location'))->toBe('City Hall') + ->and(implode("\n", $this->lines))->toContain('ambiguous-address-id'); +}); + +test('skips when link and URL location both set', function () { + Entry::make() + ->collection('events') + ->slug('ambiguous-link-event') + ->id('ambiguous-link-id') + ->data([ + 'title' => 'Ambiguous Link Event', + 'start_date' => now()->toDateString(), + 'link' => 'https://example.com/join', + 'location' => 'https://zoom.us/j/123', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('ambiguous-link-id'); + + expect($entry->get('link'))->toBe('https://example.com/join') + ->and($entry->get('location'))->toBe('https://zoom.us/j/123') + ->and(implode("\n", $this->lines))->toContain('ambiguous-link-id'); +}); + +test('skips when online_url conflicts with link', function () { + Entry::make() + ->collection('events') + ->slug('conflict-online-event') + ->id('conflict-online-id') + ->data([ + 'title' => 'Conflict Online Event', + 'start_date' => now()->toDateString(), + 'online_url' => 'https://zoom.us/j/already', + 'link' => 'https://example.com/join', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('conflict-online-id'); + + expect($entry->get('online_url'))->toBe('https://zoom.us/j/already') + ->and($entry->get('link'))->toBe('https://example.com/join') + ->and(implode("\n", $this->lines))->toContain('conflict-online-id'); +}); + +test('leaves array-shaped location untouched', function () { + Entry::make() + ->collection('events') + ->slug('prime-location-event') + ->id('prime-location-id') + ->data([ + 'title' => 'Prime Location Event', + 'start_date' => now()->toDateString(), + 'location' => [ + 'details' => 'Virtual', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ], + 'link' => 'https://example.com/join', + ])->save(); + + $this->script->update(); + + $entry = Entry::find('prime-location-id'); + + expect($entry->get('location'))->toBe([ + 'details' => 'Virtual', + 'coordinates' => [ + 'latitude' => 40, + 'longitude' => 50, + ], + ]) + ->and($entry->get('online_url'))->toBe('https://example.com/join') + ->and($entry->get('link'))->toBeNull(); +}); + +test('migrates localized entries', function () { + Site::setSites([ + 'default' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + $this->collection->sites(['default', 'fr'])->save(); + + $origin = Entry::make() + ->collection('events') + ->locale('default') + ->slug('localized-event') + ->id('localized-origin-id') + ->data([ + 'title' => 'Localized Event', + 'start_date' => now()->toDateString(), + 'address' => '123 Main St', + 'link' => 'https://example.com/en', + ]); + $origin->save(); + + $localized = $origin->makeLocalization('fr'); + $localized->id('localized-fr-id'); + $localized->data([ + 'title' => 'Événement', + 'address' => '456 Rue Principale', + 'link' => 'https://example.com/fr', + ]); + $localized->save(); + + $this->script->update(); + + expect(Entry::find('localized-origin-id')->get('location'))->toBe(['name' => '123 Main St']) + ->and(Entry::find('localized-origin-id')->get('online_url'))->toBe('https://example.com/en') + ->and(Entry::find('localized-fr-id')->get('location'))->toBe(['name' => '456 Rue Principale']) + ->and(Entry::find('localized-fr-id')->get('online_url'))->toBe('https://example.com/fr'); +}); From c749fc25fab3d97697d2434b1ef5771c1000c6bd Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:45:44 -0700 Subject: [PATCH 02/18] Use isUpdatingTo for location migrator gate --- src/UpdateScripts/MigrateLocationFields.php | 9 +-------- tests/UpdateScripts/MigrateLocationFieldsTest.php | 15 --------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 0bd8e31..c0cc2ee 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -2,7 +2,6 @@ namespace TransformStudios\Events\UpdateScripts; -use Composer\Semver\VersionParser; use Illuminate\Support\Str; use Statamic\Facades\Entry; use Statamic\UpdateScripts\UpdateScript; @@ -12,13 +11,7 @@ class MigrateLocationFields extends UpdateScript { public function shouldUpdate($newVersion, $oldVersion) { - $parser = new VersionParser; - $version = $parser->normalize('7.0'); - $newVersion = $parser->normalize($newVersion); - $oldVersion = $parser->normalize($oldVersion); - - return version_compare($version, $newVersion, '<=') - && version_compare($version, $oldVersion, '>'); + return $this->isUpdatingTo('7.0'); } public function update() diff --git a/tests/UpdateScripts/MigrateLocationFieldsTest.php b/tests/UpdateScripts/MigrateLocationFieldsTest.php index 9f67a66..900ee40 100644 --- a/tests/UpdateScripts/MigrateLocationFieldsTest.php +++ b/tests/UpdateScripts/MigrateLocationFieldsTest.php @@ -20,21 +20,6 @@ $this->script = new MigrateLocationFields('transformstudios/events', $this->console); }); -test('shouldUpdate is true when crossing to 7.0', function (string $new, string $old) { - expect($this->script->shouldUpdate($new, $old))->toBeTrue(); -})->with([ - ['7.0.0', '6.2.0'], - ['7.0.0', '5.4.0'], - ['7.1.0', '6.0.0'], -]); - -test('shouldUpdate is false within a major line', function (string $new, string $old) { - expect($this->script->shouldUpdate($new, $old))->toBeFalse(); -})->with([ - ['6.2.0', '6.1.0'], - ['7.1.0', '7.0.0'], -]); - test('migrates address to location name', function () { Entry::make() ->collection('events') From f539fd748ba8660fe291793f7cae7c18c0b0a86b Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:47:12 -0700 Subject: [PATCH 03/18] Simplify location migrator update pipeline --- src/UpdateScripts/MigrateLocationFields.php | 36 ++++++++++----------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index c0cc2ee..5fdf3f4 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -16,27 +16,14 @@ public function shouldUpdate($newVersion, $oldVersion) public function update() { - $skipped = []; + $skipped = collect(Events::setting('collections', ['events'])) + ->flatMap(fn (string $collection) => Entry::query()->where('collection', $collection)->get()) + ->map(fn ($entry) => $this->processEntry($entry)) + ->filter(); - collect(Events::setting('collections', ['events'])) - ->each(function (string $collection) use (&$skipped) { - Entry::query() - ->where('collection', $collection) - ->get() - ->each(function ($entry) use (&$skipped) { - if ($reason = $this->skipReason($entry->data()->all())) { - $skipped[] = "{$entry->id()} ({$reason})"; - - return; - } - - $this->migrateEntry($entry); - }); - }); - - if ($skipped !== []) { + if ($skipped->isNotEmpty()) { $this->console()->warn('Skipped entries (resolve by hand):'); - collect($skipped)->each(fn (string $line) => $this->console()->line(" - {$line}")); + $skipped->each(fn (string $line) => $this->console()->line(" - {$line}")); } $this->console()->info('Migrated event location fields to the 7.0 shape.'); @@ -89,6 +76,17 @@ private function migrateEntry($entry): void $entry->save(); } + private function processEntry($entry): ?string + { + if ($reason = $this->skipReason($entry->data()->all())) { + return "{$entry->id()} ({$reason})"; + } + + $this->migrateEntry($entry); + + return null; + } + private function resolveName(array $data): ?string { if ($this->filledString($data['address'] ?? null)) { From 45dca26b9476864d4c93039cea97e28052951995 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:49:59 -0700 Subject: [PATCH 04/18] Use filled() for location migrator string checks --- src/UpdateScripts/MigrateLocationFields.php | 45 +++++++++------------ 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 5fdf3f4..e94fa02 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -2,6 +2,7 @@ namespace TransformStudios\Events\UpdateScripts; +use Illuminate\Support\Arr; use Illuminate\Support\Str; use Statamic\Facades\Entry; use Statamic\UpdateScripts\UpdateScript; @@ -29,21 +30,19 @@ public function update() $this->console()->info('Migrated event location fields to the 7.0 shape.'); } - private function filledString(mixed $value): bool + private function filledString(array $data, string $key): bool { - return is_string($value) && $value !== ''; + return is_string($value = Arr::get($data, $key)) && filled($value); } private function migrateEntry($entry): void { $data = $entry->data()->all(); - $location = $data['location'] ?? null; + $location = Arr::get($data, 'location'); // Foreign/Prime group: never reshape location; only lift a lone link. if (is_array($location)) { - $link = $data['link'] ?? null; - - if (is_string($link) && $link !== '' && ! $this->filledString($data['online_url'] ?? null)) { + if (is_string($link = Arr::get($data, 'link')) && filled($link) && ! $this->filledString($data, 'online_url')) { $entry->set('online_url', $link)->remove('link')->save(); } @@ -51,7 +50,8 @@ private function migrateEntry($entry): void } $name = $this->resolveName($data); - $coordinates = is_array($data['coordinates'] ?? null) ? $data['coordinates'] : null; + $coordinates = Arr::get($data, 'coordinates'); + $coordinates = is_array($coordinates) ? $coordinates : null; $onlineUrl = $this->resolveOnlineUrl($data); if ($name !== null || $coordinates !== null) { @@ -89,14 +89,12 @@ private function processEntry($entry): ?string private function resolveName(array $data): ?string { - if ($this->filledString($data['address'] ?? null)) { + if ($this->filledString($data, 'address')) { return $data['address']; } - $location = $data['location'] ?? null; - - if ($this->filledString($location) && ! Str::isUrl($location)) { - return $location; + if ($this->filledString($data, 'location') && ! Str::isUrl($data['location'])) { + return $data['location']; } return null; @@ -104,18 +102,16 @@ private function resolveName(array $data): ?string private function resolveOnlineUrl(array $data): ?string { - if ($this->filledString($data['online_url'] ?? null)) { + if ($this->filledString($data, 'online_url')) { return $data['online_url']; } - if ($this->filledString($data['link'] ?? null)) { + if ($this->filledString($data, 'link')) { return $data['link']; } - $location = $data['location'] ?? null; - - if ($this->filledString($location) && Str::isUrl($location)) { - return $location; + if ($this->filledString($data, 'location') && Str::isUrl($data['location'])) { + return $data['location']; } return null; @@ -123,13 +119,12 @@ private function resolveOnlineUrl(array $data): ?string private function skipReason(array $data): ?string { - $location = $data['location'] ?? null; - $hasAddress = $this->filledString($data['address'] ?? null); - $hasLink = $this->filledString($data['link'] ?? null); - $hasOnlineUrl = $this->filledString($data['online_url'] ?? null); - $isStringLocation = $this->filledString($location); - $isUrlLocation = $isStringLocation && Str::isUrl($location); - $isNonUrlStringLocation = $isStringLocation && ! Str::isUrl($location); + $hasAddress = $this->filledString($data, 'address'); + $hasLink = $this->filledString($data, 'link'); + $hasOnlineUrl = $this->filledString($data, 'online_url'); + $isStringLocation = $this->filledString($data, 'location'); + $isUrlLocation = $isStringLocation && Str::isUrl($data['location']); + $isNonUrlStringLocation = $isStringLocation && ! Str::isUrl($data['location']); if ($hasAddress && $isNonUrlStringLocation) { return 'address and non-URL location both set'; From 975d66d74f1f6e31320fce4d0758116239dd4632 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:54:55 -0700 Subject: [PATCH 05/18] Typehint Entry in location migrator helpers --- src/UpdateScripts/MigrateLocationFields.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index e94fa02..7999a57 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -4,7 +4,8 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; -use Statamic\Facades\Entry; +use Statamic\Entries\Entry; +use Statamic\Facades\Entry as Entries; use Statamic\UpdateScripts\UpdateScript; use TransformStudios\Events\Events; @@ -18,8 +19,8 @@ public function shouldUpdate($newVersion, $oldVersion) public function update() { $skipped = collect(Events::setting('collections', ['events'])) - ->flatMap(fn (string $collection) => Entry::query()->where('collection', $collection)->get()) - ->map(fn ($entry) => $this->processEntry($entry)) + ->flatMap(fn (string $collection) => Entries::query()->where('collection', $collection)->get()) + ->map(fn (Entry $entry) => $this->processEntry($entry)) ->filter(); if ($skipped->isNotEmpty()) { @@ -35,7 +36,7 @@ private function filledString(array $data, string $key): bool return is_string($value = Arr::get($data, $key)) && filled($value); } - private function migrateEntry($entry): void + private function migrateEntry(Entry $entry): void { $data = $entry->data()->all(); $location = Arr::get($data, 'location'); @@ -76,7 +77,7 @@ private function migrateEntry($entry): void $entry->save(); } - private function processEntry($entry): ?string + private function processEntry(Entry $entry): ?string { if ($reason = $this->skipReason($entry->data()->all())) { return "{$entry->id()} ({$reason})"; From e7dff0999c26aa1c5ff0afda2f9300f7a57a5c50 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:55:49 -0700 Subject: [PATCH 06/18] Clarify Prime location migrator comment --- src/UpdateScripts/MigrateLocationFields.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 7999a57..e869fa3 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -41,7 +41,7 @@ private function migrateEntry(Entry $entry): void $data = $entry->data()->all(); $location = Arr::get($data, 'location'); - // Foreign/Prime group: never reshape location; only lift a lone link. + // Prime/foreign group: leave location alone; move link → online_url if needed. if (is_array($location)) { if (is_string($link = Arr::get($data, 'link')) && filled($link) && ! $this->filledString($data, 'online_url')) { $entry->set('online_url', $link)->remove('link')->save(); From 36639348d7a10ad28c6661bc9435b992db52502b Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:57:38 -0700 Subject: [PATCH 07/18] Use is_null in location migrator --- src/UpdateScripts/MigrateLocationFields.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index e869fa3..1e9b7aa 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -55,16 +55,16 @@ private function migrateEntry(Entry $entry): void $coordinates = is_array($coordinates) ? $coordinates : null; $onlineUrl = $this->resolveOnlineUrl($data); - if ($name !== null || $coordinates !== null) { + if (! is_null($name) || ! is_null($coordinates)) { $entry->set('location', array_filter([ 'name' => $name, 'coordinates' => $coordinates, - ], fn ($value) => $value !== null)); + ], fn ($value) => ! is_null($value))); } elseif (is_string($location)) { $entry->remove('location'); } - if ($onlineUrl !== null) { + if (! is_null($onlineUrl)) { $entry->set('online_url', $onlineUrl); } From 32c1695fb359bb052223b78978b65ef2e97ac97c Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 15:59:08 -0700 Subject: [PATCH 08/18] Collapse coordinates assignment to one line --- src/UpdateScripts/MigrateLocationFields.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 1e9b7aa..aab8528 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -51,8 +51,7 @@ private function migrateEntry(Entry $entry): void } $name = $this->resolveName($data); - $coordinates = Arr::get($data, 'coordinates'); - $coordinates = is_array($coordinates) ? $coordinates : null; + $coordinates = is_array($coordinates = Arr::get($data, 'coordinates')) ? $coordinates : null; $onlineUrl = $this->resolveOnlineUrl($data); if (! is_null($name) || ! is_null($coordinates)) { From 939c17767f105f0be17d69657cd9fe8959fb402d Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:00:02 -0700 Subject: [PATCH 09/18] Split location group set onto two lines --- src/UpdateScripts/MigrateLocationFields.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index aab8528..7eb8d7d 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -55,10 +55,8 @@ private function migrateEntry(Entry $entry): void $onlineUrl = $this->resolveOnlineUrl($data); if (! is_null($name) || ! is_null($coordinates)) { - $entry->set('location', array_filter([ - 'name' => $name, - 'coordinates' => $coordinates, - ], fn ($value) => ! is_null($value))); + $group = array_filter(compact('name', 'coordinates'), fn ($value) => ! is_null($value)); + $entry->set('location', $group); } elseif (is_string($location)) { $entry->remove('location'); } From d6241943bf73b9f2b779fe21dc888256f22d4cbb Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:01:01 -0700 Subject: [PATCH 10/18] Inline online_url resolve into null check --- src/UpdateScripts/MigrateLocationFields.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 7eb8d7d..4a85585 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -52,7 +52,6 @@ private function migrateEntry(Entry $entry): void $name = $this->resolveName($data); $coordinates = is_array($coordinates = Arr::get($data, 'coordinates')) ? $coordinates : null; - $onlineUrl = $this->resolveOnlineUrl($data); if (! is_null($name) || ! is_null($coordinates)) { $group = array_filter(compact('name', 'coordinates'), fn ($value) => ! is_null($value)); @@ -61,7 +60,7 @@ private function migrateEntry(Entry $entry): void $entry->remove('location'); } - if (! is_null($onlineUrl)) { + if (! is_null($onlineUrl = $this->resolveOnlineUrl($data))) { $entry->set('online_url', $onlineUrl); } From 3644e47a8116d4a8c9b8613c4b1e296b9e4e4ae3 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:03:21 -0700 Subject: [PATCH 11/18] Extract arrayValue helper for coordinates --- src/UpdateScripts/MigrateLocationFields.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 4a85585..11b3086 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -31,6 +31,11 @@ public function update() $this->console()->info('Migrated event location fields to the 7.0 shape.'); } + private function arrayValue(array $data, string $key): ?array + { + return is_array($value = Arr::get($data, $key)) ? $value : null; + } + private function filledString(array $data, string $key): bool { return is_string($value = Arr::get($data, $key)) && filled($value); @@ -51,7 +56,7 @@ private function migrateEntry(Entry $entry): void } $name = $this->resolveName($data); - $coordinates = is_array($coordinates = Arr::get($data, 'coordinates')) ? $coordinates : null; + $coordinates = $this->arrayValue($data, 'coordinates'); if (! is_null($name) || ! is_null($coordinates)) { $group = array_filter(compact('name', 'coordinates'), fn ($value) => ! is_null($value)); From 257af84d9fd1a246eb5ff662c3e6bd9f83b13d34 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:03:36 -0700 Subject: [PATCH 12/18] Use filledString for Prime link migration check --- src/UpdateScripts/MigrateLocationFields.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 11b3086..fe14c49 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -48,8 +48,8 @@ private function migrateEntry(Entry $entry): void // Prime/foreign group: leave location alone; move link → online_url if needed. if (is_array($location)) { - if (is_string($link = Arr::get($data, 'link')) && filled($link) && ! $this->filledString($data, 'online_url')) { - $entry->set('online_url', $link)->remove('link')->save(); + if ($this->filledString($data, 'link') && ! $this->filledString($data, 'online_url')) { + $entry->set('online_url', $data['link'])->remove('link')->save(); } return; From 04741d5d1bba424df4219c0c1101b31df5efd91b Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:04:40 -0700 Subject: [PATCH 13/18] Use early return for Prime location branch --- src/UpdateScripts/MigrateLocationFields.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index fe14c49..57c51b7 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -48,10 +48,12 @@ private function migrateEntry(Entry $entry): void // Prime/foreign group: leave location alone; move link → online_url if needed. if (is_array($location)) { - if ($this->filledString($data, 'link') && ! $this->filledString($data, 'online_url')) { - $entry->set('online_url', $data['link'])->remove('link')->save(); + if (! $this->filledString($data, 'link') || $this->filledString($data, 'online_url')) { + return; } + $entry->set('online_url', $data['link'])->remove('link')->save(); + return; } From 784c7f544a2500e1f128cc24461d6092c86fae75 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:06:59 -0700 Subject: [PATCH 14/18] Rename arrayValue to resolveCoordinates --- src/UpdateScripts/MigrateLocationFields.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 57c51b7..2cb3017 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -31,11 +31,6 @@ public function update() $this->console()->info('Migrated event location fields to the 7.0 shape.'); } - private function arrayValue(array $data, string $key): ?array - { - return is_array($value = Arr::get($data, $key)) ? $value : null; - } - private function filledString(array $data, string $key): bool { return is_string($value = Arr::get($data, $key)) && filled($value); @@ -58,7 +53,7 @@ private function migrateEntry(Entry $entry): void } $name = $this->resolveName($data); - $coordinates = $this->arrayValue($data, 'coordinates'); + $coordinates = $this->resolveCoordinates($data); if (! is_null($name) || ! is_null($coordinates)) { $group = array_filter(compact('name', 'coordinates'), fn ($value) => ! is_null($value)); @@ -91,6 +86,11 @@ private function processEntry(Entry $entry): ?string return null; } + private function resolveCoordinates(array $data): ?array + { + return is_array($value = Arr::get($data, 'coordinates')) ? $value : null; + } + private function resolveName(array $data): ?string { if ($this->filledString($data, 'address')) { From f1b25401ee1a8309541fd4f0d38fac19c7623739 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:09:20 -0700 Subject: [PATCH 15/18] Comment why in location migrator --- src/UpdateScripts/MigrateLocationFields.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 2cb3017..685dcfd 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -41,7 +41,9 @@ private function migrateEntry(Entry $entry): void $data = $entry->data()->all(); $location = Arr::get($data, 'location'); - // Prime/foreign group: leave location alone; move link → online_url if needed. + // Prime/Simple shape, e.g. location: { details, coordinates } — Events must not + // reshape that group (prime#834 owns it). Only move a leftover Events link: + // link: https://… → online_url: https://… if (is_array($location)) { if (! $this->filledString($data, 'link') || $this->filledString($data, 'online_url')) { return; @@ -52,6 +54,11 @@ private function migrateEntry(Entry $entry): void return; } + // Events legacy → nested group, e.g. + // address: '123 Main St' + coordinates: { lat, lng } + // → location: { name: '123 Main St', coordinates: { lat, lng } } + // location: 'City Hall' → location: { name: 'City Hall' } + // location: 'https://zoom…' alone is not a name — stripped here, becomes online_url below $name = $this->resolveName($data); $coordinates = $this->resolveCoordinates($data); @@ -62,10 +69,12 @@ private function migrateEntry(Entry $entry): void $entry->remove('location'); } + // link / URL-valued location / existing online_url → online_url if (! is_null($onlineUrl = $this->resolveOnlineUrl($data))) { $entry->set('online_url', $onlineUrl); } + // Drop legacy top-level handles now that values live under location / online_url foreach (['address', 'link', 'coordinates'] as $handle) { if (array_key_exists($handle, $data)) { $entry->remove($handle); From 99ecf771f7874091f0b11f789527758372f75745 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:10:24 -0700 Subject: [PATCH 16/18] Drop Prime/Simple from migrator comments --- src/UpdateScripts/MigrateLocationFields.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 685dcfd..e287655 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -41,9 +41,8 @@ private function migrateEntry(Entry $entry): void $data = $entry->data()->all(); $location = Arr::get($data, 'location'); - // Prime/Simple shape, e.g. location: { details, coordinates } — Events must not - // reshape that group (prime#834 owns it). Only move a leftover Events link: - // link: https://… → online_url: https://… + // Array-shaped location (another package's group) — do not reshape it. + // Only move a leftover Events link: link: https://… → online_url: https://… if (is_array($location)) { if (! $this->filledString($data, 'link') || $this->filledString($data, 'online_url')) { return; From cf57c31ed6cb48321076c9f48c465e25707df4cc Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:11:15 -0700 Subject: [PATCH 17/18] Drop shape jargon from migrator and docs --- DOCUMENTATION.md | 4 ++-- src/UpdateScripts/MigrateLocationFields.php | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c035a6d..ea28ff7 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -57,7 +57,7 @@ ICS downloads read the following entry fields when present: | Online only | `online_url` | `online_url` | — | | Hybrid | `location.name` | `online_url` | `location.coordinates` | -`location` must be a group. A string or other non-group value is skipped (no `LOCATION:` from it). Nested coordinates shape: +`location` must be a group. A string or other non-group value is skipped (no `LOCATION:` from it). Nested coordinates: ```php 'location' => [ @@ -121,7 +121,7 @@ Skipped (logged with entry IDs — resolve by hand): - `address` and a non-URL string `location` both set - `link` and a URL-valued `location` both set - `online_url` already set together with a conflicting `link` or URL-valued `location` -- Array-shaped `location` (e.g. Prime/Simple) — left alone; only a lone `link` may move to `online_url` +- `location` that is already a group — left alone; only a lone `link` may move to `online_url` Computed-value mappings are not migrated — update those by hand. diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index e287655..015eead 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -28,7 +28,7 @@ public function update() $skipped->each(fn (string $line) => $this->console()->line(" - {$line}")); } - $this->console()->info('Migrated event location fields to the 7.0 shape.'); + $this->console()->info('Migrated event location fields for 7.0.'); } private function filledString(array $data, string $key): bool @@ -41,7 +41,7 @@ private function migrateEntry(Entry $entry): void $data = $entry->data()->all(); $location = Arr::get($data, 'location'); - // Array-shaped location (another package's group) — do not reshape it. + // location is already a group from another package — leave it alone. // Only move a leftover Events link: link: https://… → online_url: https://… if (is_array($location)) { if (! $this->filledString($data, 'link') || $this->filledString($data, 'online_url')) { From 679aab4d76ac59b18fbb20b99138dd6c2836a151 Mon Sep 17 00:00:00 2001 From: edalzell Date: Wed, 9 Sep 2026 16:43:01 -0700 Subject: [PATCH 18/18] Only save migrated entries; fix upgrade docs --- DOCUMENTATION.md | 3 ++- src/UpdateScripts/MigrateLocationFields.php | 11 ++++++-- .../MigrateLocationFieldsTest.php | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ea28ff7..95afc31 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -121,7 +121,8 @@ Skipped (logged with entry IDs — resolve by hand): - `address` and a non-URL string `location` both set - `link` and a URL-valued `location` both set - `online_url` already set together with a conflicting `link` or URL-valued `location` -- `location` that is already a group — left alone; only a lone `link` may move to `online_url` + +`location` that is already a group is left alone (not logged). A lone `link` on those entries may still move to `online_url`. Computed-value mappings are not migrated — update those by hand. diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php index 015eead..56a2516 100644 --- a/src/UpdateScripts/MigrateLocationFields.php +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -60,27 +60,34 @@ private function migrateEntry(Entry $entry): void // location: 'https://zoom…' alone is not a name — stripped here, becomes online_url below $name = $this->resolveName($data); $coordinates = $this->resolveCoordinates($data); + $changed = false; if (! is_null($name) || ! is_null($coordinates)) { $group = array_filter(compact('name', 'coordinates'), fn ($value) => ! is_null($value)); $entry->set('location', $group); + $changed = true; } elseif (is_string($location)) { $entry->remove('location'); + $changed = true; } // link / URL-valued location / existing online_url → online_url - if (! is_null($onlineUrl = $this->resolveOnlineUrl($data))) { + if (! is_null($onlineUrl = $this->resolveOnlineUrl($data)) && Arr::get($data, 'online_url') !== $onlineUrl) { $entry->set('online_url', $onlineUrl); + $changed = true; } // Drop legacy top-level handles now that values live under location / online_url foreach (['address', 'link', 'coordinates'] as $handle) { if (array_key_exists($handle, $data)) { $entry->remove($handle); + $changed = true; } } - $entry->save(); + if ($changed) { + $entry->save(); + } } private function processEntry(Entry $entry): ?string diff --git a/tests/UpdateScripts/MigrateLocationFieldsTest.php b/tests/UpdateScripts/MigrateLocationFieldsTest.php index 900ee40..b4cf8cf 100644 --- a/tests/UpdateScripts/MigrateLocationFieldsTest.php +++ b/tests/UpdateScripts/MigrateLocationFieldsTest.php @@ -250,6 +250,33 @@ ->and($entry->get('link'))->toBeNull(); }); +test('does not save entries with nothing to migrate', function () { + Entry::make() + ->collection('events') + ->slug('noop-event') + ->id('noop-id') + ->data([ + 'title' => 'Noop Event', + 'start_date' => now()->toDateString(), + 'start_time' => '11:00', + 'end_time' => '12:00', + ])->save(); + + $saved = 0; + \Statamic\Facades\Entry::find('noop-id'); + \Illuminate\Support\Facades\Event::listen(\Statamic\Events\EntrySaved::class, function ($event) use (&$saved) { + if ($event->entry->id() === 'noop-id') { + $saved++; + } + }); + + $this->script->update(); + + expect($saved)->toBe(0) + ->and(Entry::find('noop-id')->get('location'))->toBeNull() + ->and(Entry::find('noop-id')->get('online_url'))->toBeNull(); +}); + test('migrates localized entries', function () { Site::setSites([ 'default' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/'],