From c3e30b2cfd595c1b8ff87df26a8285313e6eef7f Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 6 Aug 2024 14:53:04 -0700 Subject: [PATCH] implement association-based search - Add vertical-align to crowdsource css to override value set within reset.css - Force refresh of crowdsource.css within client's browsers crowdsource editor styling issue - resolve vertical-align issue, which persisted when opening in crowdsourcing mode and then switching between long and short forms - Fix fieldset padding issue within image popout Api media 2024 06 (#1463) - Fix route bug interfering with POST write calls - Add recordID UUID to POST call - Improve error output media object isn't found (400 error) - Improve standardization of all response output - Add sort fields to api documentation - Add function that attempts to determine mime type via file header. Has added benefit of ensuring input file exists. - Update swagger documentation Media API - Add ability to match images based on recordID UUID within PATCH call added confirmation windows for project and editor delete icons (#1454) [3.1] -burpsuite-fix: do not add taxa to query if there are special characters or quotes (#1436) * do not add taxa to query if there are special characters or quotes * cherry pick fix in 6250abde5 where getTaxonWhereFrag was getting un-cleaned taxon string * respond to code review feedback Occurrence download bug (#1478) - searchvar term is being sanitized prior to being set as a session variable, which is converting & to &, thus interfering with parsing of all terms after the first term. Translating & to & fixes the issue. - The cleanOutStr function santizes output using htmlspecialchars. The htmlspecialchars on line 638 will double sanitize output, thus converting "east & west" to "east &amp; west". - Variable is getting sanitized within index.php with htmlspecialchars twice. Once on line 12 and again on line 205 Addresses GH issue: https://github.com/BioKIC/Symbiota/issues/1470 subfunctionalize the chip button click to the handleRemoval method only run getAssociatedTaxaSqlFragment when the relevant form field values are present in the query fix the behavior of the chip removal persist search terms fix the non-persisting taxon type selection remove var_dump fix bug where association search is not playing well with taxon accordion get the synonym searching to work get the sql command for wider taxa to work but not to display successfully on the UI yet remove spurious TODO and correct issue where usethes-associations and usethes were reversed Fix bug where synonym choice is not being respected Fix bug where synonym choice is not being respected Add all reciprocal relationships to drop down menu and Exclude non-relevant controlled vocabulary from the association dropdown list Accommodate no particular relationship in the search Accommodate no particular taxon in the search remove print statement add none as an option to the association dropdown and handle its logic remove cruft from classes/AssociationManager.php clean up OccurrenceListManager fix bug where family-based search was not working make the taxstatus join only happen when family is selected from the dropdown fix bug where there were duplicates in the relationship dropdown respond to self code review remove cruft add a button to populate missing reverse relationships make improvements to populating the missing reverse relationships and also add more ways to match the search results remove reverse association section because the new entries in the database fixed that issue. This speeds up the search by a huge amount add reverse search back in get union statement to work, dramatically increasing speed of search creates an inverse record if it can for each new association made and handles an error on that front more gracefully now make pushing the button take the user back to the search page relax uniqueness constraint on index UQ_omoccurassoc_sciname relax uniqueness constraint on index UQ_omoccurassoc_sciname fix bug where searching for taxon and then relationship separately was breaking exclude reverse entries in omoccurassociations with basisOfRecord values of scriptGenerated from search results of getAssociationArr by default --- classes/AssociationManager.php | 150 +++++++++++++---- classes/OccurrenceListManager.php | 4 +- classes/OccurrenceManager.php | 18 +- classes/OccurrenceTaxaManager.php | 154 ++---------------- classes/OmAssociations.php | 26 ++- collections/list.php | 45 ++++- collections/search/index.php | 1 + collections/search/js/searchform.js | 8 - .../3.0/patches/db_schema_patch-3.1.sql | 2 + content/lang/collections/search/index.es.php | 3 + content/lang/collections/search/index.fr.php | 3 + 11 files changed, 213 insertions(+), 201 deletions(-) diff --git a/classes/AssociationManager.php b/classes/AssociationManager.php index 654de72287..00fd1f9e7f 100644 --- a/classes/AssociationManager.php +++ b/classes/AssociationManager.php @@ -1,5 +1,7 @@ isEditor = true; @@ -44,35 +47,122 @@ public function getRelationshipTypes(){ } } - public function getAssociatedRecords($associationArr){ - $sql=''; - - if(array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'none'){ - $familyJoinStr = ''; - $shouldUseFamily = array_key_exists('associated-taxa', $associationArr) && $associationArr['associated-taxa'] == '3'; - if($shouldUseFamily) $familyJoinStr = 'LEFT JOIN taxstatus ts ON o.tidinterpreted = ts.tid'; - - - // "Forward" association - $relationshipType = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? $associationArr['relationship'] : 'IS NOT NULL'; - $relationshipStr = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? ("='" . $relationshipType . "'") : (' IS NOT NULL'); - // $sql .= "AND (o.occid IN (SELECT DISTINCT o.occid FROM omoccurrences o INNER JOIN omoccurassociations oa on o.occid=oa.occid WHERE oa.relationship" . $relationshipStr . " "; - $sql .= "AND (o.occid IN (SELECT DISTINCT o.occid FROM omoccurrences o INNER JOIN omoccurassociations oa on o.occid=oa.occid " . $familyJoinStr . " WHERE oa.relationship" . $relationshipStr . " "; - $sql .= $this->getAssociatedTaxonWhereFrag($associationArr) . ')'; - - // @TODO handle situation where the associationType is external and there's no occidAssociate; pull resourceUrl instead? - //something like: - // $sql =. 'SELECT DISTINCT resourceUrl from omoccurassociations WHERE associationType="externalOccurrence" AND occidAssociate IS NULL AND relationship = ' . $relationshipType . ' AND '; - - // "Reverse" association - $reverseAssociationType = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? $this->getInverseRelationshipOf($relationshipType) : 'IS NOT NULL'; - $reverseRelationshipStr = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? ("='" . $reverseAssociationType . "'") : (' IS NOT NULL'); - $sql .= " OR o.occid IN (SELECT DISTINCT oa.occidAssociate FROM omoccurrences o INNER JOIN omoccurassociations oa on o.occid=oa.occid INNER JOIN omoccurdeterminations od ON oa.occid=od.occid " . $familyJoinStr . " where oa.relationship " . $reverseRelationshipStr . " "; //isCurrent="1" AND my thought was that we want these results to be as relaxed as possible - $sql .= $this->getAssociatedTaxonWhereFrag($associationArr) . ')'; + public function establishInverseRelationshipRecords(){ + $sql = "SELECT * FROM omoccurassociations where occid IS NOT NULL AND occidAssociate IS NOT NULL;"; + if($statement = $this->conn->prepare($sql)){ + $statement->execute(); + $result = $statement->get_result(); + while ($row = $result->fetch_assoc()) { + // $returnVal = $row['inverseRelationship']; + if(!$this->hasInverseRecord($row)){ + $this->createInverseRecord($row); + } + // if that record has an inverse present, do nothing + // else, create an inverse record + } + $statement->close(); + // return $returnVal; + }else{ + return ''; + } + } + + private function hasInverseRecord($record){ + // var_dump($record); + $sql = "SELECT * FROM omoccurassociations WHERE occidAssociate = ? AND occid = ? and relationship = ?;"; + $recordOccid = array_key_exists('occid', $record) ? $record['occid'] : ''; + $recordOccidAssociate = array_key_exists('occidAssociate', $record) ? $record['occidAssociate'] : ''; + $relationship = array_key_exists('relationship', $record) ? $record['relationship'] : ''; + $inverseRelationship = $this->getInverseRelationshipOf($relationship); + if($statement = $this->conn->prepare($sql)){ + $statement->bind_param('iis', $recordOccid, $recordOccidAssociate, $inverseRelationship); + $statement->execute(); + $result = $statement->get_result(); + $returnVal = false; + if ($row = $result->fetch_assoc()) { + // $returnVal = $row['inverseRelationship']; + $returnVal = true; + } + $statement->close(); + return $returnVal; + }else{ + return ''; + } + } + public function createInverseRecord($record){ + $recordOccid = array_key_exists('occid', $record) ? $record['occid'] : ''; + $recordOccidAssociate = array_key_exists('occidAssociate', $record) ? $record['occidAssociate'] : ''; + $relationship = array_key_exists('relationship', $record) ? $record['relationship'] : ''; + $inverseRelationship = $this->getInverseRelationshipOf($relationship); + $verbatimsciname = $this->getCorrespondingVerbatimsciname($recordOccid); + $createdUid = $GLOBALS['SYMB_UID']; + $basisOfRecord = 'scriptGenerated'; + $sql = 'INSERT INTO omoccurassociations(occid, occidAssociate, relationship, basisOfRecord, createdUid, verbatimsciname)'; + $sql .= ' VALUES(?,?,?,?,?,?);'; + $returnVal = false; + // $this->resetConnection(); + $shouldCreateInverseRecord = !empty($recordOccid) && !empty($recordOccidAssociate) && !empty($relationship) && !empty($inverseRelationship) && !empty($recordOccid); + if($shouldCreateInverseRecord && $statement = $this->conn->prepare($sql)){ + $statement->bind_param('iissis', $recordOccidAssociate, $recordOccid, $inverseRelationship, $basisOfRecord, $createdUid, $verbatimsciname); + if($statement->execute()){ + $returnVal = true; + } + $statement->close(); + } + // $this->resetConnectionToRead(); + return $returnVal; + } + + private function getCorrespondingVerbatimsciname($targetOccid){ + $sql = 'SELECT sciname from omoccurrences where occid=?'; + $returnVal = ''; + if($statement = $this->conn->prepare($sql)){ + $statement->bind_param('s', $targetOccid); + $statement->execute(); + $result = $statement->get_result(); + if ($row = $result->fetch_assoc()) { + $returnVal = array_key_exists('sciname', $row) ? $row['sciname'] : ''; + } + $statement->close(); } - return $sql; + return $returnVal; } + // protected function resetConnection(){ + // $this->conn = MySQLiConnectionFactory::getCon('write'); + // } + + // protected function resetConnectionToRead(){ + // $this->conn = MySQLiConnectionFactory::getCon('readonly'); + // } + + public function getAssociatedRecords($associationArr) { + $sql = ''; + + if (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'none') { + $familyJoinStr = ''; + $shouldUseFamily = array_key_exists('associated-taxa', $associationArr) && $associationArr['associated-taxa'] == '3'; + if ($shouldUseFamily) $familyJoinStr = 'LEFT JOIN taxstatus ts ON o.tidinterpreted = ts.tid'; + + // "Forward" association + $relationshipType = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? $associationArr['relationship'] : 'IS NOT NULL'; + $relationshipStr = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? ("='" . $relationshipType . "'") : ' IS NOT NULL'; + + $forwardSql = "SELECT o.occid FROM omoccurrences o INNER JOIN omoccurassociations oa ON o.occid = oa.occid " . $familyJoinStr . " WHERE oa.relationship " . $relationshipStr . " "; + $forwardSql .= $this->getAssociatedTaxonWhereFrag($associationArr); + + // "Reverse" association + $reverseAssociationType = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? $this->getInverseRelationshipOf($relationshipType) : 'IS NOT NULL'; + $reverseRelationshipStr = (array_key_exists('relationship', $associationArr) && $associationArr['relationship'] !== 'any') ? ("='" . $reverseAssociationType . "'") : ' IS NOT NULL'; + + $reverseSql = "SELECT oa.occidAssociate FROM omoccurrences o INNER JOIN omoccurassociations oa ON o.occid = oa.occid INNER JOIN omoccurdeterminations od ON oa.occid = od.occid " . $familyJoinStr . " WHERE oa.relationship " . $reverseRelationshipStr . " "; + $reverseSql .= $this->getAssociatedTaxonWhereFrag($associationArr); + + $sql .= "AND (o.occid IN (SELECT occid FROM ( " . $forwardSql . " UNION " . $reverseSql . " ) AS occids)"; + } + return $sql; +} + public function getAssociatedTaxonWhereFrag($associationArr){ $sqlWhereTaxa = ''; if(isset($associationArr['taxa'])){ @@ -122,21 +212,21 @@ public function getAssociatedTaxonWhereFrag($associationArr){ //Return matches that are not linked to thesaurus if($rankid > 179){ if($this->exactMatchOnly) $sqlWhereTaxa .= 'OR (o.sciname = "' . $term . '") '; - else $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term . "%') "; + else $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term . "%') OR (oa.verbatimsciname LIKE '" . $term . "%') "; } } else{ //Protect against someone trying to download big pieces of the occurrence table through the user interface if(strlen($term) < 4) $term .= ' '; if($this->exactMatchOnly){ - $sqlWhereTaxa .= 'OR (o.sciname = "' . $term . '") '; + $sqlWhereTaxa .= 'OR (o.sciname = "' . $term . '") OR (oa.verbatimsciname LIKE "' . $term . '%") '; } else{ - $sqlWhereTaxa .= 'OR (o.sciname LIKE "' . $term . '%") '; + $sqlWhereTaxa .= 'OR (o.sciname LIKE "' . $term . '%") OR (oa.verbatimsciname LIKE "' . $term . '%") '; if(!strpos($term,' _ ')){ //Accommodate for formats of hybrid designations within input and target data (e.g. x, multiplication sign, etc) $term2 = preg_replace('/^([^\s]+\s{1})/', '$1 _ ', $term); - $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term2 . "%') "; + $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term2 . "%') OR (oa.verbatimsciname LIKE '" . $term . "%') "; } } } diff --git a/classes/OccurrenceListManager.php b/classes/OccurrenceListManager.php index 687e794b6f..89b0f13d28 100644 --- a/classes/OccurrenceListManager.php +++ b/classes/OccurrenceListManager.php @@ -40,7 +40,7 @@ public function getSpecimenMap($pageRequest,$cntPerPage){ $pageRequest = ($pageRequest - 1)*$cntPerPage; } $sql .= ' LIMIT ' . $pageRequest . ',' . $cntPerPage; - // echo '
Spec sql: ' . $sql . '
'; exit; + // echo '
Spec sql: ' . $sql . '
'; exit; // @TODO HERE $result = $this->conn->query($sql); if($result){ $securityCollArr = array(); @@ -125,7 +125,7 @@ private function setImages($occArr,&$retArr){ private function setRecordCnt($sqlWhere){ if($sqlWhere){ $sql = "SELECT COUNT(DISTINCT o.occid) AS cnt FROM omoccurrences o ".$this->getTableJoins($sqlWhere).$sqlWhere; - // echo "
Count sql: ".$sql."
"; + // echo "
Count sql: ".$sql."
"; exit; $result = $this->conn->query($sql); if($result){ if($row = $result->fetch_object()){ diff --git a/classes/OccurrenceManager.php b/classes/OccurrenceManager.php index 6a3ef03dc4..8635345ad0 100644 --- a/classes/OccurrenceManager.php +++ b/classes/OccurrenceManager.php @@ -21,6 +21,7 @@ class OccurrenceManager extends OccurrenceTaxaManager { public function __construct($type='readonly'){ parent::__construct($type); if(array_key_exists('reset',$_REQUEST) && $_REQUEST['reset']) $this->reset(); + $this->associationManager = new AssociationManager(); $this->readRequestVariables(); $langTag = ''; if(!empty($GLOBALS['LANG_TAG'])) $langTag = $GLOBALS['LANG_TAG']; @@ -28,7 +29,6 @@ public function __construct($type='readonly'){ include_once($GLOBALS['SERVER_ROOT'] . '/content/lang/classes/OccurrenceManager.' . $langTag . '.php'); else include_once($GLOBALS['SERVER_ROOT'] . '/content/lang/classes/OccurrenceManager.en.php'); $this->LANG = $LANG; - $this->associationManager = new AssociationManager(); } public function __destruct(){ @@ -60,15 +60,11 @@ public function getSqlWhere(){ protected function setSqlWhere(){ $sqlWhere = ''; - // var_dump($this->searchTermArr); - // $deleteMe = $this->associationManager->getAssociatedTaxonWhereFrag($this->associationArr); - // var_dump($deleteMe); if(array_key_exists("targetclid",$this->searchTermArr) && is_numeric($this->searchTermArr["targetclid"])){ if(!$this->voucherManager){ $this->setChecklistVariables($this->searchTermArr['targetclid']); } $voucherVariableArr = $this->voucherManager->getQueryVariableArr(); - // var_dump($voucherVariableArr); if($voucherVariableArr){ if(isset($voucherVariableArr['association-type'])) $this->searchTermArr['association-type'] = $voucherVariableArr['association-type']; if(isset($voucherVariableArr['taxontype-association'])) $this->searchTermArr['taxontype-association'] = $voucherVariableArr['taxontype-association']; @@ -129,11 +125,8 @@ protected function setSqlWhere(){ $this->displaySearchArr[] = $this->LANG['DATASETS'] . ': ' . $this->getDatasetTitle($this->searchTermArr['datasetid']); } $sqlWhere .= $this->getTaxonWhereFrag(); - // echo "
this dot getTaxonWhereFrag() is: " . $this->getTaxonWhereFrag() . "
"; - // echo "
sqlWhere before getting the association taxa is: " . $sqlWhere . "
"; $hasValidRelationship = isset(($this->associationArr['relationship'])) && $this->associationArr['relationship']!=='none'; - // $hasValidAssociatedTaxon = isset($this->associationArr['search']); - if($hasValidRelationship){ // || $hasValidAssociatedTaxon // @TODO + if($hasValidRelationship){ $sqlWhere = substr_replace($sqlWhere,'',-1); $sqlWhere .= $this->associationManager->getAssociatedRecords($this->associationArr) . ')'; } @@ -547,7 +540,6 @@ protected function getTableJoins($sqlWhere){ $sqlJoin .= 'INNER JOIN taxaenumtree e ON o.tidinterpreted = e.tid '; } if(strpos($sqlWhere,'ts.family')){ - echo 'ts.family entered'; $sqlJoin .= 'LEFT JOIN taxstatus ts ON o.tidinterpreted = ts.tid '; } if(strpos($sqlWhere,'ds.datasetid')){ @@ -668,7 +660,6 @@ public function getQueryTermStr(){ $retStr .= '&taxontype=1'; } } - // var_dump($this->associationArr); $patternOfOnlyLettersDigitsAndSpaces = '/^[a-zA-Z0-9\s\-]*$/'; // TOOD accommodate symbols associated with extinct taxa, hybrid crosses, and abbreviations with periods, e.g. "var."? if(isset($this->associationArr['search'])){ if (preg_match($patternOfOnlyLettersDigitsAndSpaces, $this->associationArr['search'])==1) { @@ -715,11 +706,9 @@ private function getDatasetTitle($dsIdStr){ } protected function readRequestVariables(){ - // var_dump($_REQUEST); // @TODO this is an intervention point if(array_key_exists('searchvar',$_REQUEST)){ $parsedArr = array(); $taxaArr = array(); - // parse_str($_REQUEST['searchvar'], $parsedArr); $searchVar = str_replace('&', '&', $_REQUEST['searchvar']); parse_str($searchVar, $parsedArr); @@ -735,7 +724,6 @@ protected function readRequestVariables(){ unset($parsedArr['taxontype']); } $this->setTaxonRequestVariable($taxaArr); - // $this->setAssociationRequestVariable(); } foreach($parsedArr as $k => $v){ $k = $this->cleanInputStr($k); @@ -776,7 +764,6 @@ protected function readRequestVariables(){ if($hasEverythingRequiredForAssociationSearch){ $this->setAssociationRequestVariable(); } - // var_dump($this->searchTermArr); if(array_key_exists('country',$_REQUEST)){ $country = $this->cleanInputStr($_REQUEST['country']); if($country){ @@ -1010,7 +997,6 @@ protected function readRequestVariables(){ $this->searchTermArr['footprintwkt'] = $this->cleanInputStr($_REQUEST['footprintwkt']); } - // var_dump($this->searchTermArr); } private function setChecklistVariables($clid){ diff --git a/classes/OccurrenceTaxaManager.php b/classes/OccurrenceTaxaManager.php index 11ad552e6a..bab1d9ae2e 100644 --- a/classes/OccurrenceTaxaManager.php +++ b/classes/OccurrenceTaxaManager.php @@ -60,8 +60,6 @@ public function setAssociationRequestVariable($inputArr = null, $exactMatchOnly $this->associationArr['relationship'] = $associationTypeStr; } - // $taxonTypeAssociationStr = $this->cleanAndAssignGeneric('taxontype-association', $inputArr); - if($associatedTaxonStr){ $this->associationArr['search'] = $associatedTaxonStr; $this->setAssociationUseThes($inputArr, 'usethes-associations'); @@ -82,14 +80,10 @@ public function setAssociationRequestVariable($inputArr = null, $exactMatchOnly } - // @TODO $taxonType2Str - // var_dump($this->associationArr['taxa']); - - // LEFT OFF HERE figuring out the remaining TODOs here and testing it out } protected function processSingleTerm($searchTerm, $searchTermkey, $defaultTaxaType){ - $this->associationTaxaSearchTerms[$searchTermkey] = $searchTerm; // @TODO generalize + $this->associationTaxaSearchTerms[$searchTermkey] = $searchTerm; $taxaType = $defaultTaxaType; if($defaultTaxaType == TaxaSearchType::ANY_NAME) { $searchTermName = explode(': ',$searchTerm); @@ -100,7 +94,7 @@ protected function processSingleTerm($searchTerm, $searchTermkey, $defaultTaxaTy $taxaType = TaxaSearchType::SCIENTIFIC_NAME; } } - $this->setSciNamesByVerns($searchTerm, $this->associationArr); // @TODO test whether this works + $this->setSciNamesByVerns($searchTerm, $this->associationArr); $this->setTaxonRankAndType($searchTerm, $taxaType, 'usethes-associations'); } @@ -123,7 +117,7 @@ protected function setTaxonRankAndType($searchTerm, $taxaType, $useThesId='useth if($this->associationArr[$useThesId]){ $sql .= 'INNER JOIN taxstatus ts ON t.tid = ts.tidaccepted INNER JOIN taxa t2 ON ts.tid = t2.tid - WHERE (ts.taxauthid = ?) AND (t2.sciname IN(?))'; // @TODO does this work? + WHERE (ts.taxauthid = ?) AND (t2.sciname IN(?))'; $typeStr .= 'is'; array_push($bindingArr, $this->taxAuthId, $this->cleanInStr($searchTerm)); } else{ @@ -132,10 +126,7 @@ protected function setTaxonRankAndType($searchTerm, $taxaType, $useThesId='useth array_push($bindingArr, $this->cleanInStr($searchTerm)); } } - // var_dump($sql); if ($statement = $this->conn->prepare($sql)) { - // var_dump($typeStr); - // var_dump(...$bindingArr); $statement->bind_param($typeStr,...$bindingArr); $statement->execute(); $result = $statement->get_result(); @@ -162,7 +153,7 @@ protected function setTaxonRankAndType($searchTerm, $taxaType, $useThesId='useth protected function setAssociationSynonyms(){ if(isset($this->associationArr['taxa'])){ - foreach($this->associationArr['taxa'] as $searchStr => $searchArr){ // @TODO LEFT OFF HERE + foreach($this->associationArr['taxa'] as $searchStr => $searchArr){ if(isset($searchArr['tid']) && $searchArr['tid']){ foreach($searchArr['tid'] as $tid => $rankid){ $accArr = array(); @@ -195,7 +186,7 @@ protected function addAcceptedChildrenToArray($tid, $rankid, $accArr, $searchStr $accArr[] = $r1['tid']; if(!isset($this->associationArr['taxa'][$r1['sciname']])){ if($rankid == 220) $this->associationArr['taxa'][$r1['sciname']]['tid'][$r1['tid']] = $r1['rankid']; - else $this->associationArr['taxa'][$searchStr]['TID_BATCH'][$r1['tid']] = ''; // @TODO generalize and use in setSynonyms as well to DRY up code + else $this->associationArr['taxa'][$searchStr]['TID_BATCH'][$r1['tid']] = ''; } } } @@ -218,7 +209,7 @@ protected function addSynonymsOfAcceptedTaxaToArray($accArr, $rankid, $searchStr $result = $statement2->get_result(); if($result->num_rows > 0){ while($r2 = $result->fetch_assoc()){ - if($rankid >= 220) $this->associationArr['taxa'][$r2['accepted']]['synonyms'][$r2['tid']] = $r2['sciname']; // @TODO generalize and use in setSynonyms as well to DRY up code + if($rankid >= 220) $this->associationArr['taxa'][$r2['accepted']]['synonyms'][$r2['tid']] = $r2['sciname']; else $this->associationArr['taxa'][$searchStr]['TID_BATCH'][$r2['tid']] = ''; } } @@ -227,7 +218,7 @@ protected function addSynonymsOfAcceptedTaxaToArray($accArr, $rankid, $searchStr } protected function setAssociationUseThes($inputArr = null, $useThesId='usethes'){ - $this->associationArr[$useThesId] = 0; // @TODO generalize and use in setTaxonRequestVariable as well to DRY up code + $this->associationArr[$useThesId] = 0; if(isset($inputArr[$useThesId]) && $inputArr[$useThesId]){ $this->associationArr[$useThesId] = 1; } @@ -236,7 +227,7 @@ protected function setAssociationUseThes($inputArr = null, $useThesId='usethes') } } - protected function setAndGetAssociationDefaultTaxaType($inputArr = null){ // @TOD generalize this work with setTaxonRequestVariable as well + protected function setAndGetAssociationDefaultTaxaType($inputArr = null){ $defaultTaxaType = TaxaSearchType::SCIENTIFIC_NAME; if(isset($inputArr['associated-taxa']) && is_numeric($inputArr['associated-taxa'])){ $defaultTaxaType = $inputArr['associated-taxa']; @@ -375,20 +366,20 @@ private function setSciNamesByVerns(&$searchTerm, &$alternateTaxaArr = null) { while($row = $result->fetch_object()){ $vernName = $row->VernacularName; if($row->rankid == 140){ - if(array_key_exists('taxa', $alternateTaxaArr)){ - $alternateTaxaArr['taxa'][$vernName]['families'][] = $row->sciname; // @TODO check that these values update correctly + if(is_array($alternateTaxaArr) && array_key_exists('taxa', $alternateTaxaArr)){ + $alternateTaxaArr['taxa'][$vernName]['families'][] = $row->sciname; } else{ $this->taxaArr['taxa'][$vernName]['families'][] = $row->sciname; } } else{ - if(array_key_exists('taxa', $alternateTaxaArr)){ + if(is_array($alternateTaxaArr) && array_key_exists('taxa', $alternateTaxaArr)){ $alternateTaxaArr['taxa'][$vernName]['scinames'][] = $row->sciname; }else{ $this->taxaArr['taxa'][$vernName]['scinames'][] = $row->sciname; } } - if(array_key_exists('taxa', $alternateTaxaArr)){ + if(is_array($alternateTaxaArr) && array_key_exists('taxa', $alternateTaxaArr)){ $alternateTaxaArr['taxa'][$vernName]['tid'][$row->tid] = $row->rankid; }else{ $this->taxaArr['taxa'][$vernName]['tid'][$row->tid] = $row->rankid; @@ -446,120 +437,9 @@ private function setSynonyms(){ } } - // public function getAssociatedTaxonWhereFrag(){ - // $sqlWhereTaxa = ''; - // if(isset($this->associationArr['taxa'])){ - // // var_dump($this->associationArr); - // $tidInArr = array(); - // $taxonType = $this->associationArr['associated-taxa']; - // foreach($this->associationArr['taxa'] as $searchTaxon => $searchArr){ - // if(isset($searchArr['taxontype'])) $taxonType = $searchArr['taxontype']; - // if($taxonType == TaxaSearchType::TAXONOMIC_GROUP){ - // //Class, order, or other higher rank - // if(isset($searchArr['tid'])){ - // $tidArr = array_keys($searchArr['tid']); - // //$sqlWhereTaxa .= 'OR (o.tidinterpreted IN(SELECT DISTINCT tid FROM taxaenumtree WHERE (taxauthid = '.$this->taxAuthId.') AND (parenttid IN('.trim($tidStr,',').') OR (tid = '.trim($tidStr,',').')))) '; - // $sqlWhereTaxa .= 'OR (e.parenttid IN('.implode(',', $tidArr).') '; - // $sqlWhereTaxa .= 'OR (e.tid IN('.implode(',', $tidArr).')) '; - // if(isset($searchArr['synonyms'])) $sqlWhereTaxa .= 'OR (e.tid IN('.implode(',',array_keys($searchArr['synonyms'])).')) '; - // //$tidInArr = array_merge($tidInArr,$tidArr); - // //if(isset($searchArr['synonyms'])) $tidInArr = array_merge($tidInArr,array_keys($searchArr['synonyms'])); - // $sqlWhereTaxa .= ') '; - // } - // else{ - // //Unable to find higher taxon within taxonomic tree, thus return nothing - // $sqlWhereTaxa .= 'OR (o.tidinterpreted = 0) '; - // } - // } - // elseif($taxonType == TaxaSearchType::FAMILY_ONLY){ - // //$sqlWhereTaxa .= 'OR ((o.family = "'.$searchTaxon.'") OR (o.sciname = "'.$searchTaxon.'")) '; - // //$sqlWhereTaxa .= 'OR (((ts.family = "'.$searchTaxon.'") AND (ts.taxauthid = '.$this->taxAuthId.')) OR (o.family = "'.$searchTaxon.'") OR (o.sciname = "'.$searchTaxon.'")) '; - // //$sqlWhereTaxa .= 'OR (((ts.family = "'.$searchTaxon.'") AND (ts.taxauthid = '.$this->taxAuthId.')) OR o.sciname = "'.$searchTaxon.'") '; - // if(isset($searchArr['tid'])){ - // $tidArr = array_keys($searchArr['tid']); - // $sqlWhereTaxa .= 'OR ((ts.family = "'.$searchTaxon.'") OR (ts.tid IN('.implode(',', $tidArr).'))) '; - // } - // else{ - // $sqlWhereTaxa .= 'OR ((o.family = "'.$searchTaxon.'") OR (o.sciname = "'.$searchTaxon.'")) '; - // } - // } - // else{ - // if($taxonType == TaxaSearchType::COMMON_NAME){ - // $famArr = $this->setCommonNameWhereTerms($searchArr, $tidInArr); - // if($famArr) $sqlWhereTaxa .= 'OR (o.family IN("'.implode('","',$famArr).'")) '; - // } - // if(isset($searchArr['TID_BATCH'])){ - // $tidInArr = array_merge($tidInArr, array_keys($searchArr['TID_BATCH'])); - // if(isset($searchArr['tid'])) $tidInArr = array_merge($tidInArr, array_keys($searchArr['tid'])); - // } - // else{ - // $term = $this->cleanInStr(trim($searchTaxon,'%')); - // //$term = preg_replace('/\s{1}.{1,2}\s{1}/', ' _ ', $term); - // $term = preg_replace(array('/\s{1}x\s{1}/','/\s{1}X\s{1}/','/\s{1}\x{00D7}\s{1}/u'), ' _ ', $term); - // if(array_key_exists('tid',$searchArr)){ - // $rankid = current($searchArr['tid']); - // $tidArr = array_keys($searchArr['tid']); - // //$sqlWhereTaxa .= "OR (o.tidinterpreted IN(".implode(',',$tidArr).")) "; - // $tidInArr = array_merge($tidInArr, $tidArr); - // //Return matches that are not linked to thesaurus - // if($rankid > 179){ - // if($this->exactMatchOnly) $sqlWhereTaxa .= 'OR (o.sciname = "' . $term . '") '; - // else $sqlWhereTaxa .= 'OR (o.sciname LIKE "' . $term . '%") '; - // } - // } - // else{ - // //Protect against someone trying to download big pieces of the occurrence table through the user interface - // if(strlen($term) < 4) $term .= ' '; - // /* - // if(strpos($term, ' ') || strpos($term, '%')){ - // //Return matches for "Pinus a" - // $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term . "%') "; - // } - // else{ - // $sqlWhereTaxa .= "OR (o.sciname LIKE '" . $term . " %') "; - // } - // */ - // if($this->exactMatchOnly){ - // $sqlWhereTaxa .= 'OR (o.sciname = "' . $term . '") '; - // } - // else{ - // $sqlWhereTaxa .= 'OR (o.sciname LIKE "' . $term . '%") '; - // if(!strpos($term,' _ ')){ - // //Accommodate for formats of hybrid designations within input and target data (e.g. x, multiplication sign, etc) - // $term2 = preg_replace('/^([^\s]+\s{1})/', '$1 _ ', $term); - // $sqlWhereTaxa .= 'OR (o.sciname LIKE "' . $term2 . '%") '; - // } - // } - // } - // } - // if(array_key_exists('synonyms',$searchArr)){ - // $synArr = $searchArr['synonyms']; - // if($synArr){ - // if($taxonType == TaxaSearchType::SCIENTIFIC_NAME || $taxonType == TaxaSearchType::COMMON_NAME){ - // foreach($synArr as $synTid => $sciName){ - // if(strpos($sciName,'aceae') || strpos($sciName,'idae')){ - // $sqlWhereTaxa .= 'OR (o.family = "' . $sciName . '") '; - // } - // } - // } - // //$sqlWhereTaxa .= 'OR (o.tidinterpreted IN('.implode(',',array_keys($synArr)).')) '; - // $tidInArr = array_merge($tidInArr,array_keys($synArr)); - // } - // } - // } - // } - // if($tidInArr) $sqlWhereTaxa .= 'OR (o.tidinterpreted IN('.implode(',',array_unique($tidInArr)).')) '; - // $sqlWhereTaxa = 'AND ('.trim(substr($sqlWhereTaxa,3)).') '; - // if(strpos($sqlWhereTaxa,'e.parenttid')) $sqlWhereTaxa .= 'AND (e.taxauthid = '.$this->taxAuthId.') '; - // if(strpos($sqlWhereTaxa,'ts.family')) $sqlWhereTaxa .= 'AND (ts.taxauthid = '.$this->taxAuthId.') '; - // } - // if($sqlWhereTaxa) return $sqlWhereTaxa; - // else return false; - // } public function getTaxonWhereFrag(){ $sqlWhereTaxa = ''; - // var_dump($this->taxaArr); if(isset($this->taxaArr['taxa'])){ $tidInArr = array(); $taxonType = $this->taxaArr['taxontype']; @@ -570,12 +450,9 @@ public function getTaxonWhereFrag(){ //Class, order, or other higher rank if(isset($searchArr['tid'])){ $tidArr = array_keys($searchArr['tid']); - //$sqlWhereTaxa .= 'OR (o.tidinterpreted IN(SELECT DISTINCT tid FROM taxaenumtree WHERE (taxauthid = '.$this->taxAuthId.') AND (parenttid IN('.trim($tidStr,',').') OR (tid = '.trim($tidStr,',').')))) '; $sqlWhereTaxa .= 'OR (e.parenttid IN('.implode(',', $tidArr).') '; $sqlWhereTaxa .= 'OR (e.tid IN('.implode(',', $tidArr).')) '; if(isset($searchArr['synonyms'])) $sqlWhereTaxa .= 'OR (e.tid IN('.implode(',',array_keys($searchArr['synonyms'])).')) '; - //$tidInArr = array_merge($tidInArr,$tidArr); - //if(isset($searchArr['synonyms'])) $tidInArr = array_merge($tidInArr,array_keys($searchArr['synonyms'])); $sqlWhereTaxa .= ') '; } else{ @@ -584,9 +461,6 @@ public function getTaxonWhereFrag(){ } } elseif($taxonType == TaxaSearchType::FAMILY_ONLY){ - //$sqlWhereTaxa .= 'OR ((o.family = "'.$searchTaxon.'") OR (o.sciname = "'.$searchTaxon.'")) '; - //$sqlWhereTaxa .= 'OR (((ts.family = "'.$searchTaxon.'") AND (ts.taxauthid = '.$this->taxAuthId.')) OR (o.family = "'.$searchTaxon.'") OR (o.sciname = "'.$searchTaxon.'")) '; - //$sqlWhereTaxa .= 'OR (((ts.family = "'.$searchTaxon.'") AND (ts.taxauthid = '.$this->taxAuthId.')) OR o.sciname = "'.$searchTaxon.'") '; if(isset($searchArr['tid'])){ $tidArr = array_keys($searchArr['tid']); $sqlWhereTaxa .= 'OR ((ts.family = "'.$cleanedSearchTaxon.'") OR (ts.tid IN('.implode(',', $tidArr).'))) '; @@ -606,12 +480,10 @@ public function getTaxonWhereFrag(){ } else{ $term = $this->cleanInStr(trim($searchTaxon,'%')); - //$term = preg_replace('/\s{1}.{1,2}\s{1}/', ' _ ', $term); $term = preg_replace(array('/\s{1}x\s{1}/','/\s{1}X\s{1}/','/\s{1}\x{00D7}\s{1}/u'), ' _ ', $term); if(array_key_exists('tid',$searchArr)){ $rankid = current($searchArr['tid']); $tidArr = array_keys($searchArr['tid']); - //$sqlWhereTaxa .= "OR (o.tidinterpreted IN(".implode(',',$tidArr).")) "; $tidInArr = array_merge($tidInArr, $tidArr); //Return matches that are not linked to thesaurus if($rankid > 179){ @@ -722,7 +594,7 @@ public function getTaxaSearchStr(){ public function getAssociationSearchStr(){ $str = ''; - if(isset($this->associationArr['relationship'])){ + if(isset($this->associationArr['relationship']) && $this->associationArr['relationship'] != 'none'){ $str = 'Taxa that have the following association: '; $str .= $this->associationArr['relationship']; } diff --git a/classes/OmAssociations.php b/classes/OmAssociations.php index ccc3b3ecb7..72fa827d9d 100644 --- a/classes/OmAssociations.php +++ b/classes/OmAssociations.php @@ -2,6 +2,7 @@ include_once('Manager.php'); include_once('OccurrenceUtilities.php'); include_once('UuidFactory.php'); +include_once('AssociationManager.php'); class OmAssociations extends Manager{ @@ -24,11 +25,14 @@ public function __destruct(){ parent::__destruct(); } - public function getAssociationArr($filter = null){ + public function getAssociationArr($filter = null, $excludeScriptGenerated = true){ $retArr = array(); $relOccidArr = array(); $uidArr = array(); $sql = 'SELECT assocID, occid, '.implode(', ', array_keys($this->schemaMap)).', modifiedUid, modifiedTimestamp, createdUid, initialTimestamp FROM omoccurassociations WHERE '; + if($excludeScriptGenerated){ + $sql .= "basisOfRecord IS NULL OR basisOfRecord != 'scriptGenerated' AND "; + } if($this->assocID) $sql .= '(assocID = '.$this->assocID.') '; elseif($filter == 'FULL')$sql .= '(occid = '.$this->occid.' OR occidAssociate = '.$this->occid.') '; elseif($this->occid) $sql .= '(occid = '.$this->occid.') '; @@ -37,6 +41,7 @@ public function getAssociationArr($filter = null){ $sql .= 'AND '.$field.' = "'.$this->cleanInStr($cond).'" '; } } + echo 'sql is: ' . $sql; if($rs = $this->conn->query($sql)){ while($r = $rs->fetch_assoc()){ $retArr[$r['assocID']] = $r; @@ -120,6 +125,7 @@ public function insertAssociation($inputArr){ $paramArr[] = $value; } $sql .= ') VALUES('.trim($sqlValues, ', ').') '; + $insertedRecord = null; if($stmt = $this->conn->prepare($sql)){ $stmt->bind_param($this->typeStr, ...$paramArr); try{ @@ -127,6 +133,19 @@ public function insertAssociation($inputArr){ if($stmt->affected_rows || !$stmt->error){ $this->assocID = $stmt->insert_id; $status = true; + + $fetchSql = 'SELECT * FROM omoccurassociations WHERE assocID = ?'; + if ($fetchStmt = $this->conn->prepare($fetchSql)) { + $fetchStmt->bind_param('i', $this->assocID); + $fetchStmt->execute(); + $result = $fetchStmt->get_result(); + if ($result->num_rows > 0) { + $insertedRecord = $result->fetch_assoc(); + } else { + $this->errorMessage = 'Record not found after insertion.'; + } + $fetchStmt->close(); + } } else $this->errorMessage = $stmt->error; } @@ -139,6 +158,11 @@ public function insertAssociation($inputArr){ } else $this->errorMessage = 'ERROR preparing statement for omoccurassociations insert: '.$this->conn->error; } + + if($status == true){ + $associationManager = new AssociationManager; + $associationManager->createInverseRecord($insertedRecord); + } return $status; } diff --git a/collections/list.php b/collections/list.php index cc5837bd31..c73ecec28f 100644 --- a/collections/list.php +++ b/collections/list.php @@ -3,8 +3,8 @@ if ($LANG_TAG != 'en' && file_exists($SERVER_ROOT . '/content/lang/collections/list.' . $LANG_TAG . '.php')) include_once($SERVER_ROOT . '/content/lang/collections/list.' . $LANG_TAG . '.php'); else include_once($SERVER_ROOT . '/content/lang/collections/list.en.php'); include_once($SERVER_ROOT . '/classes/OccurrenceListManager.php'); +include_once($SERVER_ROOT.'/classes/AssociationManager.php'); header("Content-Type: text/html; charset=" . $CHARSET); -// var_dump($_REQUEST); $taxonFilter = array_key_exists('taxonfilter', $_REQUEST) ? filter_var($_REQUEST['taxonfilter'], FILTER_SANITIZE_NUMBER_INT) : 0; $targetTid = array_key_exists('targettid', $_REQUEST) ? filter_var($_REQUEST['targettid'], FILTER_SANITIZE_NUMBER_INT) : ''; $tabIndex = array_key_exists('tabindex', $_REQUEST) ? filter_var($_REQUEST['tabindex'], FILTER_SANITIZE_NUMBER_INT) : 1; @@ -13,13 +13,23 @@ $datasetid = array_key_exists('datasetid', $_REQUEST) ? filter_var($_REQUEST['datasetid'], FILTER_SANITIZE_NUMBER_INT) : ''; $comingFrom = array_key_exists('comingFrom', $_REQUEST) ? htmlspecialchars($_REQUEST['comingFrom'], HTML_SPECIAL_CHARS_FLAGS) : ''; $_SESSION['datasetid'] = filter_var($datasetid, FILTER_SANITIZE_NUMBER_INT); +$associationManager = new AssociationManager(); +$shouldEstablishInverseRelationshipRecords = array_key_exists('establishInverseRelationshipRecords', $_REQUEST) ? true : false; +if($shouldEstablishInverseRelationshipRecords){ + echo '
Establishing Inverse Relationship Records...
'; + $associationManager->establishInverseRelationshipRecords(); + if($comingFrom === 'search/index.php'){ + header('Location: search/index.php'); + } else{ + header('Location: harvestparams.php'); + } +} + $collManager = new OccurrenceListManager(); $searchVar = $collManager->getQueryTermStr(); -// var_dump($searchVar); if ($targetTid && array_key_exists('mode', $_REQUEST)) $searchVar .= '&mode=voucher&targettid=' . $targetTid; $occurArr = $collManager->getSpecimenMap($pageNumber, $cntPerPage); -// var_dump($occurArr); $SHOULD_INCLUDE_CULTIVATED_AS_DEFAULT = $SHOULD_INCLUDE_CULTIVATED_AS_DEFAULT ?? false; $SHOULD_USE_HARVESTPARAMS = $SHOULD_USE_HARVESTPARAMS ?? false; @@ -66,6 +76,23 @@ }); }); + // function establishInverseRelationshipRecords() { + // console.log('deleteMe got here in establishInverseRelationshipRecords'); + // $.ajax({ + // url: 'list.php', + // type: 'post', + // data: { action: 'establishInverseRelationshipRecords' }, + // success: function(response) { + // console.log('deleteMe success'); + // console.log(response); + // }, + // error: function(xhr, status, error) { + // console.log('deleteMe failure'); + // console.error(xhr.responseText); + // } + // }); + // } + function validateOccurListForm(f) { if (f.targetdatasetid.value == "") { alert(""); @@ -227,6 +254,18 @@ function displayDatasetTools() { if ($associationSearchStr = $collManager->getAssociationSearchStr()) { if (strlen($associationSearchStr) > 300) $associationSearchStr = substr($associationSearchStr, 0, 300) . '... (' . htmlspecialchars($LANG['SHOW_ALL'], ENT_COMPAT | ENT_HTML401 | ENT_SUBSTITUTE) . ')'; // @TODO wouldn't this truncate in either case? echo '
' . $LANG['ASSOCIATIONS'] . ': ' . $associationSearchStr . '
'; + // echo '
' . "Didn't find what you were looking for? Try " . '
' . ". Note that this may take serveral minutes." . '
'; + ?> +
+ Didn't find what you were looking for? +
+ + + +
+ And try your search again. Note that this may take serveral minutes. +
+ getLocalSearchStr()) { echo '
' . $LANG['SEARCH_CRITERIA'] . ': ' . $localSearchStr . '
'; diff --git a/collections/search/index.php b/collections/search/index.php index 86c64cd4be..8ffc82bed7 100644 --- a/collections/search/index.php +++ b/collections/search/index.php @@ -31,6 +31,7 @@ $specArr = (isset($collList['spec'])?$collList['spec']:null); $obsArr = (isset($collList['obs'])?$collList['obs']:null); $associationManager = new AssociationManager(); +$relationshipTypes = $associationManager->getRelationshipTypes(); ?> diff --git a/collections/search/js/searchform.js b/collections/search/js/searchform.js index 15fa4e11fd..f3d950810d 100644 --- a/collections/search/js/searchform.js +++ b/collections/search/js/searchform.js @@ -171,7 +171,6 @@ function addChip(element) { } function handleRemoval(element, inputChip) { - console.log("deleteMe handleRemoval clicked"); element.type === "checkbox" ? (element.checked = false) : (element.value = element.defaultValue); @@ -193,13 +192,6 @@ function handleRemoval(element, inputChip) { collection.checked = false; }); } - // if (element?.getAttribute("id")?.startsWith("materialsampletype")) { - // // if they close a materialsampletype chip, revert to the none option selected - // const targetIndex = document.getElementById( - // "materialsampletype-none" - // ).selectedIndex; - // document.getElementById("materialsampletype").selectedIndex = targetIndex; - // } setAssociationRelationshipTypeToDefault(element); setMaterialSampleToDefault(element); setTaxonTypeToDefault(element); diff --git a/config/schema/3.0/patches/db_schema_patch-3.1.sql b/config/schema/3.0/patches/db_schema_patch-3.1.sql index 84d3cb542d..ed887ae087 100644 --- a/config/schema/3.0/patches/db_schema_patch-3.1.sql +++ b/config/schema/3.0/patches/db_schema_patch-3.1.sql @@ -274,3 +274,5 @@ CREATE TABLE `usersthirdpartyauth` ( ALTER TABLE `omoccurresource` RENAME TO `deprecated_omoccurresource` ; +# We need to relax this if we want inverse relationship entries in omoccurassociations for derivedFromSameIndividual +ALTER TABLE omoccurassociations DROP INDEX UQ_omoccurassoc_sciname, ADD INDEX `UQ_omoccurassoc_sciname` (`occid`, `verbatimSciname`, `associationType`) USING BTREE; \ No newline at end of file diff --git a/content/lang/collections/search/index.es.php b/content/lang/collections/search/index.es.php index 56839f977a..5612ab5847 100644 --- a/content/lang/collections/search/index.es.php +++ b/content/lang/collections/search/index.es.php @@ -115,4 +115,7 @@ $LANG['EXPAND_ALL_SECTIONS'] = 'Expandir todas las secciones'; $LANG['COLLAPSE_ALL_SECTIONS'] = 'Contraer todas las secciones'; $LANG['ASSOCIATIONS'] = 'Asociaciones'; +$LANG['ASSOCIATION_DESCRIPTION'] = 'Encuentre todas las apariciones de taxones que tengan la siguiente asociación'; +$LANG['ASSOCIATION_DESCRIPTION_2'] = 'con el siguiente taxon'; +$LANG['ASSOCIATION_TYPE'] = 'Tipo de asociación'; ?> diff --git a/content/lang/collections/search/index.fr.php b/content/lang/collections/search/index.fr.php index 679b37f1ff..892b06768b 100644 --- a/content/lang/collections/search/index.fr.php +++ b/content/lang/collections/search/index.fr.php @@ -116,5 +116,8 @@ $LANG['EXPAND_ALL_SECTIONS'] = 'Développer toutes les sections'; $LANG['COLLAPSE_ALL_SECTIONS'] = 'Réduire toutes les sections'; $LANG['ASSOCIATIONS'] = 'Les associations'; +$LANG['ASSOCIATION_DESCRIPTION'] = 'Trouver toutes les occurrences de taxons qui ont l\'association suivante'; +$LANG['ASSOCIATION_DESCRIPTION_2'] = 'avec le taxon suivant'; +$LANG['ASSOCIATION_TYPE'] = 'Type d\'association'; ?>