| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- <?php
- /**
- * One-time data migrations, run by hand from admin/migrate.php after an update.
- *
- * Why this exists
- * ---------------
- * The gallery files have always evolved by lazy defaults: a new field is read
- * as `$gallery['x'] ?? default`, so a file written by an older version simply
- * keeps working and gains the field the next time something writes it. That is
- * still true — nothing here is required for the application to run.
- *
- * What it buys is a place to *finish* a schema change rather than leaving every
- * gallery in one of two shapes indefinitely: files get normalised in one pass,
- * broken leftovers are cleaned up, and each gallery records how far it has been
- * brought. The next schema change adds a numbered step to MIGRATIONS instead of
- * inventing a mechanism.
- *
- * Rules for a step
- * ----------------
- * - idempotent: running it twice must be indistinguishable from running it
- * once, because nothing stops an operator from pressing the button again
- * - written through json_update(), so it cannot fight an upload that lands
- * while it runs
- * - never destructive: a step normalises and repairs, it does not delete
- * images or S3 objects
- */
- declare(strict_types=1);
- /** The schema version a fully migrated gallery file carries. */
- const SCHEMA_VERSION = 1;
- /** Version number => the function that brings a gallery up to it. */
- const MIGRATIONS = [
- 1 => 'migrate_v1_topics',
- ];
- /** How far this gallery has been migrated. Files predating this read as 0. */
- function migrate_version_of(array $gallery): int
- {
- return (int)($gallery['schema_version'] ?? 0);
- }
- /** The steps a gallery still needs, in order. */
- function migrate_pending_for(array $gallery): array
- {
- $at = migrate_version_of($gallery);
- return array_values(array_filter(array_keys(MIGRATIONS), fn(int $v): bool => $v > $at));
- }
- /**
- * Every gallery with something left to do: slug => [title, versions].
- * An empty result means the installation is fully migrated.
- */
- function migrate_pending(): array
- {
- $out = [];
- foreach (galleries_all() as $gallery) {
- $pending = migrate_pending_for($gallery);
- if ($pending !== []) {
- $out[(string)$gallery['slug']] = [
- 'title' => (string)($gallery['title'] ?? $gallery['slug']),
- 'versions' => $pending,
- ];
- }
- }
- return $out;
- }
- /**
- * Run every outstanding step against every gallery.
- *
- * Each gallery is migrated under its own lock and committed on its own, so a
- * failure part-way through leaves the galleries already done in their new shape
- * and the rest exactly as they were — never a half-written file.
- *
- * Returns one row per gallery: ['slug', 'title', 'applied' => [versions],
- * 'notes' => [string], 'error' => ?string].
- */
- function migrate_run(): array
- {
- $rows = [];
- foreach (galleries_all() as $gallery) {
- $slug = (string)$gallery['slug'];
- $row = ['slug' => $slug, 'title' => (string)($gallery['title'] ?? $slug),
- 'applied' => [], 'notes' => [], 'error' => null];
- try {
- json_update(gallery_file($slug), function (array $g) use (&$row): ?array {
- if ($g === []) {
- return null; // deleted between the listing and now
- }
- $pending = migrate_pending_for($g);
- if ($pending === []) {
- return null;
- }
- foreach ($pending as $version) {
- $g = (MIGRATIONS[$version])($g, $row['notes']);
- $g['schema_version'] = $version;
- $row['applied'][] = $version;
- }
- return $g;
- });
- } catch (Throwable $e) {
- $row['error'] = $e->getMessage();
- }
- $rows[] = $row;
- }
- return $rows;
- }
- /**
- * v1 — topics.
- *
- * Topics are additive, so there is nothing to convert: a gallery written before
- * they existed is already valid and renders as one untopiced section. This step
- * only tidies:
- *
- * - gives every gallery a 'topics' key, so the field is present rather than
- * merely defaulted on read
- * - drops malformed topic entries (a hand-edited file, an interrupted write)
- * - clears an image's 'topic' when it names a topic that does not exist —
- * harmless at render time, where it reads as no topic, but not worth
- * carrying around
- *
- * It also flags what it deliberately cannot do: guest uploads made before this
- * version are indistinguishable from the photographer's own, because the image
- * record never recorded who uploaded it. They stay untopiced; only uploads made
- * from now on land in the guest topic.
- *
- * @param string[] $notes collects operator-facing remarks about this gallery
- */
- function migrate_v1_topics(array $gallery, array &$notes): array
- {
- $before = count($gallery['topics'] ?? []);
- $topics = gallery_topics($gallery);
- $gallery['topics'] = $topics;
- if ($before > count($topics)) {
- $notes[] = ($before - count($topics)) . ' malformed topic entr'
- . ($before - count($topics) === 1 ? 'y' : 'ies') . ' dropped.';
- }
- $known = gallery_topic_map($gallery);
- $cleared = 0;
- foreach ($gallery['images'] ?? [] as $i => $image) {
- $topic = (string)($image['topic'] ?? '');
- if ($topic !== '' && !isset($known[$topic])) {
- unset($gallery['images'][$i]['topic']);
- $cleared++;
- }
- }
- if ($cleared > 0) {
- $notes[] = "$cleared image(s) pointed at a topic that no longer exists; they are now without a topic.";
- }
- if (!empty($gallery['upload_key'])) {
- $notes[] = 'Guest uploads are enabled. Photos guests uploaded before this update '
- . 'cannot be identified afterwards and stay without a topic; new guest uploads '
- . 'go into "' . GALLERY_GUEST_TOPIC_NAME . '".';
- }
- return $gallery;
- }
|