From ebe1cd79558492adc5cf95cc267eb389b17e7854 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:06:51 +0100 Subject: [PATCH 1/9] feat(organisms): add preset views for inactive and unmapped organisms - Rename 'inactive entries' to 'inactive' for organisms with molecule_count <= 0 - Add 'unmapped' preset view for organisms without a rank assigned - Fix query grouping for proper OR condition handling --- .../OrganismResource/Pages/ListOrganisms.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Filament/Dashboard/Resources/OrganismResource/Pages/ListOrganisms.php b/app/Filament/Dashboard/Resources/OrganismResource/Pages/ListOrganisms.php index 471c7096..40daa4da 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource/Pages/ListOrganisms.php +++ b/app/Filament/Dashboard/Resources/OrganismResource/Pages/ListOrganisms.php @@ -31,11 +31,20 @@ public function getPresetViews(): array ->badge(Organism::query()->where('molecule_count', '>', 0)->count()) ->preserveAll() ->default(), - 'inactive entries' => PresetView::make() + 'inactive' => PresetView::make() ->modifyQueryUsing(fn ($query) => $query->where('molecule_count', '<=', 0)) ->favorite() ->badge(Organism::query()->where('molecule_count', '<=', 0)->count()) ->preserveAll(), + 'unmapped' => PresetView::make() + ->modifyQueryUsing(fn ($query) => $query->where(function ($q) { + $q->whereNull('rank')->orWhere('rank', ''); + })) + ->favorite() + ->badge(Organism::query()->where(function ($q) { + $q->whereNull('rank')->orWhere('rank', ''); + })->count()) + ->preserveAll(), ]; } } From e83182651c172a1f97ae0155b0ff5defc62fbc3a Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:06:58 +0100 Subject: [PATCH 2/9] fix(organisms): correct isObsolete boolean check and family rank mapping - Fix isObsolete comparison from string 'false' to boolean false - Fix family rank incorrectly labeled as 'genus' in updateOrganismModel - Auto-generate slug if not exists when updating organism --- app/Console/Commands/MapOrganismNamesToOGG.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/MapOrganismNamesToOGG.php b/app/Console/Commands/MapOrganismNamesToOGG.php index fdf36322..5bb9fd85 100644 --- a/app/Console/Commands/MapOrganismNamesToOGG.php +++ b/app/Console/Commands/MapOrganismNamesToOGG.php @@ -54,7 +54,7 @@ public function handle() } else { $data = $this->getOLSIRI(explode(' ', $name)[0], 'family'); if ($data) { - $this->updateOrganismModel($name, $data, $organism, 'genus'); + $this->updateOrganismModel($name, $data, $organism, 'family'); $this->info("Mapped and updated: $name"); } else { $this->getGNFMatches($name, $organism); @@ -155,7 +155,7 @@ protected function getOLSIRI($name, $rank) if (isset($data['elements']) && count($data['elements']) > 0) { $element = $data['elements'][0]; - if (isset($element['iri'], $element['ontologyId']) && $element['isObsolete'] === 'false') { + if (isset($element['iri'], $element['ontologyId']) && $element['isObsolete'] === false) { if ($rank && $rank == 'species') { if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_species') { return urlencode($element['iri']); @@ -188,6 +188,10 @@ protected function updateOrganismModel($name, $iri, $organism = null, $rank = nu if ($organism) { $organism->iri = $iri; $organism->rank = $rank; + // Auto-generate slug if not exists + if (! $organism->slug) { + $organism->slug = \Illuminate\Support\Str::slug($name); + } $organism->save(); } else { $this->error("Organism not found in the database: $name"); From 3c553b7bace7fed018023dfe852f2bd0b6a36b9d Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:07:05 +0100 Subject: [PATCH 3/9] feat(organisms): enhance organisms table with molecule count and IRI - Add molecule_count column to organisms table (sortable) - Show IRI as description below organism name - Fix isObsolete boolean comparison in OLS lookup - Clean up unused code --- .../Dashboard/Resources/OrganismResource.php | 98 +++++++++---------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/app/Filament/Dashboard/Resources/OrganismResource.php b/app/Filament/Dashboard/Resources/OrganismResource.php index fe496d37..7364859b 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource.php +++ b/app/Filament/Dashboard/Resources/OrganismResource.php @@ -18,8 +18,6 @@ use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; use Filament\Resources\Resource; -use Filament\Schemas\Components\Grid; -use Filament\Schemas\Components\Group; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables; @@ -46,30 +44,19 @@ public static function form(Schema $schema): Schema { return $schema ->components([ - Grid::make() + Section::make('Organism Information') + ->description('Enter the organism details below') + ->schema(Organism::getForm()), + + Section::make('Similar Organisms') + ->description('View similar organisms in the database') ->schema([ - Group::make() - ->schema([ - Section::make('') - ->schema(Organism::getForm()), - ]) - ->columnSpan(1), - Group::make() - ->schema([ - Section::make('') - ->schema([ - OrganismsTable::make('Custom Table'), - // \Livewire\Livewire::mount('similar-organisms', ['organismId' => function ($get) { - // return $get('name'); - // }]), - ]), - ]) - ->hidden(function ($operation) { - return $operation === 'create'; - }) - ->columnSpan(1), + OrganismsTable::make('Custom Table'), ]) - ->columns(2), // Defines the number of columns in the grid + ->hidden(function ($operation) { + return $operation === 'create'; + }) + ->collapsible(), ]); } @@ -78,9 +65,13 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('name') - ->searchable(), + ->searchable() + ->description(fn (Organism $record): ?string => $record->iri ? urldecode($record->iri) : null), TextColumn::make('rank')->wrap() ->searchable(), + TextColumn::make('molecule_count') + ->label('Molecules') + ->sortable(), TextColumn::make('created_at') ->dateTime() ->sortable() @@ -219,6 +210,10 @@ protected static function updateOrganismModel($name, $iri, $organism = null, $ra $organism->name = $name; $organism->iri = $iri; $organism->rank = $rank; + // Auto-generate slug if not exists + if (! $organism->slug) { + $organism->slug = \Illuminate\Support\Str::slug($name); + } $organism->save(); } else { self::error("Organism not found in the database: $name"); @@ -228,46 +223,41 @@ protected static function updateOrganismModel($name, $iri, $organism = null, $ra protected static function getOLSIRI($name, $rank) { $client = new Client([ - 'base_uri' => 'https://www.ebi.ac.uk/ols4/api/', + 'base_uri' => 'https://www.ebi.ac.uk/ols4/api/v2/', ]); try { - $response = $client->get('search', [ + $response = $client->get('entities', [ 'query' => [ - 'q' => $name, - 'ontology' => ['ncbitaxon', 'efo', 'obi', 'uberon', 'taxrank'], - 'exact' => false, - 'obsoletes' => false, - 'format' => 'json', + 'search' => $name, + 'ontologyId' => 'ncbitaxon', + 'exactMatch' => true, + 'type' => 'class', ], ]); $data = json_decode($response->getBody(), true); - return $data; - // var_dump($data); - - // if (isset($data['elements']) && count($data['elements']) > 0) { + if (isset($data['elements']) && count($data['elements']) > 0) { - // $element = $data['elements'][0]; - // if (isset($element['iri'], $element['ontologyId']) && $element['isObsolete'] === 'false') { - // if ($rank && $rank == 'species') { - // if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_species') { - // return urlencode($element['iri']); - // } - // } elseif ($rank && $rank == 'genus') { - // if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_genus') { - // return urlencode($element['iri']); - // } - // } elseif ($rank && $rank == 'family') { - // if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_family') { - // return urlencode($element['iri']); - // } - // } - // } - // } + $element = $data['elements'][0]; + if (isset($element['iri'], $element['ontologyId']) && $element['isObsolete'] === false) { + if ($rank && $rank == 'species') { + if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_species') { + return urlencode($element['iri']); + } + } elseif ($rank && $rank == 'genus') { + if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_genus') { + return urlencode($element['iri']); + } + } elseif ($rank && $rank == 'family') { + if (isset($element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank']) && $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] == 'http://purl.obolibrary.org/obo/NCBITaxon_family') { + return urlencode($element['iri']); + } + } + } + } } catch (Exception $e) { - // Self::error("Error fetching IRI for $name: " . $e->getMessage()); Log::error("Error fetching IRI for $name: ".$e->getMessage()); } From bb6820e7089232129b658866cc68a10db8a0da42 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:07:13 +0100 Subject: [PATCH 4/9] feat(organisms): add multi-source taxonomy search with modal selection - Add taxonomy search modal with results from multiple sources - Search NCBI Taxonomy (OLS) with exact and similar matches - Search Global Names Finder (GNF) including unverified names - Normalize organism name case before searching - Add Google and Google Scholar hint action links - Allow user to select from search results and apply to form - Fix isObsolete boolean comparison --- app/Models/Organism.php | 461 ++++++++++++++++++++++++++++++++-------- 1 file changed, 371 insertions(+), 90 deletions(-) diff --git a/app/Models/Organism.php b/app/Models/Organism.php index 81a4640c..20ee4a7d 100644 --- a/app/Models/Organism.php +++ b/app/Models/Organism.php @@ -3,8 +3,6 @@ namespace App\Models; use Filament\Forms; -use Filament\Forms\Components\Actions\Action; -use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -29,6 +27,20 @@ class Organism extends Model implements Auditable 'slug', ]; + /** + * Boot the model and add event listeners. + */ + protected static function boot() + { + parent::boot(); + + static::saving(function ($organism) { + if ($organism->name && ! $organism->slug) { + $organism->slug = \Illuminate\Support\Str::slug($organism->name); + } + }); + } + public function molecules(): BelongsToMany { return $this->belongsToMany(Molecule::class)->distinct('molecule_id')->orderBy('molecule_id')->withTimestamps(); @@ -91,97 +103,366 @@ public static function getForm(): array { return [ Forms\Components\TextInput::make('name') + ->label('Organism Name') + ->placeholder('e.g., Homo sapiens, Escherichia coli') ->required() - ->unique(Organism::class, 'name') + ->unique(Organism::class, 'name', ignoreRecord: true) ->maxLength(255) - // ->suffixAction( - // Action::make('infoFromSources') - // ->icon('heroicon-m-clipboard') - // // ->fillForm(function ($record, callable $get): array { - // // $entered_name = $get('name'); - // // $name = ucfirst(trim($entered_name)); - // // $data = null; - // // $iri = null; - // // $organism = null; - // // $rank = null; - - // // if ($name && $name != '') { - // // $data = Self::getOLSIRI($name, 'species'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'species'); - // // Self::info("Mapped and updated: $name"); - // // } else { - // // $data = Self::getOLSIRI(explode(' ', $name)[0], 'genus'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'genus'); - // // Self::info("Mapped and updated: $name"); - // // } else { - // // $data = Self::getOLSIRI(explode(' ', $name)[0], 'family'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'genus'); - // // Self::info("Mapped and updated: $name"); - // // } else { - // // [$name, $iri, $organism, $rank] = Self::getGNFMatches($name, $record); - // // } - // // } - // // } - // // } - // // return [ - // // 'name' => $name, - // // 'iri' => $iri, - // // 'rank' => $rank, - // // ]; - // // }) - // // ->form([ - // // Forms\Components\TextInput::make('name')->readOnly(), - // // Forms\Components\TextInput::make('iri')->readOnly(), - // // Forms\Components\TextInput::make('rank')->readOnly(), - // // ]) - // // ->action(fn ( $record) => $record->advance()) - // ->modalContent(function ($record, $get): View { - // $name = ucfirst(trim($get('name'))); - // $data = null; - // // $iri = null; - // // $organism = null; - // // $rank = null; - - // if ($name && $name != '') { - // $data = self::getOLSIRI($name, 'species'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'species'); - // // } else { - // // $data = Self::getOLSIRI(explode(' ', $name)[0], 'genus'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'genus'); - // // } else { - // // $data = Self::getOLSIRI(explode(' ', $name)[0], 'family'); - // // if ($data) { - // // Self::updateOrganismModel($name, $data, $record, 'genus'); - // // } else { - // // [$name, $iri, $organism, $rank] = Self::getGNFMatches($name, $record); - // // } - // // } - // // } - // } - - // return view( - // 'forms.components.organism-info', - // [ - // 'data' => $data, - // ], - // ); - // }) - // ->action(function (array $data, Organism $record): void { - // // Self::updateOrganismModel($data['name'], $data['iri'], $record, $data['rank']); - // }) - // ->slideOver() - // ) - , + ->helperText('Enter the scientific name of the organism (genus and species)') + ->hintActions([ + \Filament\Actions\Action::make('searchGoogle') + ->label('Google') + ->icon('heroicon-m-globe-alt') + ->color('gray') + ->size('xs') + ->url(fn ($state) => $state ? 'https://www.google.com/search?q='.urlencode($state) : null, shouldOpenInNewTab: true) + ->visible(fn ($state) => filled($state)), + \Filament\Actions\Action::make('searchScholar') + ->label('Scholar') + ->icon('heroicon-m-academic-cap') + ->color('gray') + ->size('xs') + ->url(fn ($state) => $state ? 'https://scholar.google.com/scholar?q='.urlencode($state) : null, shouldOpenInNewTab: true) + ->visible(fn ($state) => filled($state)), + ]) + ->live(onBlur: true) + ->suffixAction( + fn (?string $state): \Filament\Actions\Action => \Filament\Actions\Action::make('lookupOrganism') + ->icon('heroicon-m-magnifying-glass') + ->label('Search') + ->tooltip('Search taxonomic databases for this organism') + ->form(function () use ($state) { + $name = trim($state ?? ''); + if (empty($name)) { + return [ + Forms\Components\Placeholder::make('empty') + ->content('Please enter an organism name first') + ->columnSpanFull(), + ]; + } + + $results = self::searchAllSources($name); + + if (empty($results)) { + return [ + Forms\Components\Placeholder::make('no_results') + ->content("No taxonomic data found for: {$name}") + ->columnSpanFull(), + ]; + } + + return [ + Forms\Components\Radio::make('selected_result') + ->label('Select a result') + ->options(collect($results)->mapWithKeys(function ($result, $index) { + $label = "{$result['name']} ({$result['rank']})"; + + return [$index => $label]; + })->toArray()) + ->descriptions(collect($results)->mapWithKeys(function ($result, $index) { + $desc = $result['source']; + if ($result['iri']) { + $desc .= ' • '.\Illuminate\Support\Str::limit($result['iri'], 50); + } + + return [$index => $desc]; + })->toArray()) + ->required() + ->columnSpanFull(), + Forms\Components\Hidden::make('results_data') + ->default(json_encode($results)), + ]; + }) + ->modalHeading('Taxonomy Search Results') + ->modalDescription(fn () => 'Results for: '.trim($state ?? '')) + ->modalSubmitActionLabel('Use Selected') + ->modalWidth('lg') + ->action(function (array $data, \Filament\Schemas\Components\Utilities\Set $set) { + if (! isset($data['selected_result']) || ! isset($data['results_data'])) { + return; + } + + $results = json_decode($data['results_data'], true); + $selected = $results[$data['selected_result']] ?? null; + + if ($selected) { + $set('iri', $selected['iri']); + $set('rank', $selected['rank']); + + \Filament\Notifications\Notification::make() + ->title('Organism data applied!') + ->body("Applied: {$selected['name']} ({$selected['rank']}) from {$selected['source']}") + ->success() + ->send(); + } + }) + ) + ->columnSpanFull(), + + Forms\Components\ToggleButtons::make('rank') + ->label('Taxonomic Rank') + ->options([ + 'domain' => 'Domain', + 'kingdom' => 'Kingdom', + 'phylum' => 'Phylum', + 'class' => 'Class', + 'order' => 'Order', + 'family' => 'Family', + 'genus' => 'Genus', + 'species' => 'Species', + 'subspecies' => 'Subspecies', + 'variety' => 'Variety', + 'strain' => 'Strain', + ]) + ->default('species') + ->inline() + ->helperText('Select the taxonomic classification level of this organism') + ->columnSpanFull(), + Forms\Components\TextInput::make('iri') - ->label('IRI') - ->maxLength(255), - Forms\Components\TextInput::make('rank') - ->maxLength(255), + ->label('IRI (Internationalized Resource Identifier)') + ->placeholder('https://example.org/organisms/...') + ->maxLength(255) + ->url() + ->helperText('Optional: Enter the ontology IRI/URI (e.g., from NCBI Taxonomy, OLS)') + ->suffixIcon('heroicon-m-link') + ->columnSpanFull(), + ]; + } + + /** + * Search all sources and return all results + */ + public static function searchAllSources($name) + { + $results = []; + + // Normalize name + $name = ucfirst(strtolower(trim($name))); + $genus = explode(' ', $name)[0]; + + $client = new \GuzzleHttp\Client([ + 'base_uri' => 'https://www.ebi.ac.uk/ols4/api/v2/', + ]); + + // Search OLS/NCBI Taxonomy + try { + $olsResults = self::searchOLS($client, $name); + $results = array_merge($results, $olsResults); + + // Also search by genus if different from full name + if ($genus !== $name) { + $genusResults = self::searchOLS($client, $genus); + $results = array_merge($results, $genusResults); + } + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('OLS search error: '.$e->getMessage()); + } + + // Search Global Names Finder + try { + $gnfResults = self::searchGNF($name); + $results = array_merge($results, $gnfResults); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('GNF search error: '.$e->getMessage()); + } + + // Remove duplicates based on IRI + $seen = []; + $uniqueResults = []; + foreach ($results as $result) { + $key = $result['iri'] ?? $result['name']; + if (! isset($seen[$key])) { + $seen[$key] = true; + $uniqueResults[] = $result; + } + } + + return $uniqueResults; + } + + /** + * Search OLS/NCBI Taxonomy and return all matches + */ + protected static function searchOLS($client, $name) + { + $results = []; + + try { + // Try exact match first + $response = $client->get('entities', [ + 'query' => [ + 'search' => $name, + 'ontologyId' => 'ncbitaxon', + 'exactMatch' => true, + 'type' => 'class', + ], + ]); + + $data = json_decode($response->getBody(), true); + $results = array_merge($results, self::parseOLSResults($data, 'Exact match')); + + // Also try non-exact match for more results + $response = $client->get('entities', [ + 'query' => [ + 'search' => $name, + 'ontologyId' => 'ncbitaxon', + 'exactMatch' => false, + 'type' => 'class', + 'size' => 5, + ], + ]); + + $data = json_decode($response->getBody(), true); + $results = array_merge($results, self::parseOLSResults($data, 'Similar match')); + + } catch (\Exception $e) { + // Silent fail + } + + return $results; + } + + /** + * Parse OLS API results + */ + protected static function parseOLSResults($data, $matchType) + { + $results = []; + $rankMap = [ + 'http://purl.obolibrary.org/obo/NCBITaxon_species' => 'species', + 'http://purl.obolibrary.org/obo/NCBITaxon_genus' => 'genus', + 'http://purl.obolibrary.org/obo/NCBITaxon_family' => 'family', + 'http://purl.obolibrary.org/obo/NCBITaxon_order' => 'order', + 'http://purl.obolibrary.org/obo/NCBITaxon_class' => 'class', + 'http://purl.obolibrary.org/obo/NCBITaxon_phylum' => 'phylum', + 'http://purl.obolibrary.org/obo/NCBITaxon_kingdom' => 'kingdom', + 'http://purl.obolibrary.org/obo/NCBITaxon_domain' => 'domain', + 'http://purl.obolibrary.org/obo/NCBITaxon_subspecies' => 'subspecies', + 'http://purl.obolibrary.org/obo/NCBITaxon_varietas' => 'variety', + 'http://purl.obolibrary.org/obo/NCBITaxon_strain' => 'strain', ]; + + if (isset($data['elements']) && count($data['elements']) > 0) { + foreach ($data['elements'] as $element) { + if (isset($element['iri']) && $element['isObsolete'] === false) { + $rankIri = $element['http://purl.obolibrary.org/obo/ncbitaxon#has_rank'] ?? null; + $rank = $rankMap[$rankIri] ?? 'unknown'; + $label = $element['label'][0] ?? 'Unknown'; + + $results[] = [ + 'name' => $label, + 'iri' => $element['iri'], + 'rank' => $rank, + 'source' => 'NCBI Taxonomy (OLS)', + 'match_type' => $matchType, + ]; + } + } + } + + return $results; + } + + /** + * Search Global Names Finder and return all matches + */ + protected static function searchGNF($name) + { + $results = []; + + try { + $client = new \GuzzleHttp\Client; + $response = $client->post('https://finder.globalnames.org/api/v1/find', [ + 'json' => [ + 'text' => $name, + 'bytesOffset' => false, + 'returnContent' => false, + 'uniqueNames' => true, + 'ambiguousNames' => true, + 'noBayes' => false, + 'oddsDetails' => false, + 'language' => 'eng', + 'wordsAround' => 0, + 'verification' => true, + 'allMatches' => true, + ], + ]); + + $responseBody = json_decode($response->getBody(), true); + + if (isset($responseBody['names']) && count($responseBody['names']) > 0) { + foreach ($responseBody['names'] as $r_name) { + $matchType = $r_name['verification']['matchType'] ?? null; + $foundName = $r_name['name'] ?? $name; + + // Handle verified matches (Exact, Fuzzy, PartialExact, PartialFuzzy) + if (in_array($matchType, ['Exact', 'Fuzzy', 'PartialExact', 'PartialFuzzy'])) { + // Get best result + $bestResult = $r_name['verification']['bestResult'] ?? null; + if ($bestResult) { + $iri = $bestResult['outlink'] ?? null; + $dataSource = $bestResult['dataSourceTitleShort'] ?? 'Unknown'; + $matchedName = $bestResult['matchedName'] ?? $foundName; + $ranks = $bestResult['classificationRanks'] ?? null; + + $rank = 'unknown'; + if ($ranks) { + $ranks = rtrim($ranks, '|'); + $ranksArray = explode('|', $ranks); + $rank = strtolower(end($ranksArray)); + } + + $results[] = [ + 'name' => $matchedName, + 'iri' => $iri, + 'rank' => $rank, + 'source' => "Global Names ({$dataSource})", + 'match_type' => $matchType, + ]; + } + + // Also include other results if available + if (isset($r_name['verification']['results'])) { + foreach (array_slice($r_name['verification']['results'], 0, 3) as $result) { + $iri = $result['outlink'] ?? null; + $dataSource = $result['dataSourceTitleShort'] ?? 'Unknown'; + $matchedName = $result['matchedName'] ?? $name; + $ranks = $result['classificationRanks'] ?? null; + + $rank = 'unknown'; + if ($ranks) { + $ranks = rtrim($ranks, '|'); + $ranksArray = explode('|', $ranks); + $rank = strtolower(end($ranksArray)); + } + + $results[] = [ + 'name' => $matchedName, + 'iri' => $iri, + 'rank' => $rank, + 'source' => "Global Names ({$dataSource})", + 'match_type' => $matchType, + ]; + } + } + } + // Handle NoMatch - GNF recognized the name but couldn't verify it + elseif ($matchType === 'NoMatch' && $foundName) { + $results[] = [ + 'name' => $foundName, + 'iri' => null, + 'rank' => 'unknown', + 'source' => 'Global Names (Unverified)', + 'match_type' => 'Name recognized', + ]; + } + } + } + } catch (\Exception $e) { + // Silent fail + } + + return $results; } } From e5f36c7446ac43305b21727d05433a2e14824162 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:07:21 +0100 Subject: [PATCH 5/9] feat(organisms): redesign similar organisms UI - Update card layout with table structure for reliable rendering - Show organism name in bold italic (scientific name style) - Display molecule count and IRI link - Add always-visible Edit button with inline styles - Fix array key error for single-word organism names - Fix urldecode on column name string - Improve empty state with search icon --- app/Forms/Components/OrganismsTable.php | 7 +- .../components/organisms-table.blade.php | 68 +++++++++++++------ 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/app/Forms/Components/OrganismsTable.php b/app/Forms/Components/OrganismsTable.php index 2b655434..41368315 100644 --- a/app/Forms/Components/OrganismsTable.php +++ b/app/Forms/Components/OrganismsTable.php @@ -16,11 +16,14 @@ public static function make(?string $name = null): static public function getTableData($record_name) { - return Organism::select('id', 'name', urldecode('iri'), 'molecule_count') + return Organism::select('id', 'name', 'iri', 'molecule_count') ->where('molecule_count', '>', 0) ->where(function ($q) use ($record_name) { $arr = explode(' ', $record_name); - $sanitised_org_name = $arr[0].' '.$arr[1]; + // Use genus + species if available, otherwise just genus + $sanitised_org_name = count($arr) > 1 + ? $arr[0].' '.$arr[1] + : $arr[0]; $q->where([ ['name', '!=', $record_name], ['name', 'ILIKE', '%'.$sanitised_org_name.'%'], diff --git a/resources/views/forms/components/organisms-table.blade.php b/resources/views/forms/components/organisms-table.blade.php index f3758ea6..78c60a54 100644 --- a/resources/views/forms/components/organisms-table.blade.php +++ b/resources/views/forms/components/organisms-table.blade.php @@ -1,22 +1,48 @@ -
- @foreach($getTableData($getRecord()->name) as $row) -
-
-

- {{ $row['name'] }} -

-

- Molecules - {{ $row['molecule_count'] }} -

-

- - IRI - {{ $row['iri'] }} - -

+
+ @forelse($getTableData($getRecord()->name) as $row) +
+ + + + + +
+ +

+ {{ $row['name'] }} +

+ + +

+ Molecules - {{ number_format($row['molecule_count']) }} +

+ + + @if($row['iri']) + + IRI - {{ $row['iri'] }} + + @endif +
+ + Edit + +
- - Edit - -
- @endforeach -
\ No newline at end of file + @empty +
+
+ + + +
+

No similar organisms found

+

No organisms with matching genus in the database

+
+ @endforelse +
From c615a289878ef179eaa56f75c083f870f3fcc8d7 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Thu, 27 Nov 2025 16:07:41 +0100 Subject: [PATCH 6/9] style(organisms): set full width for create/edit organism pages - Add getMaxContentWidth() returning 'full' for better form layout --- .../Resources/OrganismResource/Pages/CreateOrganism.php | 5 +++++ .../Resources/OrganismResource/Pages/EditOrganism.php | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/app/Filament/Dashboard/Resources/OrganismResource/Pages/CreateOrganism.php b/app/Filament/Dashboard/Resources/OrganismResource/Pages/CreateOrganism.php index ec8e5681..4ee8f1da 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource/Pages/CreateOrganism.php +++ b/app/Filament/Dashboard/Resources/OrganismResource/Pages/CreateOrganism.php @@ -8,4 +8,9 @@ class CreateOrganism extends CreateRecord { protected static string $resource = OrganismResource::class; + + public function getMaxContentWidth(): ?string + { + return 'full'; + } } diff --git a/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php b/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php index 230a968c..8a1f8b72 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php +++ b/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php @@ -16,4 +16,9 @@ protected function getHeaderActions(): array DeleteAction::make(), ]; } + + public function getMaxContentWidth(): ?string + { + return 'full'; + } } From 9bfa4d0848369b86c54509bda0890c5b4059ccc6 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Fri, 28 Nov 2025 18:50:23 +0100 Subject: [PATCH 7/9] feat(organisms): click table rows to open view instead of edit - Enable ViewOrganism page route - Add ViewAction button to table row actions - Set recordUrl to navigate to view page on row click --- .../Dashboard/Resources/OrganismResource.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/Filament/Dashboard/Resources/OrganismResource.php b/app/Filament/Dashboard/Resources/OrganismResource.php index 7364859b..1407f529 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource.php +++ b/app/Filament/Dashboard/Resources/OrganismResource.php @@ -17,14 +17,13 @@ use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Actions\ViewAction; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; -use Filament\Tables; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use GuzzleHttp\Client; -use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Log; @@ -92,7 +91,8 @@ public static function table(Table $table): Table ->color('info') ->icon('heroicon-o-link') ->iconButton(), - // Tables\Actions\ViewAction::make(), + ViewAction::make() + ->iconButton(), EditAction::make() ->iconButton(), ]) @@ -100,7 +100,10 @@ public static function table(Table $table): Table BulkActionGroup::make([ DeleteBulkAction::make(), ]), - ]); + ]) + ->recordUrl( + fn (Organism $record): string => self::getUrl('view', ['record' => $record]), + ); } public static function getRelations(): array @@ -120,7 +123,7 @@ public static function getPages(): array 'index' => ListOrganisms::route('/'), 'create' => CreateOrganism::route('/create'), 'edit' => EditOrganism::route('/{record}/edit'), - // 'view' => Pages\ViewOrganism::route('/{record}'), + 'view' => Pages\ViewOrganism::route('/{record}'), ]; } From 30d095d36942d0dd30ca82d362a223842109a725 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Fri, 28 Nov 2025 18:50:34 +0100 Subject: [PATCH 8/9] feat(organisms): improve similar organisms query - Handle concatenated names (e.g., 'Micheliachampaca' -> 'Michelia') - Handle names without CamelCase using first 8 chars as genus - Add similarity ranking for better result ordering - Sort by similarity rank then by molecule count --- app/Forms/Components/OrganismsTable.php | 59 ++++++++++++++++++++----- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/app/Forms/Components/OrganismsTable.php b/app/Forms/Components/OrganismsTable.php index 41368315..d929f22a 100644 --- a/app/Forms/Components/OrganismsTable.php +++ b/app/Forms/Components/OrganismsTable.php @@ -16,19 +16,58 @@ public static function make(?string $name = null): static public function getTableData($record_name) { + $arr = explode(' ', $record_name); + $genus = null; + + if (count($arr) > 1) { + // Has space - use first word as genus + $genus = $arr[0]; + } else { + // No space - try to split CamelCase (e.g., "Micheliachampaca" -> "Michelia") + // Look for capital letter in the middle of the string + if (preg_match('/^([A-Z][a-z]+)([A-Z][a-z]+)/', $record_name, $matches)) { + // CamelCase detected - use first part as genus + $genus = $matches[1]; + } else { + // No CamelCase - try common genus lengths (5-10 chars) + // Most genera are 5-10 characters long + $genus = substr($record_name, 0, min(strlen($record_name), 8)); + } + } + return Organism::select('id', 'name', 'iri', 'molecule_count') + ->selectRaw(' + CASE + WHEN LOWER(name) = LOWER(?) THEN 0 + WHEN name ILIKE ? THEN 1 + WHEN name ILIKE ? THEN 2 + WHEN name ILIKE ? THEN 3 + ELSE 4 + END as similarity_rank + ', [ + $genus, // Exact genus match + $genus.' %', // Genus + space + species (exact genus) + $genus.'%', // Starts with genus + '%'.$genus.'%', // Contains genus + ]) ->where('molecule_count', '>', 0) - ->where(function ($q) use ($record_name) { - $arr = explode(' ', $record_name); - // Use genus + species if available, otherwise just genus - $sanitised_org_name = count($arr) > 1 - ? $arr[0].' '.$arr[1] - : $arr[0]; - $q->where([ - ['name', '!=', $record_name], - ['name', 'ILIKE', '%'.$sanitised_org_name.'%'], - ]); + ->where(function ($q) use ($genus, $record_name) { + $q->where('name', '!=', $record_name) + ->where(function ($subQ) use ($genus, $record_name) { + // Flexible search patterns + $subQ->where('name', 'ILIKE', $genus.'%') // Starts with genus + ->orWhere('name', 'ILIKE', $genus.' %') // Genus followed by space + ->orWhere('name', 'ILIKE', '%'.$genus.'%'); // Contains genus + + // Also search without the concatenated version + // e.g., "Apocynumcannabinum" should also find similar concatenated names + if (strlen($record_name) > 10 && ! str_contains($record_name, ' ')) { + $subQ->orWhere('name', 'ILIKE', substr($record_name, 0, 6).'%'); + } + }); }) + ->orderBy('similarity_rank') + ->orderByDesc('molecule_count') ->get(); } } From 441d1c00c3fa31450d5a3551ded5c203522b1083 Mon Sep 17 00:00:00 2001 From: Venkata Nainala Date: Fri, 28 Nov 2025 18:50:42 +0100 Subject: [PATCH 9/9] feat(organisms): add merge functionality for similar organisms - Add merge button to similar organisms table - Implement Filament modal for merge confirmation - Show FROM/INTO organisms and transfer counts in modal - Transfer molecules and sample locations to target organism - Handle duplicate relations gracefully - Update molecule counts after merge - Redirect to target organism after successful merge --- .../OrganismResource/Pages/EditOrganism.php | 117 +++++++++++++ .../components/organisms-table.blade.php | 154 +++++++++++++----- 2 files changed, 229 insertions(+), 42 deletions(-) diff --git a/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php b/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php index 8a1f8b72..f2d4659d 100644 --- a/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php +++ b/app/Filament/Dashboard/Resources/OrganismResource/Pages/EditOrganism.php @@ -3,8 +3,11 @@ namespace App\Filament\Dashboard\Resources\OrganismResource\Pages; use App\Filament\Dashboard\Resources\OrganismResource; +use App\Models\Organism; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Illuminate\Support\Facades\DB; class EditOrganism extends EditRecord { @@ -21,4 +24,118 @@ public function getMaxContentWidth(): ?string { return 'full'; } + + /** + * Merge current organism into target organism + */ + public function mergeOrganism(int $targetId): void + { + $this->executeMerge($targetId); + } + + /** + * Execute the merge operation + */ + protected function executeMerge(int $targetOrganismId): void + { + $currentOrganism = $this->record; + $targetOrganism = Organism::findOrFail($targetOrganismId); + + if ($currentOrganism->id === $targetOrganismId) { + Notification::make() + ->title('Cannot merge organism into itself') + ->danger() + ->send(); + + return; + } + + DB::beginTransaction(); + + try { + // Get all molecule_organism records for the current organism + $currentRelations = DB::table('molecule_organism') + ->where('organism_id', $currentOrganism->id) + ->get(); + + $transferred = 0; + $skipped = 0; + + foreach ($currentRelations as $relation) { + // Check if this exact combination already exists for the target organism + $exists = DB::table('molecule_organism') + ->where('organism_id', $targetOrganismId) + ->where('molecule_id', $relation->molecule_id) + ->where(function ($query) use ($relation) { + $query->where('sample_location_id', $relation->sample_location_id) + ->orWhere(function ($q) use ($relation) { + $q->whereNull('sample_location_id') + ->where(DB::raw('1'), $relation->sample_location_id === null ? 1 : 0); + }); + }) + ->where(function ($query) use ($relation) { + $query->where('geo_location_id', $relation->geo_location_id) + ->orWhere(function ($q) use ($relation) { + $q->whereNull('geo_location_id') + ->where(DB::raw('1'), $relation->geo_location_id === null ? 1 : 0); + }); + }) + ->where(function ($query) use ($relation) { + $query->where('ecosystem_id', $relation->ecosystem_id) + ->orWhere(function ($q) use ($relation) { + $q->whereNull('ecosystem_id') + ->where(DB::raw('1'), $relation->ecosystem_id === null ? 1 : 0); + }); + }) + ->exists(); + + if (! $exists) { + // Update the record to point to the target organism + DB::table('molecule_organism') + ->where('id', $relation->id) + ->update(['organism_id' => $targetOrganismId]); + $transferred++; + } else { + // Delete the duplicate record from current organism + DB::table('molecule_organism') + ->where('id', $relation->id) + ->delete(); + $skipped++; + } + } + + // Update molecule counts for both organisms + $currentOrganism->molecule_count = DB::table('molecule_organism') + ->where('organism_id', $currentOrganism->id) + ->distinct('molecule_id') + ->count('molecule_id'); + $currentOrganism->save(); + + $targetOrganism->molecule_count = DB::table('molecule_organism') + ->where('organism_id', $targetOrganismId) + ->distinct('molecule_id') + ->count('molecule_id'); + $targetOrganism->save(); + + DB::commit(); + + Notification::make() + ->title('Organisms merged successfully') + ->body("Transferred {$transferred} molecule relations to \"{$targetOrganism->name}\". {$skipped} duplicates were skipped.") + ->success() + ->send(); + + // Redirect to the target organism's edit page + $this->redirect(OrganismResource::getUrl('edit', ['record' => $targetOrganism])); + + } catch (\Exception $e) { + DB::rollBack(); + + Notification::make() + ->title('Merge failed') + ->body($e->getMessage()) + ->danger() + ->send(); + } + } } diff --git a/resources/views/forms/components/organisms-table.blade.php b/resources/views/forms/components/organisms-table.blade.php index 78c60a54..46dd68d4 100644 --- a/resources/views/forms/components/organisms-table.blade.php +++ b/resources/views/forms/components/organisms-table.blade.php @@ -1,48 +1,118 @@ -
+@php + $pageComponent = $getLivewire(); +@endphp +
@forelse($getTableData($getRecord()->name) as $row) -
- - - + +
- -

- {{ $row['name'] }} -

+
+ + + + - - -
+

+ {{ $row['name'] }} +

+

+ Molecules - {{ number_format($row['molecule_count']) }} +

+ @if($row['iri']) + + IRI - {{ $row['iri'] }} + + @endif +
+
+ + View + - -

- Molecules - {{ number_format($row['molecule_count']) }} -

- - - @if($row['iri']) - - IRI - {{ $row['iri'] }} - - @endif -
- - Edit - -
-
+ + Merge + + +
+
@empty -
-
- - - +
+ +

No similar organisms found

+

No organisms with matching genus in the database

+
+ @endforelse + + + + +
+ + Merge Organism
-

No similar organisms found

-

No organisms with matching genus in the database

+
+ +
+
+
+
+ From: + {{ $getRecord()->name }} +
+
+ +
+
+ Into: + +
+
+
+ +
+

Will transfer:

+
    +
  • • {{ number_format($getRecord()->molecule_count) }} molecules
  • +
  • • {{ number_format($getRecord()->sampleLocations->count()) }} sample locations
  • +
+
+ +

+ ⚠️ This action cannot be undone. +

- @endforelse + + + + Cancel + + + Yes, Merge + + +