Converting multiple fields to Entries field

I have a Kirby 4 site that I’m migrating to v5, and which could make use of the new Entries field for one of its blueprints.

The story goes like this:

  1. There is an artist blueprint with a website URL field.
  2. The client requested a way to fill in more website URLs for a single artist, and the quick-and-dirty solution was to create a couple new fields: website_two and website_three.
  3. This was during the Kirby 5 betas, so I knew that the Kirby 5 entries field type would be a better way to handle this eventually.

Now I’m trying to migrate the existing content from the old fields to the new one. I start by adding a new Entries field to the blueprint:

# New Entries field allowing multiple URLs
websites:
  type: entries
  label: Websites
  field:
    type: url

# Old workaround for multiple URLs (up to 3)
website:
  type: url
  label: Website 1
website_two:
  type: url
  label: Website 2
website_three:
  type: url
  label: Website 3

Ideally, I’d like to copy the data from the old fields into the new websites field, and then delete the old data, all in one pass, without losing my client’s content of course.

Basically, I need a script. I prototyped something locally using a route that only works for admin users:

function () {
	if (!kirby()->user()->isAdmin()) {
		return 'nope';
	}

	$pages = collection('artists-with-drafts');
	$count = 0;

	foreach ($pages as $page) {
		/** @var ArtistPage $page */
		$sites = [];
		$new = $page->content()->get('websites')?->yaml() ?? [];
		foreach($new as $value) {
			$value = is_string($value) ? trim($value) : '';
			if ($value !== '') $sites[] = $value;
		}

		$old = array_map(function($key) use ($page) {
			$value = $page->content()->get($key)->value;
			return is_string($value) ? trim($value) : '';
		}, ['website', 'website_two', 'website_three']);
		$unsaved = array_filter($old, function($value) use ($sites) {
			return $value !== '' && !in_array($value, $sites);
		});

		if (count($unsaved) == 0) continue;

		$page->update([
			'websites' => Yaml::encode(array_merge($sites, $unsaved)),
			'website' => null,
			'website_two' => null,
			'website_three' => null,
		]);
		$count++;
	}

	return "Updated $count pages.";
}

That seems to work well, but I have a few questions to hopefully improve on it:

  1. Is there a better place or way to run admin scripts on the content, with access to Kirby APIs? I’m happy running something on the command line on the server over SSH, rather than having to go through a public route.

  2. Are there shorter, nicer or more battle-tested ways to write content migrations for a page’s fields?

  3. Calling $page->update(['field_to_remove' => null]) will blank the field in the page’s .txt file, but not remove it. You need to also remove the field’s definition in the blueprint. But I’m afraid that removing it in the blueprint will lead to data loss if some page is saved by an editor before I can run my migration script. Any ways to handle this? Best workaround I have so far would be to do the migration in two steps: one to populate the new field, and then later on update the blueprint to remove the old fields.