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; }