From d09892d8273f2f890edb1676340835819dfc3b20 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Tue, 9 Jun 2026 23:23:46 +0200 Subject: [PATCH] feat(commands): add NPEdia link migration with audit trail Introduce coconut:update-npedia-links to migrate NPEdia source URLs to the new RIKEN domain across entries, collection pivots, and collection metadata, recording OwenIt audits for traceability. Closes #690 --- app/Console/Commands/UpdateNpediaLinks.php | 267 +++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 app/Console/Commands/UpdateNpediaLinks.php diff --git a/app/Console/Commands/UpdateNpediaLinks.php b/app/Console/Commands/UpdateNpediaLinks.php new file mode 100644 index 00000000..5fba2b4d --- /dev/null +++ b/app/Console/Commands/UpdateNpediaLinks.php @@ -0,0 +1,267 @@ +first(); + + if (! $collection) { + $this->error('NPEdia collection not found.'); + + return self::FAILURE; + } + + $batchSize = max(1, (int) $this->option('batch')); + $dryRun = (bool) $this->option('dry-run'); + + $entryCount = Entry::query() + ->where('collection_id', $collection->id) + ->where('link', 'like', self::OLD_ENTRY_LINK_PREFIX.'%') + ->count(); + + $pivotCount = DB::table('collection_molecule') + ->where('collection_id', $collection->id) + ->where('url', 'like', '%'.self::OLD_ENTRY_LINK_PREFIX.'%') + ->count(); + + $collectionNeedsUpdate = $collection->url === self::OLD_COLLECTION_URL; + + $this->info("NPEdia collection ID: {$collection->id}"); + $this->info('Entries to update: '.$entryCount); + $this->info('Collection-molecule pivots to update: '.$pivotCount); + $this->info('Collection URL to update: '.($collectionNeedsUpdate ? 'yes' : 'no')); + + if ($dryRun) { + $this->warn('Dry run enabled — no changes were written.'); + + $sampleEntry = Entry::query() + ->where('collection_id', $collection->id) + ->where('link', 'like', self::OLD_ENTRY_LINK_PREFIX.'%') + ->first(['reference_id', 'link']); + + if ($sampleEntry) { + $this->line('Sample entry link:'); + $this->line(' old: '.$sampleEntry->link); + $this->line(' new: '.$this->replaceEntryLink($sampleEntry->link)); + } + + $samplePivot = DB::table('collection_molecule') + ->where('collection_id', $collection->id) + ->where('url', 'like', '%'.self::OLD_ENTRY_LINK_PREFIX.'%') + ->value('url'); + + if ($samplePivot) { + $this->line('Sample pivot url:'); + $this->line(' old: '.$samplePivot); + $this->line(' new: '.$this->replaceEntryLink($samplePivot)); + } + + if ($collectionNeedsUpdate) { + $this->line('Collection url:'); + $this->line(' old: '.self::OLD_COLLECTION_URL); + $this->line(' new: '.self::NEW_COLLECTION_URL); + } + + return self::SUCCESS; + } + + try { + $this->updateCollectionUrl($collection, $collectionNeedsUpdate); + $entriesUpdated = $this->updateEntries($collection->id, $batchSize, $entryCount); + $pivotsUpdated = $this->updateCollectionMoleculePivots($collection->id, $batchSize, $pivotCount); + + $this->info('Updated collection URL: '.($collectionNeedsUpdate ? 'yes' : 'skipped (already current)')); + $this->info("Updated {$entriesUpdated} entries."); + $this->info("Updated {$pivotsUpdated} collection-molecule pivots."); + + return self::SUCCESS; + } catch (\Throwable $e) { + Log::error('NPEdia link update failed: '.$e->getMessage(), [ + 'exception' => $e, + ]); + $this->error('Update failed: '.$e->getMessage()); + + return self::FAILURE; + } + } + + private function updateCollectionUrl(Collection $collection, bool $needsUpdate): void + { + if (! $needsUpdate) { + return; + } + + $collection->url = self::NEW_COLLECTION_URL; + $collection->save(); + } + + private function updateEntries(int $collectionId, int $batchSize, int $total): int + { + $updated = 0; + $progressBar = $this->output->createProgressBar($total); + $progressBar->start(); + + Entry::query() + ->where('collection_id', $collectionId) + ->where('link', 'like', self::OLD_ENTRY_LINK_PREFIX.'%') + ->orderBy('id') + ->chunkById($batchSize, function ($entries) use (&$updated, $progressBar) { + DB::transaction(function () use ($entries, &$updated, $progressBar) { + foreach ($entries as $entry) { + $newLink = $this->replaceEntryLink($entry->link); + + if ($newLink === $entry->link) { + $progressBar->advance(); + + continue; + } + + $entry->link = $newLink; + $entry->meta_data = $this->replaceMetaDataUrl($entry->meta_data); + $entry->save(); + $updated++; + $progressBar->advance(); + } + }); + }); + + $progressBar->finish(); + $this->newLine(); + + return $updated; + } + + private function updateCollectionMoleculePivots(int $collectionId, int $batchSize, int $total): int + { + $updated = 0; + $progressBar = $this->output->createProgressBar($total); + $progressBar->start(); + + DB::table('collection_molecule') + ->where('collection_id', $collectionId) + ->where('url', 'like', '%'.self::OLD_ENTRY_LINK_PREFIX.'%') + ->orderBy('molecule_id') + ->chunk($batchSize, function ($pivots) use ($collectionId, &$updated, $progressBar) { + DB::transaction(function () use ($pivots, $collectionId, &$updated, $progressBar) { + $bulkUpdates = []; + $auditEvents = []; + + $moleculeIds = $pivots->pluck('molecule_id')->all(); + $molecules = Molecule::query()->findMany($moleculeIds)->keyBy('id'); + + foreach ($pivots as $pivot) { + $newUrl = $this->replaceEntryLink($pivot->url); + + if ($newUrl === $pivot->url) { + continue; + } + + $bulkUpdates[] = [ + 'molecule_id' => $pivot->molecule_id, + 'collection_id' => $collectionId, + 'url' => $newUrl, + 'reference' => $pivot->reference, + ]; + + $molecule = $molecules->get($pivot->molecule_id); + if ($molecule) { + $auditEvents[] = [ + 'molecule' => $molecule, + 'old' => ['url' => $pivot->url], + 'new' => ['url' => $newUrl], + ]; + } + + $updated++; + $progressBar->advance(); + } + + foreach (array_chunk($bulkUpdates, 100) as $chunk) { + DB::table('collection_molecule')->upsert( + $chunk, + ['molecule_id', 'collection_id'], + ['url'] + ); + } + + foreach (array_chunk($auditEvents, 50) as $chunk) { + foreach ($chunk as $audit) { + $molecule = $audit['molecule']; + $molecule->auditEvent = 'npediaLinkUpdate'; + $molecule->isCustomEvent = true; + $molecule->auditCustomOld = $audit['old']; + $molecule->auditCustomNew = $audit['new']; + Event::dispatch(AuditCustom::class, [$molecule]); + } + } + }); + }); + + $progressBar->finish(); + $this->newLine(); + + return $updated; + } + + private function replaceEntryLink(?string $value): ?string + { + if ($value === null || $value === '') { + return $value; + } + + return str_replace(self::OLD_ENTRY_LINK_PREFIX, self::NEW_ENTRY_LINK_PREFIX, $value); + } + + /** + * @param array|null $metaData + * @return array|null + */ + private function replaceMetaDataUrl(?array $metaData): ?array + { + if (! is_array($metaData) || ! isset($metaData['m']['url'])) { + return $metaData; + } + + $metaData['m']['url'] = $this->replaceEntryLink($metaData['m']['url']); + + return $metaData; + } +}