diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 1cc503a..95afc31 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' => [ @@ -106,7 +106,25 @@ 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` + +`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. ### Single-Day Events diff --git a/src/UpdateScripts/MigrateLocationFields.php b/src/UpdateScripts/MigrateLocationFields.php new file mode 100644 index 0000000..56a2516 --- /dev/null +++ b/src/UpdateScripts/MigrateLocationFields.php @@ -0,0 +1,162 @@ +isUpdatingTo('7.0'); + } + + public function update() + { + $skipped = collect(Events::setting('collections', ['events'])) + ->flatMap(fn (string $collection) => Entries::query()->where('collection', $collection)->get()) + ->map(fn (Entry $entry) => $this->processEntry($entry)) + ->filter(); + + if ($skipped->isNotEmpty()) { + $this->console()->warn('Skipped entries (resolve by hand):'); + $skipped->each(fn (string $line) => $this->console()->line(" - {$line}")); + } + + $this->console()->info('Migrated event location fields for 7.0.'); + } + + private function filledString(array $data, string $key): bool + { + return is_string($value = Arr::get($data, $key)) && filled($value); + } + + private function migrateEntry(Entry $entry): void + { + $data = $entry->data()->all(); + $location = Arr::get($data, 'location'); + + // 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')) { + return; + } + + $entry->set('online_url', $data['link'])->remove('link')->save(); + + 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); + $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)) && 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; + } + } + + if ($changed) { + $entry->save(); + } + } + + private function processEntry(Entry $entry): ?string + { + if ($reason = $this->skipReason($entry->data()->all())) { + return "{$entry->id()} ({$reason})"; + } + + $this->migrateEntry($entry); + + 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')) { + return $data['address']; + } + + if ($this->filledString($data, 'location') && ! Str::isUrl($data['location'])) { + return $data['location']; + } + + return null; + } + + private function resolveOnlineUrl(array $data): ?string + { + if ($this->filledString($data, 'online_url')) { + return $data['online_url']; + } + + if ($this->filledString($data, 'link')) { + return $data['link']; + } + + if ($this->filledString($data, 'location') && Str::isUrl($data['location'])) { + return $data['location']; + } + + return null; + } + + private function skipReason(array $data): ?string + { + $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'; + } + + 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..b4cf8cf --- /dev/null +++ b/tests/UpdateScripts/MigrateLocationFieldsTest.php @@ -0,0 +1,316 @@ +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('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('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' => '/'], + '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'); +});