diff --git a/.dev/storage/UceConfig.json b/.dev/storage/UceConfig.json new file mode 100755 index 00000000..6c42a364 --- /dev/null +++ b/.dev/storage/UceConfig.json @@ -0,0 +1,151 @@ +{ + "meta": { + "name": "Fachinformationsdienst Biodiversität", + "version": "1.0.1", + "description": "

BIOfid stellt wissenschaftliche Literatur zur Biodiversitätsforschung – einschließlich digitalisierter historischer Werke – zentral und dauerhaft für die Forschung bereit.

Projektpartner und Förderung
Lizenzierte Ressourcen
Text-Mining

Entwicklung und Anwendung von Text-Mining-Methoden auf biodiversitätsbezogene Fachliteratur. Fokus auf Vögel, Schmetterlinge und Gefäßpflanzen. Werkzeuge wie TextAnnotator und DUUI werden bereitgestellt.

Digitale Sammlungen

Digitalisierung umfangreicher Literaturbestände (vor allem aus dem 19. und 20. Jahrhundert) für die Forschung und das Text-Mining.

Open-Access-Publikationen

Bereitstellung einer Hosting-Plattform für Zeitschriften der Biodiversitätsforschung (z. B. „Beiträge zur Entomologie“, „Kochia“).

Literaturversorgung

Bereitstellung lizenzierter und digitalisierter Literatur über das BIOfid-Portal sowie vifabio.

Semantische Suche

Integration taxonomischer und anatomischer Ontologien zur semantischen Annotation und Suche. Weiterentwicklung des BIOfid-Portals mit Ontologie-gestützter Recherche nach Arten, Merkmalen und Synonymen.

Perspektiven

Dieses Webportal wurde durch den Unified Corpus Explorer bereitgestellt und stellt den digitalen Zugang zu gesammelten Daten und Technologien dar. Mehr zum BIOfid-Projekt unter:

Zur Projektwebseite
" + }, + "corporate": { + "team": { + "description": "Das aktive Team hinter dem BIOfid-Suchportal. Das vollständige BIOfid-Team finden Sie hier.", + "members": [ + { + "name": "Prof. Alexander Mehler", + "role": "Supervisor", + "description": "Professor Mehler ist Teilprojektleiter des BIOfid-Projekts und Aufseher des Unified Corpus Explorers.", + "contact": { + "name": "Prof. Dr. Alexander Mehler", + "email": "mehler@em.uni-frankfurt.de", + "website": "https://www.texttechnologylab.org/team/alexander-mehler/", + "address": "Robert-Mayer-Straße 10
60325 Frankfurt am Main" + }, + "image": "FILE::https://www.texttechnologylab.org/wp-content/uploads/2024/01/71.1-d.jpg" + }, + { + "name": "Kevin Bönisch", + "role": "Lead Developer", + "description": "Herr Bönisch is verantwortlich für die Entwicklung und technische Leitung des Unified Corpus Explorers (UCE).", + "contact": { + "name": "Kevin Bönisch", + "email": "boenisch@em.uni-frankfurt.de", + "website": "https://www.texttechnologylab.org/team/kevin-boenisch/", + "address": "Robert-Mayer-Straße 10
60325 Frankfurt am Main" + }, + "image": "FILE::https://www.texttechnologylab.org/wp-content/uploads/2024/01/Boenisch.png" + }, + { + "name": "Manuel Schaaf", + "role": "Developer", + "description": "Doktorand, Teil des Text Technology Labs und Developer von UCE.", + "contact": { + "name": "Manuel Schaaf", + "email": "manuel.schaaf@em.uni-frankfurt.de", + "website": "https://www.texttechnologylab.org/team/manuel-schaaf/", + "address": "Robert-Mayer-Straße 10
60325 Frankfurt am Main" + }, + "image": "FILE::https://www.texttechnologylab.org/wp-content/uploads/2023/06/stoeckel.jpg" + }, + { + "name": "Dr. Gerwin Kasperek", + "role": "Biologe", + "description": "Teilprojektleiter des BIOfid-Projekts.", + "contact": { + "name": "Dr. Gerwin Kasperek", + "email": "g.kasperek@ub.uni-frankfurt.de", + "website": "https://www.biofid.de/de/team/", + "address": "" + }, + "image": "FILE::https://www.biofid.de/media/images/profile_kasperek.min-100x120.jpg" + }, + { + "name": "Katrin Peikert testuceconfig2", + "role": "Entwicklung", + "description": "Wissenschaftliche Mitarbeiterin an der Universitätsbibliothek J.C. Senckenberg.", + "contact": { + "name": "Katrin Peikert", + "email": "k.peikert@ub.uni-frankfurt.de", + "website": "https://www.biofid.de/de/team/", + "address": "" + }, + "image": null + }, + { + "name": "Dr. Martha Kandziora", + "role": "Biologin", + "description": "Wissenschaftliche Mitarbeiterin am Senckenberg Institut Frankfurt.", + "contact": { + "name": "Dr. Martha Kandziora", + "email": "martha.kandziora@senckenberg.de", + "website": "https://www.biofid.de/de/team/", + "address": "" + }, + "image": null + }, + { + "name": "Pedro Henrique dos Santos Dias, PhD", + "role": "Biologe", + "description": "Wissenschaftlicher Mitarbeiter am Senckenberg Institut Frankfurt.", + "contact": { + "name": "Pedro Henrique dos Santos Dias", + "email": "pedro.dias@senckenberg.de", + "website": "https://www.biofid.de/de/team/", + "address": "" + }, + "image": null + } + ] + }, + "contact": { + "name": "Prof. Dr. Alexander Mehler", + "email": "mehler@em.uni-frankfurt.de", + "website": "https://www.texttechnologylab.org/team/alexander-mehler/", + "address": "Robert-Mayer-Straße 10
60325 Frankfurt am Main" + }, + "website": "https://www.biofid.de/de/", + "//comment": "[Either or a file path - the prefixes are important and Filepath can also be an url!]", + "logo": "BASE64::data:image/png;base64,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PftypuffKILVGCBP/41j9rkyrhuDEhEREREREXVGRDQno+BnlrEHxNp/Y/l2uzKuG4MSERERERERddUt01dVVHuGzIFiPfpr53+RYzdPX37CjaFZ+BMREREREVHcy5ua15Q7o2CNis5ToMrtPN2m+pJbQ7PwJyIiIiIion5jYcbKbdrguwbQX7qdpTsEKHZxbCIiIiIiIqL+Z8uep5cD+mMohrqd5VIEaLAbfKNvvfYr1W6Mzyf+RERERERE1C/lZuT/2tiSBeBdt7NcikKfdavoB1j4ExERERERUT+24Jr8/QmehjkA/tPtLB1R4/y7m+Nzqj8RERERERH1e6oqW3c/9T8gEgDgcTvPBUpyMwuWuBmAT/yJiIiIiIio3xMRzZ2x8geA3AjguNt5WjTZan3T7RAs/ImIiIiIiGjAyM3M/5MDezaA7W5ngeLfFs24Z5/bMVj4ExERERER0YBya+aqo05y7Q2A/MS9FPLXau+Q77o3/se4xp+IiIiIiIgGrK17NtypKj8BMKQPhz1t207WoplfPdSHY3aIhT8RERERERENaKW7nplowS6CILMPhjtrjN6yYPrKHX0wVpdwqj8RERERERENaItm3LOvun7IHFX8EEAkhkOdireiH+ATfyIiIiIiIhpEynZvmCSQxwDkRPXEgjfVlryF1+QfjOp5o4CFPxEREREREQ06Zbs23CaQhyGY28tTnYNIYcKHh34wf74/lrMJeoyFPxEREREREQ1aZW8/PdtYer8qFgMY1o23fgDFs+FI+PtLslafjlW+aGDhT0RERERERIPeq6/6PY3DJ8wR1QUKnQbIlRBLPljmAAAAH0lEQVSMxce98aoBvAPoHoG8si3j8Gt+8TsuRu6y/w9wSPper4/MEwAAAABJRU5ErkJggg==", + "minilogo":"BASE64::data:image/png;base64,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", + "name": "BIOfid Project", + "primaryColor": "#5c7a17", + "secondaryColor": "darkgreen", + "imprint": "

Information is given according to § 5 of the German Teleservices Act.

Service provider:

Goethe-University
Theodor-W.-Adorno-Platz 1
60323 Frankfurt am Main, Germany
Phone: ++49-69-798-0 | Telefax: ++49-69-798-18383
WWW: www.uni-frankfurt.de

Legal representative: the president of the Goethe-University, Prof. Dr. Enrico Schleiff
Legal supervision: Hessian Ministry of Science and Art, Rheinstr. 23-25, 65185 Wiesbaden, Germany.
Place of jurisdiction: Frankfurt am Main
Value added tax identification number (VAT No.): DE 114 110 511
Tax number: 04 522 658 002

Responsible for content:

Prof. Dr. Alexander Mehler
FB 12 Computer Science and Mathematics
Robert-Mayer-Straße 10
60325 Frankfurt am Main, Germany

Editorial work:

Manuel Schaaf
FB 12 Computer Science and Mathematics
Robert-Mayer-Straße 10
60325 Frankfurt am Main, Germany
E-mail: support(at)hucompute.org
Phone: +49-69-798-24661

Giuseppe Abrami
FB 12 Computer Science and Mathematics
Robert-Mayer-Straße 10
60325 Frankfurt am Main, Germany
E-mail: support(at)hucompute.org
Phone: +49-69-798-28926

The contents are generated electronically and redacted manually. We assume no liability for any potential deviation from source texts. If you have any suggestion concerning an extension of our information supply or concerning a mistake that has to be corrected please send a respective message to support(at)hucompute.org.

© Copyright

The president of the Goethe-University holds the copyright and all other rights of content and design of the webpages of the Goethe-University. The proliferation of contents, even in excerpts, for educational, academic or private purposes is permitted only with an indication of source, as long as the source does not make an explicit divergent specification. The use of layout and of any source text that is generated by a CMS in a modified or unmodified way as well as any commercial use of the webpages requires a written permission by the president of the Goethe-University.

Liability note / Disclaimer:

Being content provider, the Goethe-University is responsible for own contents that are made available for public use according to general laws. This legal notice applies to information supplied by the Central Administration Board and the central facilities of the Goethe-University in the WWW, that are endorsed by “© Goethe-Universität” and are accessible via the URL www.uni-frankfurt.de.

The Goethe-University takes no responsibility for the completeness of these contents or their appropriateness for a certain purpose if use. The usage of contents provided by the webpages is the sole risk of the users.

A note on difficulties with external links

One has to distinguish between these own contents and links to contents provided by other facilities of the Goethe-University or by other content providers. Links are potentially dynamic means of reference. The editorial office for WWW matters of the Goethe-University verified each first linkage to third-party contents for whether they possibly give rise to a responsibility by civil law or by criminal law. The Goethe-University does not constantly verifies linked contents for modifications that may cause responsibility. If the Goethe-University notices or is made aware of a particular content that is linked from one of its webpages and gives rise to a responsibility by civil law or by criminal law, then this link is deleted.

Despite of a thorough contentwise control, no liability is assumed for contents of external links. The content of linked webpages lies exclusively within the responsibility of the operators of these webpages. The Land court of Hamburg decided by a verdict from May 12, 1998 “Liability of links”, that by linking alone the linking party adopts a co-responsibility of the content of the linked webpages and thereby can hold liability for them. Co-responsibility can be prevented by explicitly distance oneself from all contents. Given this background, the Goethe-University hereby precautionary distances itself from all contents of webpages that are linked from its webpages. The Goethe-University neither has a bearing on design on content of linked webpages, nor does it adopt linked contents as its own. This declaration applies to all links and collections of links that currently exist or will exist in future.

Data protection declaration

This data protection declaration serves to fulfil the duty of information required by Article 13 EU DSGVO when collecting data from data subjects at the time of collection.

Name and address of the person responsible

Johann Wolfgang Goethe-Universität Frankfurt am Main
Theodor-W.-Adorno-Platz 1
60323 Frankfurt am Main

Postal address:
Goethe University Frankfurt am Main
60629 Frankfurt, Germany

Phone: +49-69-798-0 | Fax: +49-69-798-18383
Internet: www.uni-frankfurt.de

If you have any questions or complaints regarding data protection, please contact the data protection officer of the Goethe University.

Contact details of the data protection officer

Johann Wolfgang Goethe University Frankfurt am Main
The data protection officers
Theodor-W.-Adorno-Platz 1
60323 Frankfurt am Main, Germany

Internet: https://www.uni-frankfurt.de/47859992/datenschutzbeauftragte

Rights and possibilities of complaints

You have the right to complain to the supervisory authority about data protection problems.

Contact address of the supervisory authority of the Goethe University Frankfurt am Main:

The Hessian Data Protection Commissioner
PO Box 3163
65021 Wiesbaden

E-mail to HDSB (link to the contact form of the Hessian Data Protection Officer: https://datenschutz.hessen.de/über-uns/kontakt)

Phone: +49 611 1408 – 0
Fax: +49 611 1408 – 611

You have the following rights vis-à-vis the Goethe University with regard to your stored personal data:

  • Right to information,
  • Right to correction or deletion,
  • Right to limitation of processing,
  • Right to revoke your consent,
  • Right of opposition to the processing,
  • Right to data transfer, in a common, structured and machine-readable form (from 25 May 2018).

To assert these rights, please contact our support via email.

Type of stored data, purpose and legal bases, deletion periods

Handling of personal data

Personal data is information by which a natural person can be identified, i.e. information by which individuals can be identified. This includes in particular names, e-mail addresses, matriculation numbers or telephone numbers. Data about preferences, hobbies, memberships or even information about websites that have been visited also count as personal data.

Personal data is only collected, used and passed on by us if this is legally permitted or if the user has consented to the data collection.

The use of personal data of students for the purpose of studying is based as far as possible on the applicable Hessian Higher Education Act in conjunction with the applicable enrollment ordinance of the State of Hesse and thus refer to EU DSGVO Article 6 paragraph 1 c).

The data of the employees of the Goethe University for the purpose of personnel administration, teaching, research and examination activities are collected and processed on the basis of the Hessian University Act, the regulations on matriculation of the State of Hesse, TV-GU, civil service law and personnel law regulations.

Access Data/Server Log Files

When accessing the pages of this web server, the following data is generally stored in the server log files

  1. IP address
  2. Date and time
  3. Type of Client Browser
  4. URL of the called page
  5. Optionally, the error message for the error that occurred
  6. Optionally, the requesting provider

This data is used solely for the purpose of checking functionality, security and troubleshooting. This use is based on EU DSGVO Article 6 paragraph 1 f). All log files are automatically deleted or made anonymous after 7 days at the latest.

Contacting

In order to contact members of the Goethe University (e.g. via contact form or e-mail), your details will be stored for the purpose of processing the enquiry and in the event that follow-up questions arise. After processing your request or after fulfilment of the legal obligation or the service used, the data will be deleted, unless the storage of the data is necessary for the implementation of legitimate interests of Goethe University or due to a statutory provision (e.g. law, ordinance, statutes of Goethe University etc.).

Cookies

Cookies are small files that allow us to store specific information about your device on your access device (PC, smartphone, etc.). They serve on the one hand the user friendliness of web pages (e.g. storage of login data). You can influence the use of cookies. Most browsers have an option with which the storage of cookies can be restricted or completely prevented. However, it is pointed out that the use and in particular the comfort of use without cookies can be restricted. Cookies are mandatory for using the login-secured pages. They serve to determine the access authorization and are deleted after the end of the session.

Access-protected websites and user-based services

In addition to the data mentioned above, the user name or an identifier is collected in addition to the authorization control of data access. This data will be deleted or made anonymous after 7 days at the latest, unless the storage of the data is necessary for the implementation of legitimate interests of the Goethe University or a statutory provision (e.g. law, ordinance, statutes of the Goethe University, etc.).

For all services not covered by these requirements, explicit declarations of consent are obtained. The deletion takes place here according to the specification of the used service. The deletion period is clearly defined in the list of processing activities. The legal basis here is EU DSGVO Article 6(1a).

Integration of third-party services

Within some pages of this online offer contents of third parties (such as videos from YouTube, maps from Google Maps, RSS feeds, graphics, etc.) from other websites are integrated. This always assumes that the providers of this content (hereinafter referred to as “third party providers”) are aware of your IP address. Because without the IP address, the third-party providers could not send the content to your browser. The IP address is therefore required for the display of this content. We make every effort to use only those contents whose respective providers use the IP address only for the delivery of the contents. However, we have no influence on any further use of your data (e.g. if the third party providers store the IP address for statistical purposes).

Version: 2023-08-14

" + }, + "settings": { + "rag": { + "models": [ + { + "model": "ollama/gemma3:latest", + "//url": "http://ollama.llm.texttechnologylab.org/", + "url": "http://localhost:11111/", + "apiKey": "sk-766cbff281e44760bb806a5eab41df15", + "displayName": "Gemma3 (4.3B - Google)" + }, + { + "model": "ollama/gemma2:27b", + "url": "http://localhost:11111/", + "apiKey": "", + "displayName": "Gemma2 (27B - Google)" + }, + { + "model": "ollama/deepseek-r1:latest", + "url": "http://localhost:11111/", + "apiKey": "sk-766cbff281e44760bb806a5eab41df15", + "displayName": "DeepSeek-R1 (7.6B - DeepSeek)" + }, + { + "model": "ollama/llama3.2:latest", + "url": "http://localhost:11111/", + "apiKey": "sk-766cbff281e44760bb806a5eab41df15", + "displayName": "Llama (3.2B - Meta)" + } + ] + }, + "analysis": { + "enableAnalysisEngine": false + }, + "authentication": { + "isActivated": false, + "publicUrl": "http://localhost:8080", + "redirectUrl": "http://localhost:4567/auth" + } + } +} \ No newline at end of file diff --git a/.dev/storage/carex_muricata_gbif_report.json b/.dev/storage/carex_muricata_gbif_report.json new file mode 100644 index 00000000..004a3109 --- /dev/null +++ b/.dev/storage/carex_muricata_gbif_report.json @@ -0,0 +1,9466 @@ +{ + "queries": [ + { + "query": "Carex muricata", + "gbifMatch": { + "usageKey": 2722926, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "rank": "SPECIES", + "status": "ACCEPTED", + "matchType": "EXACT", + "confidence": 97, + "acceptedUsageKey": null, + "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=0" + }, + "exactCanonicalUsages": [ + { + "key": 2722926, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 6 + }, + { + "key": 100013814, + "scientificName": "Carex muricata Linnaeus", + "canonicalName": "Carex muricata", + "authorship": "Linnaeus", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 100546265, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 103031795, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 117903985, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 118376459, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 124734660, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 127648016, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 145962994, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 148957453, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 148957774, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 160028024, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 152478932, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 159152091, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 159336348, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 160290748, + "scientificName": "Carex muricata Ehrh., 1791", + "canonicalName": "Carex muricata", + "authorship": "Ehrh., 1791", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 164943627, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": null, + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 165587045, + "scientificName": "Carex muricata L.Sp.Pl", + "canonicalName": "Carex muricata", + "authorship": "L.Sp. Pl. 2: 974 (1753)", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 168114725, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 168114861, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "auct. non L.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114860, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 168211569, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176242662, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176548398, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176573191, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 179103017, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 180133123, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 180133121, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 186698774, + "scientificName": "Carex muricata Jungh.", + "canonicalName": "Carex muricata", + "authorship": "Jungh.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186714610, + "scientificName": "Carex muricata Leers", + "canonicalName": "Carex muricata", + "authorship": "Leers", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186718427, + "scientificName": "Carex muricata Schltdl.", + "canonicalName": "Carex muricata", + "authorship": "Schltdl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412948, + "accepted": "Carex brongniartii Kunth", + "numDescendants": 0 + }, + { + "key": 206105159, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 213952251, + "scientificName": "Carex muricata Schltdl. & Cham.", + "canonicalName": "Carex muricata", + "authorship": "Schltdl. & Cham.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213952250, + "accepted": "Carex brongniartii Kunth, 1837", + "numDescendants": 0 + }, + { + "key": 213955325, + "scientificName": "Carex muricata Desf.", + "canonicalName": "Carex muricata", + "authorship": "Desf.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 221885996, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "auct. non L., 1753, sensu Guin. & R.Vilm., 1978 p.p.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + }, + { + "key": 221886848, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "auct. non L., 1753 p.p.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221889329, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "auct. non L., 1753 p.p.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 224053913, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 224776247, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239712335, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239766740, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239810880, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239899602, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239901161, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240241066, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240469724, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240476050, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240540573, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240621298, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241006592, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241059491, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241062168, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241100745, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241176967, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241268823, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241348670, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241388226, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241465330, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241553298, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241601934, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 242137566, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 269916553, + "scientificName": "Carex muricata Huds. auct. non Huds.", + "canonicalName": "Carex muricata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338634, + "scientificName": "Carex muricata Leers auct. non Leers", + "canonicalName": "Carex muricata", + "authorship": "Leers", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296339275, + "scientificName": "Carex muricata Desf.", + "canonicalName": "Carex muricata", + "authorship": "Desf.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 312830004, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312861452, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312870922, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312909330, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313586976, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313595144, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313615848, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 314607274, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": null, + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "sourceToAcceptedUsage": { + "2722926": 2722926, + "100013814": 100013814, + "100546265": 100546265, + "103031795": 103031795, + "117903985": 117903985, + "118376459": 118376459, + "124734660": 124734660, + "127648016": 127648016, + "145962994": 145962994, + "148957453": 160027865, + "148957774": 160028024, + "152478932": 152478932, + "159152091": 159152091, + "159336348": 159336348, + "160290748": 160290748, + "164943627": 164943627, + "165587045": 165587045, + "168114725": 168114725, + "168114861": 168114860, + "168211569": 168211569, + "176242662": 176242662, + "176548398": 176548398, + "176573191": 176573191, + "179103017": 179103017, + "180133123": 180133121, + "186698774": 211405057, + "186714610": 211412197, + "186718427": 211412948, + "206105159": 206105159, + "213952251": 213952250, + "213955325": 213955316, + "221885996": 221885990, + "221886848": 221886838, + "221889329": 221889248, + "224053913": 224053913, + "224776247": 224776247, + "239712335": 239712335, + "239766740": 239766740, + "239810880": 239810880, + "239899602": 239899602, + "239901161": 239901161, + "240241066": 240241066, + "240469724": 240469724, + "240476050": 240476050, + "240540573": 240540573, + "240621298": 240621298, + "241006592": 241006592, + "241059491": 241059491, + "241062168": 241062168, + "241100745": 241100745, + "241176967": 241176967, + "241268823": 241268823, + "241348670": 241348670, + "241388226": 241388226, + "241465330": 241465330, + "241553298": 241553298, + "241601934": 241601934, + "242137566": 242137566, + "269916553": 269916461, + "296338634": 296338562, + "296339275": 296339203, + "312830004": 312830004, + "312861452": 312861452, + "312870922": 312870922, + "312909330": 312909330, + "313586976": 313586976, + "313595144": 313595144, + "313615848": 313615848, + "314607274": 314607274 + }, + "acceptedUsages": [ + { + "key": 2722926, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 6 + }, + { + "key": 100013814, + "scientificName": "Carex muricata Linnaeus", + "canonicalName": "Carex muricata", + "authorship": "Linnaeus", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 1 + }, + { + "key": 100546265, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 103031795, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 4 + }, + { + "key": 117903985, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 1 + }, + { + "key": 118376459, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 124734660, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 127648016, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 145962994, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 152478932, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 159152091, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 159336348, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 160027865, + "scientificName": "Carex spicata Huds.", + "canonicalName": "Carex spicata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 160028024, + "scientificName": "Carex echinata Murray", + "canonicalName": "Carex echinata", + "authorship": "Murray", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 160290748, + "scientificName": "Carex muricata Ehrh., 1791", + "canonicalName": "Carex muricata", + "authorship": "Ehrh., 1791", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 164943627, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": null, + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 165587045, + "scientificName": "Carex muricata L.Sp.Pl", + "canonicalName": "Carex muricata", + "authorship": "L.Sp.Pl", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 168114725, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 168114860, + "scientificName": "Carex echinata Murray", + "canonicalName": "Carex echinata", + "authorship": "Murray", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 168211569, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176242662, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176548398, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 176573191, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 179103017, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 4 + }, + { + "key": 180133121, + "scientificName": "Carex spicata Huds.", + "canonicalName": "Carex spicata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 206105159, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 211405057, + "scientificName": "Carex vulpina L.", + "canonicalName": "Carex vulpina", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 211412197, + "scientificName": "Carex echinata Murray", + "canonicalName": "Carex echinata", + "authorship": "Murray", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 211412948, + "scientificName": "Carex brongniartii Kunth", + "canonicalName": "Carex brongniartii", + "authorship": "Kunth", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 213952250, + "scientificName": "Carex brongniartii Kunth, 1837", + "canonicalName": "Carex brongniartii", + "authorship": "Kunth, 1837", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 213955316, + "scientificName": "Carex divulsa Stokes, 1787", + "canonicalName": "Carex divulsa", + "authorship": "Stokes, 1787", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 1 + }, + { + "key": 221885990, + "scientificName": "Carex spicata Huds., 1762", + "canonicalName": "Carex spicata", + "authorship": "Huds., 1762", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 221886838, + "scientificName": "Carex pairae F.W.Schultz, 1868", + "canonicalName": "Carex pairae", + "authorship": "F.W.Schultz, 1868", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 221889248, + "scientificName": "Carex echinata Murray, 1770", + "canonicalName": "Carex echinata", + "authorship": "Murray, 1770", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 1 + }, + { + "key": 224053913, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 224776247, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239712335, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239766740, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239810880, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239899602, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 239901161, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240241066, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240469724, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240476050, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240540573, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 240621298, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241006592, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241059491, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241062168, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241100745, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241176967, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241268823, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241348670, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241388226, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241465330, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241553298, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 241601934, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 242137566, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 269916461, + "scientificName": "Carex echinata subsp. echinata", + "canonicalName": "Carex echinata echinata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 296338562, + "scientificName": "Carex echinata subsp. echinata", + "canonicalName": "Carex echinata echinata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 296339203, + "scientificName": "Carex divulsa Stokes", + "canonicalName": "Carex divulsa", + "authorship": "Stokes", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312830004, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312861452, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312870922, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 312909330, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313586976, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313595144, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 313615848, + "scientificName": "Carex muricata L.", + "canonicalName": "Carex muricata", + "authorship": "L.", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 314607274, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "acceptedSynonyms": { + "2722926": [ + { + "key": 7930566, + "scientificName": "Carex divulsa Gaudin", + "canonicalName": "Carex divulsa", + "authorship": "Gaudin", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 8238955, + "scientificName": "Carex muricata var. densa Wallr.", + "canonicalName": "Carex muricata densa", + "authorship": "Wallr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 8079221, + "scientificName": "Carex muricata var. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 10925327, + "scientificName": "Carex pairaei subsp. borealis Hyl.", + "canonicalName": "Carex pairaei borealis", + "authorship": "Hyl.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 2728498, + "scientificName": "Caricina muricata (L.) St.-Lag.", + "canonicalName": "Caricina muricata", + "authorship": "(L.) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 2728500, + "scientificName": "Vignea muricata (L.) Rchb.", + "canonicalName": "Vignea muricata", + "authorship": "(L.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2722926, + "accepted": "Carex muricata L.", + "numDescendants": 0 + } + ], + "100013814": [], + "100546265": [], + "103031795": [ + { + "key": 166918919, + "scientificName": "Carex muricata L., 1753", + "canonicalName": "Carex muricata", + "authorship": "L., 1753", + "rank": null, + "taxonomicStatus": "SYNONYM", + "acceptedKey": 103031795, + "accepted": "Carex muricata", + "numDescendants": 0 + } + ], + "117903985": [], + "118376459": [ + { + "key": 118376514, + "scientificName": "Carex astracanica Willd. ex Kunth", + "canonicalName": "Carex astracanica", + "authorship": "Willd. ex Kunth", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376512, + "scientificName": "Carex divulsa Gaudin", + "canonicalName": "Carex divulsa", + "authorship": "Gaudin", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376473, + "scientificName": "Carex divulsa subsp. orsiniana (Ten.) K.Richt.", + "canonicalName": "Carex divulsa orsiniana", + "authorship": "(Ten.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376511, + "scientificName": "Carex intermedia Retz.", + "canonicalName": "Carex intermedia", + "authorship": "Retz.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376508, + "scientificName": "Carex muricata subsp. lamprocarpa (Wallr.) Celak.", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "(Wallr.) Celak.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376470, + "scientificName": "Carex muricata subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376478, + "scientificName": "Carex muricata subsp. orsiniana (Ten.) Nyman", + "canonicalName": "Carex muricata orsiniana", + "authorship": "(Ten.) Nyman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376467, + "scientificName": "Carex muricata var. alpina Gaudin", + "canonicalName": "Carex muricata alpina", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376476, + "scientificName": "Carex muricata var. lamprocarpa Wallr.", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "Wallr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376463, + "scientificName": "Carex muricata var. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376505, + "scientificName": "Carex orsiniana Ten.", + "canonicalName": "Carex orsiniana", + "authorship": "Ten.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376501, + "scientificName": "Carex pairae subsp. borealis Hyl.", + "canonicalName": "Carex pairae borealis", + "authorship": "Hyl.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376499, + "scientificName": "Carex pairae var. javanica Nelmes", + "canonicalName": "Carex pairae javanica", + "authorship": "Nelmes", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376497, + "scientificName": "Carex serotina Ten.", + "canonicalName": "Carex serotina", + "authorship": "Ten.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376494, + "scientificName": "Carex stellulata M.Bieb.", + "canonicalName": "Carex stellulata", + "authorship": "M.Bieb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376493, + "scientificName": "Carex tenuissima Schur", + "canonicalName": "Carex tenuissima", + "authorship": "Schur", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376491, + "scientificName": "Carex tergestina Hoppe ex Boott", + "canonicalName": "Carex tergestina", + "authorship": "Hoppe ex Boott", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376489, + "scientificName": "Carex viridis Spenn.", + "canonicalName": "Carex viridis", + "authorship": "Spenn.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376487, + "scientificName": "Carex vulpina Hohen.", + "canonicalName": "Carex vulpina", + "authorship": "Hohen.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 119456589, + "scientificName": "Caricina muricata (L.) St.-Lag.", + "canonicalName": "Caricina muricata", + "authorship": "(L.) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376486, + "scientificName": "Vignea altissima Schur", + "canonicalName": "Vignea altissima", + "authorship": "Schur", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376484, + "scientificName": "Vignea muricata (L.) Rchb.", + "canonicalName": "Vignea muricata", + "authorship": "(L.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376482, + "scientificName": "Vignea muricata subsp. lamprocarpa (Wallr.) Soj\u00e1k", + "canonicalName": "Vignea muricata lamprocarpa", + "authorship": "(Wallr.) Soj\u00e1k", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 118376480, + "scientificName": "Vignea tenuissima Schur", + "canonicalName": "Vignea tenuissima", + "authorship": "Schur", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 118376459, + "accepted": "Carex muricata L.", + "numDescendants": 0 + } + ], + "124734660": [], + "127648016": [], + "145962994": [], + "152478932": [], + "159152091": [], + "159336348": [], + "160027865": [ + { + "key": 148957452, + "scientificName": "Carex contigua Hoppe", + "canonicalName": "Carex contigua", + "authorship": "Hoppe", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 148957453, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 208001625, + "scientificName": "Carex muricata subsp. contigua (Hoppe) H.Lindb.", + "canonicalName": "Carex muricata contigua", + "authorship": "(Hoppe) H.Lindb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 148957455, + "scientificName": "Carex muricata subsp. contigua (Hoppe) H.Lindb.", + "canonicalName": "Carex muricata contigua", + "authorship": "(Hoppe) H.Lindb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 208001624, + "scientificName": "Carex muricata subsp. macrocarpa Neuman", + "canonicalName": "Carex muricata macrocarpa", + "authorship": "Neuman", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 148957456, + "scientificName": "Carex muricata subsp. macrocarpa Neuman", + "canonicalName": "Carex muricata macrocarpa", + "authorship": "Neuman", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160027865, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + } + ], + "160028024": [ + { + "key": 148957772, + "scientificName": "Carex leersii Willd.", + "canonicalName": "Carex leersii", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160028024, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 148957774, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 160028024, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 148957775, + "scientificName": "Carex stellulata Gooden.", + "canonicalName": "Carex stellulata", + "authorship": "Gooden.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 160028024, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + } + ], + "160290748": [], + "164943627": [], + "165587045": [], + "168114725": [ + { + "key": 168114726, + "scientificName": "Carex muricata subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114725, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 168114728, + "scientificName": "Carex pairae subsp. borealis Hyl.", + "canonicalName": "Carex pairae borealis", + "authorship": "Hyl.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114725, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 168114727, + "scientificName": "Carex pairaei subsp. borealis Hyl.", + "canonicalName": "Carex pairaei borealis", + "authorship": "Hyl.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114725, + "accepted": "Carex muricata L.", + "numDescendants": 0 + } + ], + "168114860": [ + { + "key": 168114862, + "scientificName": "Carex leersii Willd.", + "canonicalName": "Carex leersii", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114860, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 168114861, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114860, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 168114863, + "scientificName": "Carex stellulata Gooden.", + "canonicalName": "Carex stellulata", + "authorship": "Gooden.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 168114860, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + } + ], + "168211569": [], + "176242662": [], + "176548398": [], + "176573191": [], + "179103017": [], + "180133121": [ + { + "key": 180133126, + "scientificName": "Carex contigua Hoppe", + "canonicalName": "Carex contigua", + "authorship": "Hoppe", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 180133121, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 180133123, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 180133121, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + }, + { + "key": 180133128, + "scientificName": "Carex spicata Hudson", + "canonicalName": "Carex spicata", + "authorship": "Hudson", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 180133121, + "accepted": "Carex spicata Huds.", + "numDescendants": 0 + } + ], + "206105159": [], + "211405057": [ + { + "key": 186698785, + "scientificName": "Carex brotherorum Christ", + "canonicalName": "Carex brotherorum", + "authorship": "Christ", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698782, + "scientificName": "Carex compacta Lam.", + "canonicalName": "Carex compacta", + "authorship": "Lam.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698779, + "scientificName": "Carex glomerata Gilib.", + "canonicalName": "Carex glomerata", + "authorship": "Gilib.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698777, + "scientificName": "Carex mertensis Weihe ex Kunth", + "canonicalName": "Carex mertensis", + "authorship": "Weihe ex Kunth", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698774, + "scientificName": "Carex muricata Jungh.", + "canonicalName": "Carex muricata", + "authorship": "Jungh.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698770, + "scientificName": "Carex reflexa D.Dietr. ex Kunth", + "canonicalName": "Carex reflexa", + "authorship": "D.Dietr. ex Kunth", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698767, + "scientificName": "Carex spicata Thuill.", + "canonicalName": "Carex spicata", + "authorship": "Thuill.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698763, + "scientificName": "Carex vulpina f. aristata Asch.", + "canonicalName": "Carex vulpina aristata", + "authorship": "Asch.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698760, + "scientificName": "Carex vulpina f. capitulata (Peterm.) So\u00f3", + "canonicalName": "Carex vulpina capitulata", + "authorship": "(Peterm.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698758, + "scientificName": "Carex vulpina f. elongata Andersson", + "canonicalName": "Carex vulpina elongata", + "authorship": "Andersson", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698754, + "scientificName": "Carex vulpina f. laeviuscula Sanio ex Asch. & Graebn.", + "canonicalName": "Carex vulpina laeviuscula", + "authorship": "Sanio ex Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698751, + "scientificName": "Carex vulpina f. minor Peterm.", + "canonicalName": "Carex vulpina minor", + "authorship": "Peterm.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698748, + "scientificName": "Carex vulpina f. nemorosa (Gaudin) W.D.J.Koch", + "canonicalName": "Carex vulpina nemorosa", + "authorship": "(Gaudin) W.D.J.Koch", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698745, + "scientificName": "Carex vulpina subsp. nemorosa (Gaudin) K.Richt.", + "canonicalName": "Carex vulpina nemorosa", + "authorship": "(Gaudin) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698742, + "scientificName": "Carex vulpina subsp. stribrnyi Velen.", + "canonicalName": "Carex vulpina stribrnyi", + "authorship": "Velen.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698739, + "scientificName": "Carex vulpina var. compacta (Lam.) Velen.", + "canonicalName": "Carex vulpina compacta", + "authorship": "(Lam.) Velen.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698736, + "scientificName": "Carex vulpina var. crassinervis (Schur) K\u00fck.", + "canonicalName": "Carex vulpina crassinervis", + "authorship": "(Schur) K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698733, + "scientificName": "Carex vulpina var. divulsa Celak.", + "canonicalName": "Carex vulpina divulsa", + "authorship": "Celak.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698730, + "scientificName": "Carex vulpina var. gracilis Gaudin", + "canonicalName": "Carex vulpina gracilis", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698727, + "scientificName": "Carex vulpina var. littoralis Nolte ex Asch. & Graebn.", + "canonicalName": "Carex vulpina littoralis", + "authorship": "Nolte ex Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698724, + "scientificName": "Carex vulpina var. longibracteata Beck", + "canonicalName": "Carex vulpina longibracteata", + "authorship": "Beck", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698721, + "scientificName": "Carex vulpina var. nemorosa Gaudin", + "canonicalName": "Carex vulpina nemorosa", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698718, + "scientificName": "Carex vulpina var. pallidior Meinsh.", + "canonicalName": "Carex vulpina pallidior", + "authorship": "Meinsh.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698715, + "scientificName": "Carex vulpina var. remotiflora Lange", + "canonicalName": "Carex vulpina remotiflora", + "authorship": "Lange", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698711, + "scientificName": "Carex vulpina var. stribrnyi (Velen.) K\u00fck.", + "canonicalName": "Carex vulpina stribrnyi", + "authorship": "(Velen.) K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698708, + "scientificName": "Carex vulpina var. tenuior Ledeb.", + "canonicalName": "Carex vulpina tenuior", + "authorship": "Ledeb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698704, + "scientificName": "Carex vulpina var. vulgaris Celak.", + "canonicalName": "Carex vulpina vulgaris", + "authorship": "Celak.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698701, + "scientificName": "Edritria vulpina (L.) Raf.", + "canonicalName": "Edritria vulpina", + "authorship": "(L.) Raf.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698698, + "scientificName": "Vignea nemorosa (Gaudin) Rchb.", + "canonicalName": "Vignea nemorosa", + "authorship": "(Gaudin) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698695, + "scientificName": "Vignea vulpina (L.) Rchb.", + "canonicalName": "Vignea vulpina", + "authorship": "(L.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698692, + "scientificName": "Vignea vulpina f. capitulata Peterm.", + "canonicalName": "Vignea vulpina capitulata", + "authorship": "Peterm.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + }, + { + "key": 186698689, + "scientificName": "Vignea vulpina var. crassinervis Schur", + "canonicalName": "Vignea vulpina crassinervis", + "authorship": "Schur", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211405057, + "accepted": "Carex vulpina L.", + "numDescendants": 0 + } + ], + "211412197": [ + { + "key": 186714528, + "scientificName": "Carex angustior Mack.", + "canonicalName": "Carex angustior", + "authorship": "Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714530, + "scientificName": "Carex angustior var. gracilenta R.T.Clausen & Wahl", + "canonicalName": "Carex angustior gracilenta", + "authorship": "R.T.Clausen & Wahl", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714531, + "scientificName": "Carex basilata Ohwi", + "canonicalName": "Carex basilata", + "authorship": "Ohwi", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714533, + "scientificName": "Carex caflischii Br\u00fcgger", + "canonicalName": "Carex caflischii", + "authorship": "Br\u00fcgger", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714535, + "scientificName": "Carex cephalantha (L.H.Bailey) E.P.Bicknell", + "canonicalName": "Carex cephalantha", + "authorship": "(L.H.Bailey) E.P.Bicknell", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714537, + "scientificName": "Carex convexa Kit.", + "canonicalName": "Carex convexa", + "authorship": "Kit.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714539, + "scientificName": "Carex echinata f. brevispicata Podp.", + "canonicalName": "Carex echinata brevispicata", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714541, + "scientificName": "Carex echinata f. chlorocarpa Podp.", + "canonicalName": "Carex echinata chlorocarpa", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714543, + "scientificName": "Carex echinata f. remotiuscula Podp.", + "canonicalName": "Carex echinata remotiuscula", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714545, + "scientificName": "Carex echinata subsp. gasparrinii (Parl.) K.Richt.", + "canonicalName": "Carex echinata gasparrinii", + "authorship": "(Parl.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714547, + "scientificName": "Carex echinata subsp. grypos (Schkuhr) Arcang.", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Arcang.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714549, + "scientificName": "Carex echinata subsp. hydrophila (Dumort.) K.Richt.", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714551, + "scientificName": "Carex echinata var. angustata (J.Carey) L.H.Bailey", + "canonicalName": "Carex echinata angustata", + "authorship": "(J.Carey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714553, + "scientificName": "Carex echinata var. cephalantha L.H.Bailey", + "canonicalName": "Carex echinata cephalantha", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714555, + "scientificName": "Carex echinata var. elata Maire ex Rouy", + "canonicalName": "Carex echinata elata", + "authorship": "Maire ex Rouy", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714557, + "scientificName": "Carex echinata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex echinata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714559, + "scientificName": "Carex echinata var. grypos (Schkuhr) Nyman", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714561, + "scientificName": "Carex echinata var. hydrophila (Dumort.) Nyman", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714563, + "scientificName": "Carex echinata var. microstachys Boeckeler", + "canonicalName": "Carex echinata microstachys", + "authorship": "Boeckeler", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714565, + "scientificName": "Carex echinata var. ormantha Fernald", + "canonicalName": "Carex echinata ormantha", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714567, + "scientificName": "Carex echinata var. perileia (S.T.Blake) P.Royen", + "canonicalName": "Carex echinata perileia", + "authorship": "(S.T.Blake) P.Royen", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714569, + "scientificName": "Carex echinata var. phyllomanica (W.Boott) B.Boivin", + "canonicalName": "Carex echinata phyllomanica", + "authorship": "(W.Boott) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714571, + "scientificName": "Carex echinata var. tenuior K\u00fck.", + "canonicalName": "Carex echinata tenuior", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714573, + "scientificName": "Carex fasciculata Link ex Schkuhr", + "canonicalName": "Carex fasciculata", + "authorship": "Link ex Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714575, + "scientificName": "Carex fasciculata Willd.", + "canonicalName": "Carex fasciculata", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714577, + "scientificName": "Carex gajonum Nelmes", + "canonicalName": "Carex gajonum", + "authorship": "Nelmes", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714579, + "scientificName": "Carex gasparrinii Parl.", + "canonicalName": "Carex gasparrinii", + "authorship": "Parl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714581, + "scientificName": "Carex grypos Schkuhr", + "canonicalName": "Carex grypos", + "authorship": "Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714583, + "scientificName": "Carex hawaiiensis H.St.John", + "canonicalName": "Carex hawaiiensis", + "authorship": "H.St.John", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714585, + "scientificName": "Carex hispida subsp. retusa (Degl.) Arcang.", + "canonicalName": "Carex hispida retusa", + "authorship": "(Degl.) Arcang.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714588, + "scientificName": "Carex hydrophila Dumort.", + "canonicalName": "Carex hydrophila", + "authorship": "Dumort.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714590, + "scientificName": "Carex interior var. josselynii Fernald", + "canonicalName": "Carex interior josselynii", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714592, + "scientificName": "Carex josselynii (Fernald) Mack. ex Pease", + "canonicalName": "Carex josselynii", + "authorship": "(Fernald) Mack. ex Pease", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714594, + "scientificName": "Carex laricina Mack. ex Bright", + "canonicalName": "Carex laricina", + "authorship": "Mack. ex Bright", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714596, + "scientificName": "Carex leersii Willd.", + "canonicalName": "Carex leersii", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714598, + "scientificName": "Carex leersii var. angustata (J.Carey) Burnham", + "canonicalName": "Carex leersii angustata", + "authorship": "(J.Carey) Burnham", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714600, + "scientificName": "Carex leersii var. angustata (J.Carey) Mack.", + "canonicalName": "Carex leersii angustata", + "authorship": "(J.Carey) Mack.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714602, + "scientificName": "Carex leersii var. cephalantha (L.H.Bailey) J.K.Henry", + "canonicalName": "Carex leersii cephalantha", + "authorship": "(L.H.Bailey) J.K.Henry", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714604, + "scientificName": "Carex leptophylla Heuff.", + "canonicalName": "Carex leptophylla", + "authorship": "Heuff.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714606, + "scientificName": "Carex minganinsularum Raymond", + "canonicalName": "Carex minganinsularum", + "authorship": "Raymond", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714608, + "scientificName": "Carex muricata Huds.", + "canonicalName": "Carex muricata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714610, + "scientificName": "Carex muricata Leers", + "canonicalName": "Carex muricata", + "authorship": "Leers", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714612, + "scientificName": "Carex muricata subsp. cephalantha (L.H.Bailey) R.T.Clausen", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) R.T.Clausen", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714614, + "scientificName": "Carex muricata subsp. microcarpa Neuman", + "canonicalName": "Carex muricata microcarpa", + "authorship": "Neuman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714616, + "scientificName": "Carex muricata var. angustata (J.Carey) J.Carey ex Gleason", + "canonicalName": "Carex muricata angustata", + "authorship": "(J.Carey) J.Carey ex Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714618, + "scientificName": "Carex muricata var. basilata (Ohwi) Y.L.Chou", + "canonicalName": "Carex muricata basilata", + "authorship": "(Ohwi) Y.L.Chou", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714620, + "scientificName": "Carex muricata var. cephalantha (L.H.Bailey) Wiegand & Eames", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) Wiegand & Eames", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714622, + "scientificName": "Carex muricata var. depauperata Hampe", + "canonicalName": "Carex muricata depauperata", + "authorship": "Hampe", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714525, + "scientificName": "Carex muricata var. laricina (Mack. ex Bright) Gleason", + "canonicalName": "Carex muricata laricina", + "authorship": "(Mack. ex Bright) Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714523, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714521, + "scientificName": "Carex muricata var. virens Andersson", + "canonicalName": "Carex muricata virens", + "authorship": "Andersson", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714519, + "scientificName": "Carex obtusangula Salzm. ex Boott", + "canonicalName": "Carex obtusangula", + "authorship": "Salzm. ex Boott", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714517, + "scientificName": "Carex omiana var. perileia (S.T.Blake) T.Koyama", + "canonicalName": "Carex omiana perileia", + "authorship": "(S.T.Blake) T.Koyama", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714515, + "scientificName": "Carex ormantha (Fernald) Mack.", + "canonicalName": "Carex ormantha", + "authorship": "(Fernald) Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714513, + "scientificName": "Carex pairae f. brevispicata (Podp.) So\u00f3", + "canonicalName": "Carex pairae brevispicata", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714511, + "scientificName": "Carex pairae f. chlorocarpa (Podp.) So\u00f3", + "canonicalName": "Carex pairae chlorocarpa", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714509, + "scientificName": "Carex pairae f. remotiuscula (Podp.) So\u00f3", + "canonicalName": "Carex pairae remotiuscula", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714507, + "scientificName": "Carex perileia S.T.Blake", + "canonicalName": "Carex perileia", + "authorship": "S.T.Blake", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714505, + "scientificName": "Carex phyllomanica W.Boott", + "canonicalName": "Carex phyllomanica", + "authorship": "W.Boott", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714503, + "scientificName": "Carex phyllomanica var. angustata (J.Carey) B.Boivin", + "canonicalName": "Carex phyllomanica angustata", + "authorship": "(J.Carey) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714501, + "scientificName": "Carex phyllomanica var. ormantha (Fernald) B.Boivin", + "canonicalName": "Carex phyllomanica ormantha", + "authorship": "(Fernald) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714499, + "scientificName": "Carex provincialis Degl.", + "canonicalName": "Carex provincialis", + "authorship": "Degl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714497, + "scientificName": "Carex retusa Degl.", + "canonicalName": "Carex retusa", + "authorship": "Degl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714495, + "scientificName": "Carex riparia subsp. fasciculata (Link ex Schkuhr) K.Richt.", + "canonicalName": "Carex riparia fasciculata", + "authorship": "(Link ex Schkuhr) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714493, + "scientificName": "Carex scirpoides var. josselynii (Fernald) Fernald", + "canonicalName": "Carex scirpoides josselynii", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714491, + "scientificName": "Carex serrulata Mutel", + "canonicalName": "Carex serrulata", + "authorship": "Mutel", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714489, + "scientificName": "Carex stellulata Gooden.", + "canonicalName": "Carex stellulata", + "authorship": "Gooden.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714487, + "scientificName": "Carex stellulata f. excelsior (L.H.Bailey) K\u00fck.", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714485, + "scientificName": "Carex stellulata f. hydrophila (Dumort.) Asch. & Graebn.", + "canonicalName": "Carex stellulata hydrophila", + "authorship": "(Dumort.) Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714483, + "scientificName": "Carex stellulata f. hylogiton Asch. & Graebn.", + "canonicalName": "Carex stellulata hylogiton", + "authorship": "Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714481, + "scientificName": "Carex stellulata f. oligantha Callm\u00e9", + "canonicalName": "Carex stellulata oligantha", + "authorship": "Callm\u00e9", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714479, + "scientificName": "Carex stellulata var. angustata J.Carey", + "canonicalName": "Carex stellulata angustata", + "authorship": "J.Carey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714477, + "scientificName": "Carex stellulata var. australis K\u00fck.", + "canonicalName": "Carex stellulata australis", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714475, + "scientificName": "Carex stellulata var. cephalantha (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata cephalantha", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714473, + "scientificName": "Carex stellulata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714471, + "scientificName": "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) W.D.J.Koch", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714470, + "scientificName": "Carex stellulata var. ormantha (Fernald) Fernald", + "canonicalName": "Carex stellulata ormantha", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714467, + "scientificName": "Carex stellulata var. scirpina Tuck.", + "canonicalName": "Carex stellulata scirpina", + "authorship": "Tuck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714466, + "scientificName": "Carex stellulata var. subalpina Asch. & Graebn.", + "canonicalName": "Carex stellulata subalpina", + "authorship": "Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714464, + "scientificName": "Carex sterilis var. cephalantha (L.H.Bailey) L.H.Bailey", + "canonicalName": "Carex sterilis cephalantha", + "authorship": "(L.H.Bailey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714462, + "scientificName": "Carex sterilis var. excelsior L.H.Bailey", + "canonicalName": "Carex sterilis excelsior", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714459, + "scientificName": "Carex svenonis Skottsb.", + "canonicalName": "Carex svenonis", + "authorship": "Skottsb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714457, + "scientificName": "Carex svenonis var. alakaiensis Skottsb.", + "canonicalName": "Carex svenonis alakaiensis", + "authorship": "Skottsb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714455, + "scientificName": "Caricina stellulata (Gooden.) St.-Lag.", + "canonicalName": "Caricina stellulata", + "authorship": "(Gooden.) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714453, + "scientificName": "Vignea angustior (Mack.) Soj\u00e1k", + "canonicalName": "Vignea angustior", + "authorship": "(Mack.) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714451, + "scientificName": "Vignea echinata (Murray) Fourr.", + "canonicalName": "Vignea echinata", + "authorship": "(Murray) Fourr.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714449, + "scientificName": "Vignea grypos (Schkuhr) Rchb.", + "canonicalName": "Vignea grypos", + "authorship": "(Schkuhr) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714447, + "scientificName": "Vignea hydrophila (Dumort.) Rchb.", + "canonicalName": "Vignea hydrophila", + "authorship": "(Dumort.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714445, + "scientificName": "Vignea perileia (S.T.Blake) Soj\u00e1k", + "canonicalName": "Vignea perileia", + "authorship": "(S.T.Blake) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 186714443, + "scientificName": "Vignea stellulata (Gooden.) Rchb.", + "canonicalName": "Vignea stellulata", + "authorship": "(Gooden.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + } + ], + "211412948": [ + { + "key": 186718432, + "scientificName": "Carex hypoxanthos Steud.", + "canonicalName": "Carex hypoxanthos", + "authorship": "Steud.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412948, + "accepted": "Carex brongniartii Kunth", + "numDescendants": 0 + }, + { + "key": 186718429, + "scientificName": "Carex muehlenbergii Brongn.", + "canonicalName": "Carex muehlenbergii", + "authorship": "Brongn.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412948, + "accepted": "Carex brongniartii Kunth", + "numDescendants": 0 + }, + { + "key": 186718427, + "scientificName": "Carex muricata Schltdl.", + "canonicalName": "Carex muricata", + "authorship": "Schltdl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412948, + "accepted": "Carex brongniartii Kunth", + "numDescendants": 0 + } + ], + "213952250": [ + { + "key": 213952253, + "scientificName": "Carex hypoxanthos Steud.", + "canonicalName": "Carex hypoxanthos", + "authorship": "Steud.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213952250, + "accepted": "Carex brongniartii Kunth, 1837", + "numDescendants": 0 + }, + { + "key": 213952252, + "scientificName": "Carex muehlenbergii Brongn.", + "canonicalName": "Carex muehlenbergii", + "authorship": "Brongn.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213952250, + "accepted": "Carex brongniartii Kunth, 1837", + "numDescendants": 0 + }, + { + "key": 213952251, + "scientificName": "Carex muricata Schltdl. & Cham.", + "canonicalName": "Carex muricata", + "authorship": "Schltdl. & Cham.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213952250, + "accepted": "Carex brongniartii Kunth, 1837", + "numDescendants": 0 + } + ], + "213955316": [ + { + "key": 213955320, + "scientificName": "Carex canescens Huds.", + "canonicalName": "Carex canescens", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522399, + "scientificName": "Carex divulsa f. angustifolia (Podp.) So\u00f3", + "canonicalName": "Carex divulsa angustifolia", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522397, + "scientificName": "Carex divulsa f. guestphalica (Rchb.) K\u00fck.", + "canonicalName": "Carex divulsa guestphalica", + "authorship": "(Rchb.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522395, + "scientificName": "Carex divulsa f. misera (K\u00fck. ex Vollm.) K\u00fck.", + "canonicalName": "Carex divulsa misera", + "authorship": "(K\u00fck. ex Vollm.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522396, + "scientificName": "Carex divulsa f. polycarpa (Vollm.) K\u00fck.", + "canonicalName": "Carex divulsa polycarpa", + "authorship": "(Vollm.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522417, + "scientificName": "Carex divulsa subsp. divulsa", + "canonicalName": "Carex divulsa divulsa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522413, + "scientificName": "Carex divulsa subsp. virens (Lam.) Nyman", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Nyman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522408, + "scientificName": "Carex divulsa var. approximata Legrand", + "canonicalName": "Carex divulsa approximata", + "authorship": "Legrand", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522429, + "scientificName": "Carex divulsa var. congesta Gren.", + "canonicalName": "Carex divulsa congesta", + "authorship": "Gren.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522418, + "scientificName": "Carex divulsa var. guestphalica (Rchb.) F.W.Schultz ex Asch. & Graebn.", + "canonicalName": "Carex divulsa guestphalica", + "authorship": "(Rchb.) F.W.Schultz ex Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522428, + "scientificName": "Carex divulsa var. javanica Nelmes", + "canonicalName": "Carex divulsa javanica", + "authorship": "Nelmes", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522419, + "scientificName": "Carex divulsa var. misera K\u00fck. ex Vollm.", + "canonicalName": "Carex divulsa misera", + "authorship": "K\u00fck. ex Vollm.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522420, + "scientificName": "Carex divulsa var. polycarpa Vollm.", + "canonicalName": "Carex divulsa polycarpa", + "authorship": "Vollm.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522403, + "scientificName": "Carex divulsa var. virens (Lam.) M\u00e9rat", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) M\u00e9rat", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522404, + "scientificName": "Carex divulsa var. virens (Lam.) Steud.", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Steud.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522401, + "scientificName": "Carex divulsa var. virens (Lam.) Steud.", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Steud.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522407, + "scientificName": "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz", + "canonicalName": "Carex echinata pseudodivulsa", + "authorship": "(F.W.Schultz) F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955319, + "scientificName": "Carex guestphalica (Rchb.) Boenn. ex O.Lang", + "canonicalName": "Carex guestphalica", + "authorship": "(Rchb.) Boenn. ex O.Lang", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955317, + "scientificName": "Carex guestphalica (Rchb.) Boenn. ex W.D.J.Koch", + "canonicalName": "Carex guestphalica", + "authorship": "(Rchb.) Boenn. ex W.D.J.Koch", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955318, + "scientificName": "Carex guestphalica Boenn. ex W.D.J.Koch", + "canonicalName": "Carex guestphalica", + "authorship": "Boenn. ex W.D.J.Koch", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955326, + "scientificName": "Carex lumnitzeri (Rouy) V.I.Krecz.", + "canonicalName": "Carex lumnitzeri", + "authorship": "(Rouy) V.I.Krecz.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955325, + "scientificName": "Carex muricata Desf.", + "canonicalName": "Carex muricata", + "authorship": "Desf.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522400, + "scientificName": "Carex muricata f. angustifolia Podp.", + "canonicalName": "Carex muricata angustifolia", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522416, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Celak.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Celak.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522415, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Wahlenb.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Wahlenb.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522410, + "scientificName": "Carex muricata subsp. lumnitzeri (Rouy) So\u00f3", + "canonicalName": "Carex muricata lumnitzeri", + "authorship": "(Rouy) So\u00f3", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522412, + "scientificName": "Carex muricata subsp. virens (Lam.) \u010celak.", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) \u010celak.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522421, + "scientificName": "Carex muricata var. angustifolia Podp.", + "canonicalName": "Carex muricata angustifolia", + "authorship": "Podp.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522422, + "scientificName": "Carex muricata var. divulsa (Stokes) Wahlenb.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Wahlenb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522402, + "scientificName": "Carex muricata var. lumnitzeri Rouy", + "canonicalName": "Carex muricata lumnitzeri", + "authorship": "Rouy", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522427, + "scientificName": "Carex muricata var. nemorosa Gaudin", + "canonicalName": "Carex muricata nemorosa", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522423, + "scientificName": "Carex muricata var. subramosa Neilr.", + "canonicalName": "Carex muricata subramosa", + "authorship": "Neilr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522424, + "scientificName": "Carex muricata var. virens (Lam.) Rchb.", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) Rchb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955324, + "scientificName": "Carex nemorosa Lumn.", + "canonicalName": "Carex nemorosa", + "authorship": "Lumn.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955323, + "scientificName": "Carex persica Nelmes", + "canonicalName": "Carex persica", + "authorship": "Nelmes", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522411, + "scientificName": "Carex spicata subsp. lumnitzeri (Rouy) So\u00f3", + "canonicalName": "Carex spicata lumnitzeri", + "authorship": "(Rouy) So\u00f3", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522398, + "scientificName": "Carex stellulata f. pseudodivulsa F.W.Schultz", + "canonicalName": "Carex stellulata pseudodivulsa", + "authorship": "F.W.Schultz", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955322, + "scientificName": "Carex subramosa Willd. ex Kunth", + "canonicalName": "Carex subramosa", + "authorship": "Willd. ex Kunth", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955321, + "scientificName": "Carex virens Lam.", + "canonicalName": "Carex virens", + "authorship": "Lam.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522405, + "scientificName": "Carex virens var. divulsa (Stokes) F.W.Schultz", + "canonicalName": "Carex virens divulsa", + "authorship": "(Stokes) F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522425, + "scientificName": "Carex virens var. guestphalica (Rchb.) Garcke", + "canonicalName": "Carex virens guestphalica", + "authorship": "(Rchb.) Garcke", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522406, + "scientificName": "Carex virens var. major G.Mey.", + "canonicalName": "Carex virens major", + "authorship": "G.Mey.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522426, + "scientificName": "Carex viridis var. divulsa (Stokes) Spenn.", + "canonicalName": "Carex viridis divulsa", + "authorship": "(Stokes) Spenn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 217522414, + "scientificName": "Carex vulpina subsp. nemorosa (Gaudin) O.Schwarz", + "canonicalName": "Carex vulpina nemorosa", + "authorship": "(Gaudin) O.Schwarz", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955331, + "scientificName": "Caricina divulsa (Stokes) St.-Lag.", + "canonicalName": "Caricina divulsa", + "authorship": "(Stokes) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955330, + "scientificName": "Vignea divulsa (Stokes) Rchb.", + "canonicalName": "Vignea divulsa", + "authorship": "(Stokes) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955329, + "scientificName": "Vignea guestphalica Rchb.", + "canonicalName": "Vignea guestphalica", + "authorship": "Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955328, + "scientificName": "Vignea persica (Nelmes) Soj\u00e1k", + "canonicalName": "Vignea persica", + "authorship": "(Nelmes) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + }, + { + "key": 213955327, + "scientificName": "Vignea virens (Lam.) Rchb.", + "canonicalName": "Vignea virens", + "authorship": "(Lam.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213955316, + "accepted": "Carex divulsa Stokes, 1787", + "numDescendants": 0 + } + ], + "221885990": [ + { + "key": 221886006, + "scientificName": "Carex contigua Hoppe, 1835", + "canonicalName": "Carex contigua", + "authorship": "Hoppe, 1835", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + }, + { + "key": 221885996, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + }, + { + "key": 221886003, + "scientificName": "Carex muricata subsp. contigua (Hoppe) Moravec", + "canonicalName": "Carex muricata contigua", + "authorship": "(Hoppe) Moravec", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + }, + { + "key": 221885999, + "scientificName": "Carex muricata subsp. macrocarpa Neuman, 1901", + "canonicalName": "Carex muricata macrocarpa", + "authorship": "Neuman, 1901", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + }, + { + "key": 221885993, + "scientificName": "Vignea spicata (Huds.) Soj\u00e1k, 1980", + "canonicalName": "Vignea spicata", + "authorship": "(Huds.) Soj\u00e1k, 1980", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221885990, + "accepted": "Carex spicata Huds., 1762", + "numDescendants": 0 + } + ], + "221886838": [ + { + "key": 221886866, + "scientificName": "Carex bullockiana Nelmes, 1959", + "canonicalName": "Carex bullockiana", + "authorship": "Nelmes, 1959", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886863, + "scientificName": "Carex contigua subsp. pairae (F.W.Schultz) Degen, 1936", + "canonicalName": "Carex contigua pairae", + "authorship": "(F.W.Schultz) Degen, 1936", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886859, + "scientificName": "Carex loliacea Schkuhr, 1801", + "canonicalName": "Carex loliacea", + "authorship": "Schkuhr, 1801", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886848, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886855, + "scientificName": "Carex muricata subsp. lamprocarpa", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886852, + "scientificName": "Carex muricata subsp. pairae", + "canonicalName": "Carex muricata pairae", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886845, + "scientificName": "Carex virens", + "canonicalName": "Carex virens", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 221886841, + "scientificName": "Vignea muricata subsp. lamprocarpa", + "canonicalName": "Vignea muricata lamprocarpa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + } + ], + "221889248": [ + { + "key": 221889350, + "scientificName": "Carex basilata Ohwi, 1942", + "canonicalName": "Carex basilata", + "authorship": "Ohwi, 1942", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889348, + "scientificName": "Carex echinata race grypos (Schkuhr) Rouy, 1912", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Rouy, 1912", + "rank": "RACE", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889346, + "scientificName": "Carex echinata subsp. grypos (Schkuhr) Arcang., 1882", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Arcang., 1882", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889343, + "scientificName": "Carex echinata var. elata Maire ex Rouy, 1904", + "canonicalName": "Carex echinata elata", + "authorship": "Maire ex Rouy, 1904", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889340, + "scientificName": "Carex echinata var. grypsos (Schkuhr) Nyman, 1882", + "canonicalName": "Carex echinata grypsos", + "authorship": "(Schkuhr) Nyman, 1882", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889338, + "scientificName": "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz, 1863", + "canonicalName": "Carex echinata pseudodivulsa", + "authorship": "(F.W.Schultz) F.W.Schultz, 1863", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889335, + "scientificName": "Carex grypos Schkuhr, 1806", + "canonicalName": "Carex grypos", + "authorship": "Schkuhr, 1806", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889332, + "scientificName": "Carex leersii Willd., 1787", + "canonicalName": "Carex leersii", + "authorship": "Willd., 1787", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889329, + "scientificName": "Carex muricata", + "canonicalName": "Carex muricata", + "authorship": "", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889320, + "scientificName": "Carex stellulata Gooden., 1794", + "canonicalName": "Carex stellulata", + "authorship": "Gooden., 1794", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889326, + "scientificName": "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch, 1847", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) W.D.J.Koch, 1847", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889323, + "scientificName": "Carex stellulata var. pseudodivulsa F.W.Schultz, 1845", + "canonicalName": "Carex stellulata pseudodivulsa", + "authorship": "F.W.Schultz, 1845", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889317, + "scientificName": "Caricina stellulata (Gooden.) St.-Lag., 1889", + "canonicalName": "Caricina stellulata", + "authorship": "(Gooden.) St.-Lag., 1889", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + }, + { + "key": 221889314, + "scientificName": "Vignea echinata (Murray) Fourr., 1869", + "canonicalName": "Vignea echinata", + "authorship": "(Murray) Fourr., 1869", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221889248, + "accepted": "Carex echinata Murray, 1770", + "numDescendants": 0 + } + ], + "224053913": [], + "224776247": [], + "239712335": [], + "239766740": [], + "239810880": [], + "239899602": [], + "239901161": [], + "240241066": [], + "240469724": [], + "240476050": [], + "240540573": [], + "240621298": [], + "241006592": [], + "241059491": [], + "241062168": [], + "241100745": [], + "241176967": [], + "241268823": [], + "241348670": [], + "241388226": [], + "241465330": [], + "241553298": [], + "241601934": [], + "242137566": [], + "269916461": [ + { + "key": 269916822, + "scientificName": "Carex angustior Mack.", + "canonicalName": "Carex angustior", + "authorship": "Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916692, + "scientificName": "Carex angustior var. gracilenta R.T.Clausen & Wahl", + "canonicalName": "Carex angustior gracilenta", + "authorship": "R.T.Clausen & Wahl", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916528, + "scientificName": "Carex basilata Ohwi", + "canonicalName": "Carex basilata", + "authorship": "Ohwi", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916524, + "scientificName": "Carex caflischii Br\u00fcgger", + "canonicalName": "Carex caflischii", + "authorship": "Br\u00fcgger", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916519, + "scientificName": "Carex cephalantha (L.H.Bailey) E.P.Bicknell", + "canonicalName": "Carex cephalantha", + "authorship": "(L.H.Bailey) E.P.Bicknell", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916515, + "scientificName": "Carex convexa Kit.", + "canonicalName": "Carex convexa", + "authorship": "Kit.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916750, + "scientificName": "Carex echinata f. brevispicata Podp.", + "canonicalName": "Carex echinata brevispicata", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916754, + "scientificName": "Carex echinata f. chlorocarpa Podp.", + "canonicalName": "Carex echinata chlorocarpa", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916758, + "scientificName": "Carex echinata f. remotiuscula Podp.", + "canonicalName": "Carex echinata remotiuscula", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916532, + "scientificName": "Carex echinata f. subalpina Almq.", + "canonicalName": "Carex echinata subalpina", + "authorship": "Almq.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916545, + "scientificName": "Carex echinata prol. grypos (Schkuhr) Rouy", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Rouy", + "rank": "PROLES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916660, + "scientificName": "Carex echinata subsp. gasparrinii (Parl.) K.Richt.", + "canonicalName": "Carex echinata gasparrinii", + "authorship": "(Parl.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916664, + "scientificName": "Carex echinata subsp. grypos (Schkuhr) Arcang.", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Arcang.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916668, + "scientificName": "Carex echinata subsp. hydrophila (Dumort.) K.Richt.", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916622, + "scientificName": "Carex echinata var. angustata (J.Carey) L.H.Bailey", + "canonicalName": "Carex echinata angustata", + "authorship": "(J.Carey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916630, + "scientificName": "Carex echinata var. cephalantha L.H.Bailey", + "canonicalName": "Carex echinata cephalantha", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916626, + "scientificName": "Carex echinata var. elata Maire ex Rouy", + "canonicalName": "Carex echinata elata", + "authorship": "Maire ex Rouy", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916634, + "scientificName": "Carex echinata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex echinata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916843, + "scientificName": "Carex echinata var. grypos (Schkuhr) Nyman", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916536, + "scientificName": "Carex echinata var. gypsos (Schkuhr) Nyman", + "canonicalName": "Carex echinata gypsos", + "authorship": "(Schkuhr) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916839, + "scientificName": "Carex echinata var. hydrophila (Dumort.) Nyman", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916847, + "scientificName": "Carex echinata var. microstachys Boeckeler", + "canonicalName": "Carex echinata microstachys", + "authorship": "Boeckeler", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916641, + "scientificName": "Carex echinata var. ormantha Fernald", + "canonicalName": "Carex echinata ormantha", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916835, + "scientificName": "Carex echinata var. perileia (S.T.Blake) P.Royen", + "canonicalName": "Carex echinata perileia", + "authorship": "(S.T.Blake) P.Royen", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916637, + "scientificName": "Carex echinata var. tenuior K\u00fck.", + "canonicalName": "Carex echinata tenuior", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916507, + "scientificName": "Carex fasciculata Link ex Schkuhr", + "canonicalName": "Carex fasciculata", + "authorship": "Link ex Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916510, + "scientificName": "Carex fasciculata Willd.", + "canonicalName": "Carex fasciculata", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916502, + "scientificName": "Carex gajonum Nelmes", + "canonicalName": "Carex gajonum", + "authorship": "Nelmes", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916498, + "scientificName": "Carex gasparrinii Parl.", + "canonicalName": "Carex gasparrinii", + "authorship": "Parl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916494, + "scientificName": "Carex grypos Schkuhr", + "canonicalName": "Carex grypos", + "authorship": "Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916549, + "scientificName": "Carex grypos var. nana Christ ex Briq.", + "canonicalName": "Carex grypos nana", + "authorship": "Christ ex Briq.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916491, + "scientificName": "Carex hawaiiensis H.St.John", + "canonicalName": "Carex hawaiiensis", + "authorship": "H.St.John", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916487, + "scientificName": "Carex hydrophila Dumort.", + "canonicalName": "Carex hydrophila", + "authorship": "Dumort.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916696, + "scientificName": "Carex interior var. josselynii Fernald", + "canonicalName": "Carex interior josselynii", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916809, + "scientificName": "Carex josselynii (Fernald) Mack. ex Pease", + "canonicalName": "Carex josselynii", + "authorship": "(Fernald) Mack. ex Pease", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916813, + "scientificName": "Carex laricina Mack. ex Bright", + "canonicalName": "Carex laricina", + "authorship": "Mack. ex Bright", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916799, + "scientificName": "Carex leersii Willd.", + "canonicalName": "Carex leersii", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916700, + "scientificName": "Carex leersii var. angustata (J.Carey) Mack.", + "canonicalName": "Carex leersii angustata", + "authorship": "(J.Carey) Mack.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916704, + "scientificName": "Carex leersii var. cephalantha (L.H.Bailey) J.K.Henry", + "canonicalName": "Carex leersii cephalantha", + "authorship": "(L.H.Bailey) J.K.Henry", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916818, + "scientificName": "Carex leptophylla Heuff.", + "canonicalName": "Carex leptophylla", + "authorship": "Heuff.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916794, + "scientificName": "Carex minganinsularum Raymond", + "canonicalName": "Carex minganinsularum", + "authorship": "Raymond", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916553, + "scientificName": "Carex muricata Huds. auct. non Huds.", + "canonicalName": "Carex muricata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916557, + "scientificName": "Carex muricata Leers auct. non Leers", + "canonicalName": "Carex muricata", + "authorship": "Leers", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916672, + "scientificName": "Carex muricata subsp. cephalantha (L.H.Bailey) R.T.Clausen", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) R.T.Clausen", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916676, + "scientificName": "Carex muricata subsp. microcarpa Neuman", + "canonicalName": "Carex muricata microcarpa", + "authorship": "Neuman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916708, + "scientificName": "Carex muricata var. angustata (J.Carey) J.Carey ex Gleason", + "canonicalName": "Carex muricata angustata", + "authorship": "(J.Carey) J.Carey ex Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916711, + "scientificName": "Carex muricata var. basilata (Ohwi) Y.L.Chou", + "canonicalName": "Carex muricata basilata", + "authorship": "(Ohwi) Y.L.Chou", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916715, + "scientificName": "Carex muricata var. cephalantha (L.H.Bailey) Wiegand & Eames", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) Wiegand & Eames", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916561, + "scientificName": "Carex muricata var. depauperata Hampe", + "canonicalName": "Carex muricata depauperata", + "authorship": "Hampe", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916719, + "scientificName": "Carex muricata var. laricina (Mack. ex Bright) Gleason", + "canonicalName": "Carex muricata laricina", + "authorship": "(Mack. ex Bright) Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916723, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 303009169, + "scientificName": "Carex muricata var. virens Andersson", + "canonicalName": "Carex muricata virens", + "authorship": "Andersson", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916732, + "scientificName": "Carex omiana var. perileia (S.T.Blake) T.Koyama", + "canonicalName": "Carex omiana perileia", + "authorship": "(S.T.Blake) T.Koyama", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916826, + "scientificName": "Carex ormantha (Fernald) Mack.", + "canonicalName": "Carex ormantha", + "authorship": "(Fernald) Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916763, + "scientificName": "Carex pairae f. brevispicata (Podp.) So\u00f3", + "canonicalName": "Carex pairae brevispicata", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916767, + "scientificName": "Carex pairae f. chlorocarpa (Podp.) So\u00f3", + "canonicalName": "Carex pairae chlorocarpa", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916771, + "scientificName": "Carex pairae f. remotiuscula (Podp.) So\u00f3", + "canonicalName": "Carex pairae remotiuscula", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916483, + "scientificName": "Carex perileia S.T.Blake", + "canonicalName": "Carex perileia", + "authorship": "S.T.Blake", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916565, + "scientificName": "Carex phyllomanica var. angustata (J.Carey) B.Boivin", + "canonicalName": "Carex phyllomanica angustata", + "authorship": "(J.Carey) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916569, + "scientificName": "Carex phyllomanica var. ormantha (Fernald) B.Boivin", + "canonicalName": "Carex phyllomanica ormantha", + "authorship": "(Fernald) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916803, + "scientificName": "Carex provincialis Degl.", + "canonicalName": "Carex provincialis", + "authorship": "Degl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916680, + "scientificName": "Carex riparia subsp. fasciculata (Link ex Schkuhr) K.Richt.", + "canonicalName": "Carex riparia fasciculata", + "authorship": "(Link ex Schkuhr) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916573, + "scientificName": "Carex scirpoides var. josselynii (Fernald) Fernald", + "canonicalName": "Carex scirpoides josselynii", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916479, + "scientificName": "Carex serrulata Mutel", + "canonicalName": "Carex serrulata", + "authorship": "Mutel", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916475, + "scientificName": "Carex stellulata Gooden.", + "canonicalName": "Carex stellulata", + "authorship": "Gooden.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916776, + "scientificName": "Carex stellulata f. excelsior (L.H.Bailey) K\u00fck.", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916780, + "scientificName": "Carex stellulata f. hydrophila (Dumort.) Asch. & Graebn.", + "canonicalName": "Carex stellulata hydrophila", + "authorship": "(Dumort.) Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916786, + "scientificName": "Carex stellulata f. hylogiton Asch. & Graebn.", + "canonicalName": "Carex stellulata hylogiton", + "authorship": "Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916790, + "scientificName": "Carex stellulata f. oligantha (Callm\u00e9) K\u00fck.", + "canonicalName": "Carex stellulata oligantha", + "authorship": "(Callm\u00e9) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916683, + "scientificName": "Carex stellulata subsp. grypos (Schkuhr) Gaudin", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) Gaudin", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916688, + "scientificName": "Carex stellulata subsp. pallens Gaudin", + "canonicalName": "Carex stellulata pallens", + "authorship": "Gaudin", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916617, + "scientificName": "Carex stellulata var. alpestris Gaudin", + "canonicalName": "Carex stellulata alpestris", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916589, + "scientificName": "Carex stellulata var. angustata J.Carey", + "canonicalName": "Carex stellulata angustata", + "authorship": "J.Carey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916609, + "scientificName": "Carex stellulata var. australis K\u00fck.", + "canonicalName": "Carex stellulata australis", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916581, + "scientificName": "Carex stellulata var. cephalantha (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata cephalantha", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916577, + "scientificName": "Carex stellulata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916613, + "scientificName": "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) W.D.J.Koch", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916585, + "scientificName": "Carex stellulata var. masculina Gray", + "canonicalName": "Carex stellulata masculina", + "authorship": "Gray", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916597, + "scientificName": "Carex stellulata var. oligantha Callm\u00e9", + "canonicalName": "Carex stellulata oligantha", + "authorship": "Callm\u00e9", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916593, + "scientificName": "Carex stellulata var. ormantha (Fernald) Fernald", + "canonicalName": "Carex stellulata ormantha", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916541, + "scientificName": "Carex stellulata var. pseudodivulsa F.W.Schultz", + "canonicalName": "Carex stellulata pseudodivulsa", + "authorship": "F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916601, + "scientificName": "Carex stellulata var. scirpina Tuck.", + "canonicalName": "Carex stellulata scirpina", + "authorship": "Tuck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916605, + "scientificName": "Carex stellulata var. subalpina Asch. & Graebn.", + "canonicalName": "Carex stellulata subalpina", + "authorship": "Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916737, + "scientificName": "Carex sterilis var. cephalantha (L.H.Bailey) L.H.Bailey", + "canonicalName": "Carex sterilis cephalantha", + "authorship": "(L.H.Bailey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916741, + "scientificName": "Carex sterilis var. excelsior L.H.Bailey", + "canonicalName": "Carex sterilis excelsior", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916472, + "scientificName": "Carex svenonis Skottsb.", + "canonicalName": "Carex svenonis", + "authorship": "Skottsb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916745, + "scientificName": "Carex svenonis var. alakaiensis Skottsb.", + "canonicalName": "Carex svenonis alakaiensis", + "authorship": "Skottsb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916467, + "scientificName": "Caricina stellulata (Gooden.) St.-Lag.", + "canonicalName": "Caricina stellulata", + "authorship": "(Gooden.) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916645, + "scientificName": "Vignea angustior (Mack.) Soj\u00e1k", + "canonicalName": "Vignea angustior", + "authorship": "(Mack.) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916649, + "scientificName": "Vignea grypos (Schkuhr) Rchb.", + "canonicalName": "Vignea grypos", + "authorship": "(Schkuhr) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916653, + "scientificName": "Vignea hydrophila (Dumort.) Rchb.", + "canonicalName": "Vignea hydrophila", + "authorship": "(Dumort.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916657, + "scientificName": "Vignea perileia (S.T.Blake) Soj\u00e1k", + "canonicalName": "Vignea perileia", + "authorship": "(S.T.Blake) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916831, + "scientificName": "Vignea stellulata (Gooden.) Rchb.", + "canonicalName": "Vignea stellulata", + "authorship": "(Gooden.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + } + ], + "296338562": [ + { + "key": 296338815, + "scientificName": "Carex angustior Mack.", + "canonicalName": "Carex angustior", + "authorship": "Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338732, + "scientificName": "Carex angustior var. gracilenta R.T.Clausen & Wahl", + "canonicalName": "Carex angustior gracilenta", + "authorship": "R.T.Clausen & Wahl", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338608, + "scientificName": "Carex basilata Ohwi", + "canonicalName": "Carex basilata", + "authorship": "Ohwi", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338605, + "scientificName": "Carex caflischii Br\u00fcgger", + "canonicalName": "Carex caflischii", + "authorship": "Br\u00fcgger", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338602, + "scientificName": "Carex cephalantha (L.H.Bailey) E.P.Bicknell", + "canonicalName": "Carex cephalantha", + "authorship": "(L.H.Bailey) E.P.Bicknell", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338599, + "scientificName": "Carex convexa Kit.", + "canonicalName": "Carex convexa", + "authorship": "Kit.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338770, + "scientificName": "Carex echinata f. brevispicata Podp.", + "canonicalName": "Carex echinata brevispicata", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338773, + "scientificName": "Carex echinata f. chlorocarpa Podp.", + "canonicalName": "Carex echinata chlorocarpa", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338776, + "scientificName": "Carex echinata f. remotiuscula Podp.", + "canonicalName": "Carex echinata remotiuscula", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338618, + "scientificName": "Carex echinata f. subalpina Almq.", + "canonicalName": "Carex echinata subalpina", + "authorship": "Almq.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338624, + "scientificName": "Carex echinata prol. grypos (Schkuhr) Rouy", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Rouy", + "rank": "PROLES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338708, + "scientificName": "Carex echinata subsp. gasparrinii (Parl.) K.Richt.", + "canonicalName": "Carex echinata gasparrinii", + "authorship": "(Parl.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338710, + "scientificName": "Carex echinata subsp. grypos (Schkuhr) Arcang.", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Arcang.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285180036, + "scientificName": "Carex echinata subsp. grypos (Schkuhr) K.Richt.", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338713, + "scientificName": "Carex echinata subsp. hydrophila (Dumort.) K.Richt.", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338680, + "scientificName": "Carex echinata var. angustata (J.Carey) L.H.Bailey", + "canonicalName": "Carex echinata angustata", + "authorship": "(J.Carey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338687, + "scientificName": "Carex echinata var. cephalantha L.H.Bailey", + "canonicalName": "Carex echinata cephalantha", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338683, + "scientificName": "Carex echinata var. elata Maire ex Rouy", + "canonicalName": "Carex echinata elata", + "authorship": "Maire ex Rouy", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338690, + "scientificName": "Carex echinata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex echinata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285180029, + "scientificName": "Carex echinata var. grypos (Schkuhr) Fiori", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Fiori", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338830, + "scientificName": "Carex echinata var. grypos (Schkuhr) Nyman", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285180032, + "scientificName": "Carex echinata var. grypos (Schkuhr) Rhiner", + "canonicalName": "Carex echinata grypos", + "authorship": "(Schkuhr) Rhiner", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338620, + "scientificName": "Carex echinata var. gypsos (Schkuhr) Nyman", + "canonicalName": "Carex echinata gypsos", + "authorship": "(Schkuhr) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338827, + "scientificName": "Carex echinata var. hydrophila (Dumort.) Nyman", + "canonicalName": "Carex echinata hydrophila", + "authorship": "(Dumort.) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338832, + "scientificName": "Carex echinata var. microstachys Boeckeler", + "canonicalName": "Carex echinata microstachys", + "authorship": "Boeckeler", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338834, + "scientificName": "Carex echinata var. ormantha Fernald", + "canonicalName": "Carex echinata ormantha", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338824, + "scientificName": "Carex echinata var. perileia (S.T.Blake) P.Royen", + "canonicalName": "Carex echinata perileia", + "authorship": "(S.T.Blake) P.Royen", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338693, + "scientificName": "Carex echinata var. tenuior K\u00fck.", + "canonicalName": "Carex echinata tenuior", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338595, + "scientificName": "Carex fasciculata Link ex Schkuhr", + "canonicalName": "Carex fasciculata", + "authorship": "Link ex Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338597, + "scientificName": "Carex fasciculata Willd.", + "canonicalName": "Carex fasciculata", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338594, + "scientificName": "Carex gajonum Nelmes", + "canonicalName": "Carex gajonum", + "authorship": "Nelmes", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338592, + "scientificName": "Carex gasparrinii Parl.", + "canonicalName": "Carex gasparrinii", + "authorship": "Parl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338590, + "scientificName": "Carex grypos Schkuhr", + "canonicalName": "Carex grypos", + "authorship": "Schkuhr", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338630, + "scientificName": "Carex grypos var. nana Christ ex Briq.", + "canonicalName": "Carex grypos nana", + "authorship": "Christ ex Briq.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338587, + "scientificName": "Carex hawaiiensis H.St.John", + "canonicalName": "Carex hawaiiensis", + "authorship": "H.St.John", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338584, + "scientificName": "Carex hydrophila Dumort.", + "canonicalName": "Carex hydrophila", + "authorship": "Dumort.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338735, + "scientificName": "Carex interior var. josselynii Fernald", + "canonicalName": "Carex interior josselynii", + "authorship": "Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338807, + "scientificName": "Carex josselynii (Fernald) Mack. ex Pease", + "canonicalName": "Carex josselynii", + "authorship": "(Fernald) Mack. ex Pease", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338810, + "scientificName": "Carex laricina Mack. ex Bright", + "canonicalName": "Carex laricina", + "authorship": "Mack. ex Bright", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338801, + "scientificName": "Carex leersii Willd.", + "canonicalName": "Carex leersii", + "authorship": "Willd.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338738, + "scientificName": "Carex leersii var. angustata (J.Carey) Mack.", + "canonicalName": "Carex leersii angustata", + "authorship": "(J.Carey) Mack.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338741, + "scientificName": "Carex leersii var. cephalantha (L.H.Bailey) J.K.Henry", + "canonicalName": "Carex leersii cephalantha", + "authorship": "(L.H.Bailey) J.K.Henry", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338813, + "scientificName": "Carex leptophylla Heuff.", + "canonicalName": "Carex leptophylla", + "authorship": "Heuff.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338798, + "scientificName": "Carex minganinsularum Raymond", + "canonicalName": "Carex minganinsularum", + "authorship": "Raymond", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338632, + "scientificName": "Carex muricata Huds. auct. non Huds.", + "canonicalName": "Carex muricata", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338634, + "scientificName": "Carex muricata Leers auct. non Leers", + "canonicalName": "Carex muricata", + "authorship": "Leers", + "rank": "SPECIES", + "taxonomicStatus": "MISAPPLIED", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338717, + "scientificName": "Carex muricata subsp. cephalantha (L.H.Bailey) R.T.Clausen", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) R.T.Clausen", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338719, + "scientificName": "Carex muricata subsp. microcarpa Neuman", + "canonicalName": "Carex muricata microcarpa", + "authorship": "Neuman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338744, + "scientificName": "Carex muricata var. angustata (J.Carey) J.Carey ex Gleason", + "canonicalName": "Carex muricata angustata", + "authorship": "(J.Carey) J.Carey ex Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338746, + "scientificName": "Carex muricata var. basilata (Ohwi) Y.L.Chou", + "canonicalName": "Carex muricata basilata", + "authorship": "(Ohwi) Y.L.Chou", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338748, + "scientificName": "Carex muricata var. cephalantha (L.H.Bailey) Wiegand & Eames", + "canonicalName": "Carex muricata cephalantha", + "authorship": "(L.H.Bailey) Wiegand & Eames", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338637, + "scientificName": "Carex muricata var. depauperata Hampe", + "canonicalName": "Carex muricata depauperata", + "authorship": "Hampe", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338751, + "scientificName": "Carex muricata var. laricina (Mack. ex Bright) Gleason", + "canonicalName": "Carex muricata laricina", + "authorship": "(Mack. ex Bright) Gleason", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338754, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 304070919, + "scientificName": "Carex muricata var. virens Andersson", + "canonicalName": "Carex muricata virens", + "authorship": "Andersson", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338757, + "scientificName": "Carex omiana var. perileia (S.T.Blake) T.Koyama", + "canonicalName": "Carex omiana perileia", + "authorship": "(S.T.Blake) T.Koyama", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338818, + "scientificName": "Carex ormantha (Fernald) Mack.", + "canonicalName": "Carex ormantha", + "authorship": "(Fernald) Mack.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338779, + "scientificName": "Carex pairae f. brevispicata (Podp.) So\u00f3", + "canonicalName": "Carex pairae brevispicata", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338781, + "scientificName": "Carex pairae f. chlorocarpa (Podp.) So\u00f3", + "canonicalName": "Carex pairae chlorocarpa", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338783, + "scientificName": "Carex pairae f. remotiuscula (Podp.) So\u00f3", + "canonicalName": "Carex pairae remotiuscula", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338581, + "scientificName": "Carex perileia S.T.Blake", + "canonicalName": "Carex perileia", + "authorship": "S.T.Blake", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338640, + "scientificName": "Carex phyllomanica var. angustata (J.Carey) B.Boivin", + "canonicalName": "Carex phyllomanica angustata", + "authorship": "(J.Carey) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338643, + "scientificName": "Carex phyllomanica var. ormantha (Fernald) B.Boivin", + "canonicalName": "Carex phyllomanica ormantha", + "authorship": "(Fernald) B.Boivin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338803, + "scientificName": "Carex provincialis Degl.", + "canonicalName": "Carex provincialis", + "authorship": "Degl.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338722, + "scientificName": "Carex riparia subsp. fasciculata (Link ex Schkuhr) K.Richt.", + "canonicalName": "Carex riparia fasciculata", + "authorship": "(Link ex Schkuhr) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338646, + "scientificName": "Carex scirpoides var. josselynii (Fernald) Fernald", + "canonicalName": "Carex scirpoides josselynii", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338578, + "scientificName": "Carex serrulata Mutel", + "canonicalName": "Carex serrulata", + "authorship": "Mutel", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338575, + "scientificName": "Carex stellulata Gooden.", + "canonicalName": "Carex stellulata", + "authorship": "Gooden.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338786, + "scientificName": "Carex stellulata f. excelsior (L.H.Bailey) K\u00fck.", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338789, + "scientificName": "Carex stellulata f. hydrophila (Dumort.) Asch. & Graebn.", + "canonicalName": "Carex stellulata hydrophila", + "authorship": "(Dumort.) Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285179838, + "scientificName": "Carex stellulata f. hydrophila (Dumort.) Vollm.", + "canonicalName": "Carex stellulata hydrophila", + "authorship": "(Dumort.) Vollm.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338792, + "scientificName": "Carex stellulata f. hylogiton Asch. & Graebn.", + "canonicalName": "Carex stellulata hylogiton", + "authorship": "Asch. & Graebn.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285179752, + "scientificName": "Carex stellulata f. longibracteata Zapal.", + "canonicalName": "Carex stellulata longibracteata", + "authorship": "Zapal.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338795, + "scientificName": "Carex stellulata f. oligantha (Callm\u00e9) K\u00fck.", + "canonicalName": "Carex stellulata oligantha", + "authorship": "(Callm\u00e9) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285179827, + "scientificName": "Carex stellulata f. oligantha Callm\u00e9", + "canonicalName": "Carex stellulata oligantha", + "authorship": "Callm\u00e9", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338726, + "scientificName": "Carex stellulata subsp. grypos (Schkuhr) Gaudin", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) Gaudin", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338729, + "scientificName": "Carex stellulata subsp. pallens Gaudin", + "canonicalName": "Carex stellulata pallens", + "authorship": "Gaudin", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338677, + "scientificName": "Carex stellulata var. alpestris Gaudin", + "canonicalName": "Carex stellulata alpestris", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338659, + "scientificName": "Carex stellulata var. angustata J.Carey", + "canonicalName": "Carex stellulata angustata", + "authorship": "J.Carey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338671, + "scientificName": "Carex stellulata var. australis K\u00fck.", + "canonicalName": "Carex stellulata australis", + "authorship": "K\u00fck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338652, + "scientificName": "Carex stellulata var. cephalantha (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata cephalantha", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338649, + "scientificName": "Carex stellulata var. excelsior (L.H.Bailey) Fernald", + "canonicalName": "Carex stellulata excelsior", + "authorship": "(L.H.Bailey) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338674, + "scientificName": "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch", + "canonicalName": "Carex stellulata grypos", + "authorship": "(Schkuhr) W.D.J.Koch", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338654, + "scientificName": "Carex stellulata var. masculina Gray", + "canonicalName": "Carex stellulata masculina", + "authorship": "Gray", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338664, + "scientificName": "Carex stellulata var. oligantha Callm\u00e9", + "canonicalName": "Carex stellulata oligantha", + "authorship": "Callm\u00e9", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338662, + "scientificName": "Carex stellulata var. ormantha (Fernald) Fernald", + "canonicalName": "Carex stellulata ormantha", + "authorship": "(Fernald) Fernald", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338622, + "scientificName": "Carex stellulata var. pseudodivulsa F.W.Schultz", + "canonicalName": "Carex stellulata pseudodivulsa", + "authorship": "F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338666, + "scientificName": "Carex stellulata var. scirpina Tuck.", + "canonicalName": "Carex stellulata scirpina", + "authorship": "Tuck.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338668, + "scientificName": "Carex stellulata var. subalpina Asch. & Graebn.", + "canonicalName": "Carex stellulata subalpina", + "authorship": "Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338760, + "scientificName": "Carex sterilis var. cephalantha (L.H.Bailey) L.H.Bailey", + "canonicalName": "Carex sterilis cephalantha", + "authorship": "(L.H.Bailey) L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338763, + "scientificName": "Carex sterilis var. excelsior L.H.Bailey", + "canonicalName": "Carex sterilis excelsior", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338572, + "scientificName": "Carex svenonis Skottsb.", + "canonicalName": "Carex svenonis", + "authorship": "Skottsb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338766, + "scientificName": "Carex svenonis var. alakaiensis Skottsb.", + "canonicalName": "Carex svenonis alakaiensis", + "authorship": "Skottsb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338569, + "scientificName": "Caricina stellulata (Gooden.) St.-Lag.", + "canonicalName": "Caricina stellulata", + "authorship": "(Gooden.) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285179747, + "scientificName": "Desmiograstis echinata (Murray) B\u00f6rner", + "canonicalName": "Desmiograstis echinata", + "authorship": "(Murray) B\u00f6rner", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 285179748, + "scientificName": "Desmiograstis stellulata (Gooden.) B\u00f6rner", + "canonicalName": "Desmiograstis stellulata", + "authorship": "(Gooden.) B\u00f6rner", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338697, + "scientificName": "Vignea angustior (Mack.) Soj\u00e1k", + "canonicalName": "Vignea angustior", + "authorship": "(Mack.) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338700, + "scientificName": "Vignea grypos (Schkuhr) Rchb.", + "canonicalName": "Vignea grypos", + "authorship": "(Schkuhr) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338701, + "scientificName": "Vignea hydrophila (Dumort.) Rchb.", + "canonicalName": "Vignea hydrophila", + "authorship": "(Dumort.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338705, + "scientificName": "Vignea perileia (S.T.Blake) Soj\u00e1k", + "canonicalName": "Vignea perileia", + "authorship": "(S.T.Blake) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338822, + "scientificName": "Vignea stellulata (Gooden.) Rchb.", + "canonicalName": "Vignea stellulata", + "authorship": "(Gooden.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + } + ], + "296339203": [ + { + "key": 296339223, + "scientificName": "Carex canescens Huds.", + "canonicalName": "Carex canescens", + "authorship": "Huds.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339264, + "scientificName": "Carex divulsa f. angustifolia (Podp.) So\u00f3", + "canonicalName": "Carex divulsa angustifolia", + "authorship": "(Podp.) So\u00f3", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339266, + "scientificName": "Carex divulsa f. guestphalica (Rchb.) K\u00fck.", + "canonicalName": "Carex divulsa guestphalica", + "authorship": "(Rchb.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339269, + "scientificName": "Carex divulsa f. misera (K\u00fck. ex Vollm.) K\u00fck.", + "canonicalName": "Carex divulsa misera", + "authorship": "(K\u00fck. ex Vollm.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339271, + "scientificName": "Carex divulsa f. polycarpa (Vollm.) K\u00fck.", + "canonicalName": "Carex divulsa polycarpa", + "authorship": "(Vollm.) K\u00fck.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180950, + "scientificName": "Carex divulsa subsp. divulsa", + "canonicalName": "Carex divulsa divulsa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339236, + "scientificName": "Carex divulsa subsp. virens (Lam.) Nyman", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Nyman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339296, + "scientificName": "Carex divulsa var. approximata Legrand", + "canonicalName": "Carex divulsa approximata", + "authorship": "Legrand", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339287, + "scientificName": "Carex divulsa var. congesta Gren.", + "canonicalName": "Carex divulsa congesta", + "authorship": "Gren.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180984, + "scientificName": "Carex divulsa var. guestphalica (Boenn. ex Rchb.) Nyman", + "canonicalName": "Carex divulsa guestphalica", + "authorship": "(Boenn. ex Rchb.) Nyman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339284, + "scientificName": "Carex divulsa var. guestphalica (Rchb.) F.W.Schultz ex Asch. & Graebn.", + "canonicalName": "Carex divulsa guestphalica", + "authorship": "(Rchb.) F.W.Schultz ex Asch. & Graebn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339290, + "scientificName": "Carex divulsa var. javanica Nelmes", + "canonicalName": "Carex divulsa javanica", + "authorship": "Nelmes", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339293, + "scientificName": "Carex divulsa var. misera K\u00fck. ex Vollm.", + "canonicalName": "Carex divulsa misera", + "authorship": "K\u00fck. ex Vollm.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339299, + "scientificName": "Carex divulsa var. polycarpa Vollm.", + "canonicalName": "Carex divulsa polycarpa", + "authorship": "Vollm.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181020, + "scientificName": "Carex divulsa var. virens (Lam.) Durieu", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Durieu", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339282, + "scientificName": "Carex divulsa var. virens (Lam.) M\u00e9rat", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) M\u00e9rat", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339318, + "scientificName": "Carex divulsa var. virens (Lam.) Steud.", + "canonicalName": "Carex divulsa virens", + "authorship": "(Lam.) Steud.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181024, + "scientificName": "Carex duriaei (F.W.Schultz) F.W.Schultz", + "canonicalName": "Carex duriaei", + "authorship": "(F.W.Schultz) F.W.Schultz", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181032, + "scientificName": "Carex echinata f. pseudodivulsa (F.W.Schultz) Suess.", + "canonicalName": "Carex echinata pseudodivulsa", + "authorship": "(F.W.Schultz) Suess.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339281, + "scientificName": "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz", + "canonicalName": "Carex echinata pseudodivulsa", + "authorship": "(F.W.Schultz) F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339220, + "scientificName": "Carex guestphalica (Rchb.) Boenn. ex O.Lang", + "canonicalName": "Carex guestphalica", + "authorship": "(Rchb.) Boenn. ex O.Lang", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339308, + "scientificName": "Carex lumnitzeri (Rouy) V.I.Krecz.", + "canonicalName": "Carex lumnitzeri", + "authorship": "(Rouy) V.I.Krecz.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339275, + "scientificName": "Carex muricata Desf.", + "canonicalName": "Carex muricata", + "authorship": "Desf.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181054, + "scientificName": "Carex muricata f. angustifolia Podp.", + "canonicalName": "Carex muricata angustifolia", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339315, + "scientificName": "Carex muricata prol. lumnitzeri Rouy", + "canonicalName": "Carex muricata lumnitzeri", + "authorship": "Rouy", + "rank": "PROLES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181065, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Bonnier & Layens", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Bonnier & Layens", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 301876152, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Celak.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Celak.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181069, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Corb.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Corb.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339323, + "scientificName": "Carex muricata subsp. divulsa (Stokes) Wahlenb.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Wahlenb.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339238, + "scientificName": "Carex muricata subsp. lumnitzeri (Rouy) So\u00f3", + "canonicalName": "Carex muricata lumnitzeri", + "authorship": "(Rouy) So\u00f3", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181111, + "scientificName": "Carex muricata subsp. virens (Lam.) K.Richt.", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) K.Richt.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339241, + "scientificName": "Carex muricata subsp. virens (Lam.) \u010celak.", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) \u010celak.", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339247, + "scientificName": "Carex muricata var. angustifolia Podp.", + "canonicalName": "Carex muricata angustifolia", + "authorship": "Podp.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339250, + "scientificName": "Carex muricata var. divulsa (Stokes) Wahlenb.", + "canonicalName": "Carex muricata divulsa", + "authorship": "(Stokes) Wahlenb.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180891, + "scientificName": "Carex muricata var. guestphalica (Boenn. ex O.Lang) Neuman", + "canonicalName": "Carex muricata guestphalica", + "authorship": "(Boenn. ex O.Lang) Neuman", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181092, + "scientificName": "Carex muricata var. lumnitzeri Rouy", + "canonicalName": "Carex muricata lumnitzeri", + "authorship": "Rouy", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339305, + "scientificName": "Carex muricata var. nemorosa Gaudin", + "canonicalName": "Carex muricata nemorosa", + "authorship": "Gaudin", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339252, + "scientificName": "Carex muricata var. subramosa Neilr.", + "canonicalName": "Carex muricata subramosa", + "authorship": "Neilr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339254, + "scientificName": "Carex muricata var. virens (Lam.) Rchb.", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) Rchb.", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285181115, + "scientificName": "Carex muricata var. virens (Lam.) W.D.J.Koch", + "canonicalName": "Carex muricata virens", + "authorship": "(Lam.) W.D.J.Koch", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339312, + "scientificName": "Carex nemorosa Lumn.", + "canonicalName": "Carex nemorosa", + "authorship": "Lumn.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339218, + "scientificName": "Carex persica Nelmes", + "canonicalName": "Carex persica", + "authorship": "Nelmes", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339242, + "scientificName": "Carex spicata subsp. lumnitzeri (Rouy) So\u00f3", + "canonicalName": "Carex spicata lumnitzeri", + "authorship": "(Rouy) So\u00f3", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339273, + "scientificName": "Carex stellulata f. pseudodivulsa F.W.Schultz", + "canonicalName": "Carex stellulata pseudodivulsa", + "authorship": "F.W.Schultz", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339277, + "scientificName": "Carex subramosa Willd. ex Kunth", + "canonicalName": "Carex subramosa", + "authorship": "Willd. ex Kunth", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339215, + "scientificName": "Carex virens Lam.", + "canonicalName": "Carex virens", + "authorship": "Lam.", + "rank": "SPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180942, + "scientificName": "Carex virens Steud.", + "canonicalName": "Carex virens", + "authorship": "Steud.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339257, + "scientificName": "Carex virens var. divulsa (Stokes) F.W.Schultz", + "canonicalName": "Carex virens divulsa", + "authorship": "(Stokes) F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180888, + "scientificName": "Carex virens var. duriaei F.W.Schultz", + "canonicalName": "Carex virens duriaei", + "authorship": "F.W.Schultz", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339259, + "scientificName": "Carex virens var. guestphalica (Rchb.) Garcke", + "canonicalName": "Carex virens guestphalica", + "authorship": "(Rchb.) Garcke", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339303, + "scientificName": "Carex virens var. major G.Mey.", + "canonicalName": "Carex virens major", + "authorship": "G.Mey.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339261, + "scientificName": "Carex viridis var. divulsa (Stokes) Spenn.", + "canonicalName": "Carex viridis divulsa", + "authorship": "(Stokes) Spenn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180898, + "scientificName": "Carex viridis var. longibracteata Spenn.", + "canonicalName": "Carex viridis longibracteata", + "authorship": "Spenn.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339245, + "scientificName": "Carex vulpina subsp. nemorosa (Gaudin) O.Schwarz", + "canonicalName": "Carex vulpina nemorosa", + "authorship": "(Gaudin) O.Schwarz", + "rank": "SUBSPECIES", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339212, + "scientificName": "Caricina divulsa (Stokes) St.-Lag.", + "canonicalName": "Caricina divulsa", + "authorship": "(Stokes) St.-Lag.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 285180895, + "scientificName": "Desmiograstis divulsa (Stokes) B\u00f6rner", + "canonicalName": "Desmiograstis divulsa", + "authorship": "(Stokes) B\u00f6rner", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339232, + "scientificName": "Vignea divulsa (Stokes) Rchb.", + "canonicalName": "Vignea divulsa", + "authorship": "(Stokes) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339234, + "scientificName": "Vignea guestphalica Rchb.", + "canonicalName": "Vignea guestphalica", + "authorship": "Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339235, + "scientificName": "Vignea persica (Nelmes) Soj\u00e1k", + "canonicalName": "Vignea persica", + "authorship": "(Nelmes) Soj\u00e1k", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 296339279, + "scientificName": "Vignea virens (Lam.) Rchb.", + "canonicalName": "Vignea virens", + "authorship": "(Lam.) Rchb.", + "rank": "SPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296339203, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + } + ], + "312830004": [], + "312861452": [], + "312870922": [], + "312909330": [], + "313586976": [], + "313595144": [], + "313615848": [], + "314607274": [] + }, + "acceptedChildren": { + "2722926": [ + { + "key": 2728501, + "scientificName": "Carex muricata subsp. ashokae Molina Gonz., Acedo & Llamas", + "canonicalName": "Carex muricata ashokae", + "authorship": "Molina Gonz., Acedo & Llamas", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 11118251, + "scientificName": "Carex muricata subsp. ashokii MolinaGonz. et al.", + "canonicalName": "Carex muricata ashokii", + "authorship": "MolinaGonz. et al.", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 11342413, + "scientificName": "Carex muricata subsp. cesanensis A.M.Molina et al.", + "canonicalName": "Carex muricata cesanensis", + "authorship": "A.M.Molina et al.", + "rank": "SUBSPECIES", + "taxonomicStatus": "DOUBTFUL", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 2728499, + "scientificName": "Carex muricata subsp. cesanensis Molina Gonz., Acedo & Llamas", + "canonicalName": "Carex muricata cesanensis", + "authorship": "Molina Gonz., Acedo & Llamas", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 7227181, + "scientificName": "Carex muricata subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "100013814": [ + { + "key": 100013815, + "scientificName": "Carex muricata subsp. lamprocarpa Celakovsky", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "Celakovsky", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "100546265": [], + "103031795": [ + { + "key": 166918916, + "scientificName": "Carex muricata subsp. ashokae", + "canonicalName": "Carex muricata ashokae", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 166918913, + "scientificName": "Carex muricata subsp. cesanensis", + "canonicalName": "Carex muricata cesanensis", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 103031797, + "scientificName": "Carex muricata subsp. lamprocarpa", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 103031799, + "scientificName": "Carex muricata subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "117903985": [ + { + "key": 117903993, + "scientificName": "Carex muricata L. subsp. lamprocarpa \u010celak", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "\u010celak", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "118376459": [], + "124734660": [], + "127648016": [], + "145962994": [], + "152478932": [], + "159152091": [], + "159336348": [], + "160027865": [], + "160028024": [], + "160290748": [], + "164943627": [], + "165587045": [], + "168114725": [], + "168114860": [], + "168211569": [], + "176242662": [], + "176548398": [], + "176573191": [], + "179103017": [ + { + "key": 179103021, + "scientificName": "Carex muricata subsp. ashokae", + "canonicalName": "Carex muricata ashokae", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 179103019, + "scientificName": "Carex muricata subsp. cesanensis", + "canonicalName": "Carex muricata cesanensis", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 179103024, + "scientificName": "Carex muricata subsp. lamprocarpa", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 179103026, + "scientificName": "Carex muricata subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "180133121": [], + "206105159": [], + "211405057": [], + "211412197": [], + "211412948": [], + "213952250": [], + "213955316": [ + { + "key": 217522394, + "scientificName": "Carex divulsa subsp. leersiana Rauschert", + "canonicalName": "Carex divulsa leersiana", + "authorship": "Rauschert", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "221885990": [], + "221886838": [], + "221889248": [ + { + "key": 221889252, + "scientificName": "Carex echinata subsp. echinata", + "canonicalName": "Carex echinata echinata", + "authorship": "", + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + } + ], + "224053913": [], + "224776247": [], + "239712335": [], + "239766740": [], + "239810880": [], + "239899602": [], + "239901161": [], + "240241066": [], + "240469724": [], + "240476050": [], + "240540573": [], + "240621298": [], + "241006592": [], + "241059491": [], + "241062168": [], + "241100745": [], + "241176967": [], + "241268823": [], + "241348670": [], + "241388226": [], + "241465330": [], + "241553298": [], + "241601934": [], + "242137566": [], + "269916461": [], + "296338562": [], + "296339203": [], + "312830004": [], + "312861452": [], + "312870922": [], + "312909330": [], + "313586976": [], + "313595144": [], + "313615848": [], + "314607274": [] + }, + "acceptedVernaculars": { + "2722926": [ + { + "taxonKey": 2722926, + "vernacularName": "Carice contigua", + "language": "ita", + "source": "Info Flora Schweiz - Cyperaceae", + "sourceTaxonKey": 224776247 + }, + { + "taxonKey": 2722926, + "vernacularName": "Carice contigua", + "language": "ita", + "source": "Flora Helvetica - Cyperaceae", + "sourceTaxonKey": 224053913 + }, + { + "taxonKey": 2722926, + "vernacularName": "Hesg Ysbigog", + "language": "cym", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "Hesgen Ysbigog", + "language": "cym", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "Laiche pointue", + "language": "fra", + "source": "Info Flora Schweiz - Cyperaceae", + "sourceTaxonKey": 224776247 + }, + { + "taxonKey": 2722926, + "vernacularName": "Laiche pointue", + "language": "fra", + "source": "Flora Helvetica - Cyperaceae", + "sourceTaxonKey": 224053913 + }, + { + "taxonKey": 2722926, + "vernacularName": "La\u00eeche \u00e9pineuse", + "language": "fra", + "country": "FR", + "area": "fra", + "source": "TAXREF", + "sourceTaxonKey": 221887214 + }, + { + "taxonKey": 2722926, + "vernacularName": "Prickly Sedge", + "language": "eng", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "Rough Sedge", + "language": "eng", + "country": "GB", + "area": "eng", + "source": "TAXREF", + "sourceTaxonKey": 221887214 + }, + { + "taxonKey": 2722926, + "vernacularName": "Rough Sedge", + "language": "eng", + "country": "GB", + "source": "Checklist of Vermont Species", + "sourceTaxonKey": 160787209 + }, + { + "taxonKey": 2722926, + "vernacularName": "Rough sedge", + "language": "eng", + "country": "US", + "area": "conterminous 48 United States", + "source": "Global Register of Introduced and Invasive Species - United States (Contiguous) (ver.2.0, 2022)", + "sourceTaxonKey": 205118652 + }, + { + "taxonKey": 2722926, + "vernacularName": "Sparrige Segge", + "language": "deu", + "country": "DE", + "source": "Taxon list of vascular plants from Bavaria, Germany compiled in the context of the BFL project", + "sourceTaxonKey": 116780305 + }, + { + "taxonKey": 2722926, + "vernacularName": "Sparrige Segge", + "language": "deu", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "Sparrige Segge", + "language": "deu", + "source": "EUNIS Biodiversity Database", + "sourceTaxonKey": 101264701 + }, + { + "taxonKey": 2722926, + "vernacularName": "Stachel-Segge", + "language": "deu", + "source": "Info Flora Schweiz - Cyperaceae", + "sourceTaxonKey": 224776247 + }, + { + "taxonKey": 2722926, + "vernacularName": "Stachel-Segge", + "language": "deu", + "source": "Flora Helvetica - Cyperaceae", + "sourceTaxonKey": 224053913 + }, + { + "taxonKey": 2722926, + "vernacularName": "carex muricat", + "language": "fra", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "carex muriqu\u00e9", + "language": "fra", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "dichte bermzegge", + "language": "nld", + "country": "BE", + "source": "Belgian Species List", + "sourceTaxonKey": 100546265 + }, + { + "taxonKey": 2722926, + "vernacularName": "la\u00eeche de paira", + "language": "fra", + "country": "BE", + "source": "Belgian Species List", + "sourceTaxonKey": 100546265 + }, + { + "taxonKey": 2722926, + "vernacularName": "lesser prickly sedge", + "language": "eng", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "muricate sedge", + "language": "eng", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f6rk piggstarr", + "language": "swe", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f6rk sn\u00e5rstarr", + "language": "swe", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f6rk sn\u00e5rstarr", + "language": "swe", + "country": "SE", + "source": "Nordic Crop Wild Relative (CWR) Checklist", + "sourceTaxonKey": 127648016 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstarr", + "language": "nob", + "country": "NO", + "source": "Nordic Crop Wild Relative (CWR) Checklist", + "sourceTaxonKey": 127648016 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstarr", + "language": "nob", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstorr", + "language": "nno", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstorr", + "language": "nno", + "country": "NO", + "source": "Nordic Crop Wild Relative (CWR) Checklist", + "sourceTaxonKey": 127648016 + }, + { + "taxonKey": 2722926, + "vernacularName": "piggstarr", + "language": "nob", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "piggstorr", + "language": "nno", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "rough sedge", + "language": "eng", + "source": "Integrated Taxonomic Information System (ITIS)", + "sourceTaxonKey": 102193966 + }, + { + "taxonKey": 2722926, + "vernacularName": "rough sedge", + "language": "eng", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "rough-pointed sedge", + "language": "eng", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "stjernstar", + "language": "nor", + "source": "Nordic plant uses from Gunnerus and H\u00f8eg", + "sourceTaxonKey": 164943627 + }, + { + "taxonKey": 2722926, + "vernacularName": "tennstar", + "language": "nor", + "source": "Nordic plant uses from Gunnerus and H\u00f8eg", + "sourceTaxonKey": 164943627 + }, + { + "taxonKey": 2722926, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "swe", + "country": "FI", + "source": "Nordic Crop Wild Relative (CWR) Checklist", + "sourceTaxonKey": 127648016 + }, + { + "taxonKey": 2722926, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "fin", + "country": "FI", + "source": "The FinBIF checklist of Finnish species", + "sourceTaxonKey": 258023061 + }, + { + "taxonKey": 2722926, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "fin", + "country": "FI", + "source": "Nordic Crop Wild Relative (CWR) Checklist", + "sourceTaxonKey": 127648016 + }, + { + "taxonKey": 2722926, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "fin", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "vanleg piggstorr", + "language": "nno", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "vanlig piggstarr", + "language": "nob", + "source": "Catalogue of Life", + "sourceTaxonKey": 296357446 + }, + { + "taxonKey": 2722926, + "vernacularName": "Lesser Prickly Sedge", + "language": "eng", + "country": "US", + "source": "Checklist of Vermont Species", + "sourceTaxonKey": 160787209, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "Prickly Sedge", + "language": "eng", + "source": "United Kingdom Species Inventory (UKSI)", + "sourceTaxonKey": 180133350, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "Prickly Sedge", + "language": "eng", + "source": "Checklist of Vermont Species", + "sourceTaxonKey": 160787209, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "carex muriqu\u00e9", + "language": "fra", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "lesser prickly sedge", + "language": "eng", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f6rk sn\u00e5rstarr", + "language": "swe", + "country": "SE", + "source": "Dyntaxa. Svensk taxonomisk databas", + "sourceTaxonKey": 160027912, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "piggstarr", + "language": "nob", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "piggstorr", + "language": "nno", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": true + }, + { + "taxonKey": 2722926, + "vernacularName": "Hesg Ysbigog", + "language": "cym", + "source": "United Kingdom Species Inventory (UKSI)", + "sourceTaxonKey": 180133350, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "Hesgen Ysbigog", + "language": "cym", + "source": "United Kingdom Species Inventory (UKSI)", + "sourceTaxonKey": 180133350, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "Muricate Sedge", + "language": "eng", + "country": "US", + "source": "Checklist of Vermont Species", + "sourceTaxonKey": 160787209, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "Rough-pointed Sedge", + "language": "eng", + "country": "US", + "source": "Checklist of Vermont Species", + "sourceTaxonKey": 160787209, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "carex muricat", + "language": "fra", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "muricate sedge", + "language": "eng", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f6rk piggstarr", + "language": "swe", + "country": "SE", + "source": "Dyntaxa. Svensk taxonomisk databas", + "sourceTaxonKey": 160027912, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstarr", + "language": "nob", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "m\u00f8rk piggstorr", + "language": "nno", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "rough sedge", + "language": "eng", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "rough-pointed sedge", + "language": "eng", + "source": "Database of Vascular Plants of Canada (VASCAN)", + "sourceTaxonKey": 100013814, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "vanleg piggstorr", + "language": "nno", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": false + }, + { + "taxonKey": 2722926, + "vernacularName": "vanlig piggstarr", + "language": "nob", + "country": "NO", + "sourceTaxonKey": 168114725, + "preferred": false + } + ], + "100013814": [ + { + "taxonKey": 100013814, + "vernacularName": "carex muriqu\u00e9", + "language": "fra", + "source": "TelaBotanica 2000-2009+. Flore \u00e9lectronique. France m\u00e9tropolitaine. http://www.tela-botanica.org http://www.tela-botanica.org/", + "preferred": true + }, + { + "taxonKey": 100013814, + "vernacularName": "lesser prickly sedge", + "language": "eng", + "source": "Newmaster, S.G., A. Lehela, M.J. Oldham, P.W.C. Uhlig & S. McMurray. 1998. Ontario Plant List. Ontario Forest Research Institute, Sault Ste. Marie, Ontario. Forest Information Paper No. 123. 550 pp.", + "preferred": true + }, + { + "taxonKey": 100013814, + "vernacularName": "carex muricat", + "language": "fra", + "source": "TelaBotanica 2000-2009+. Flore \u00e9lectronique. France m\u00e9tropolitaine. http://www.tela-botanica.org http://www.tela-botanica.org/", + "preferred": false + }, + { + "taxonKey": 100013814, + "vernacularName": "muricate sedge", + "language": "eng", + "source": "Canadian Forest Service. 2007. Canada's Plant Hardiness Site. Canadian Forest Service, Natural Resources Canada. http://planthardiness.gc.ca (consulted 2010) http://planthardiness.gc.ca/", + "preferred": false + }, + { + "taxonKey": 100013814, + "vernacularName": "rough sedge", + "language": "eng", + "source": "Canadian Forest Service. 2007. Canada's Plant Hardiness Site. Canadian Forest Service, Natural Resources Canada. http://planthardiness.gc.ca (consulted 2010) http://planthardiness.gc.ca/", + "preferred": false + }, + { + "taxonKey": 100013814, + "vernacularName": "rough-pointed sedge", + "language": "eng", + "source": "Newmaster, S.G., A. Lehela, M.J. Oldham, P.W.C. Uhlig & S. McMurray. 1998. Ontario Plant List. Ontario Forest Research Institute, Sault Ste. Marie, Ontario. Forest Information Paper No. 123. 550 pp.", + "preferred": false + } + ], + "100546265": [ + { + "taxonKey": 100546265, + "vernacularName": "dichte bermzegge", + "language": "nld", + "country": "BE" + }, + { + "taxonKey": 100546265, + "vernacularName": "la\u00eeche de paira", + "language": "fra", + "country": "BE" + } + ], + "103031795": [], + "117903985": [], + "118376459": [], + "124734660": [], + "127648016": [ + { + "taxonKey": 127648016, + "vernacularName": "m\u00f6rk sn\u00e5rstarr", + "language": "swe", + "country": "SE" + }, + { + "taxonKey": 127648016, + "vernacularName": "m\u00f8rk piggstarr", + "language": "nob", + "country": "NO" + }, + { + "taxonKey": 127648016, + "vernacularName": "m\u00f8rk piggstorr", + "language": "nno", + "country": "NO" + }, + { + "taxonKey": 127648016, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "swe", + "country": "FI" + }, + { + "taxonKey": 127648016, + "vernacularName": "t\u00f6rr\u00f6sara", + "language": "fin", + "country": "FI" + } + ], + "145962994": [], + "152478932": [], + "159152091": [], + "159336348": [], + "160027865": [ + { + "taxonKey": 160027865, + "vernacularName": "piggstarr", + "language": "swe", + "country": "SE", + "source": "Dyntaxa. Svensk taxonomisk databas", + "preferred": true + } + ], + "160028024": [ + { + "taxonKey": 160028024, + "vernacularName": "stj\u00e4rnstarr", + "language": "swe", + "country": "SE", + "source": "Dyntaxa. Svensk taxonomisk databas", + "preferred": true + } + ], + "160290748": [], + "164943627": [ + { + "taxonKey": 164943627, + "vernacularName": "stjernstar", + "language": "nor" + }, + { + "taxonKey": 164943627, + "vernacularName": "tennstar", + "language": "nor" + } + ], + "165587045": [], + "168114725": [ + { + "taxonKey": 168114725, + "vernacularName": "piggstarr", + "language": "nob", + "country": "NO", + "preferred": true + }, + { + "taxonKey": 168114725, + "vernacularName": "piggstorr", + "language": "nno", + "country": "NO", + "preferred": true + }, + { + "taxonKey": 168114725, + "vernacularName": "m\u00f8rk piggstarr", + "language": "nob", + "country": "NO", + "preferred": false + }, + { + "taxonKey": 168114725, + "vernacularName": "m\u00f8rk piggstorr", + "language": "nno", + "country": "NO", + "preferred": false + }, + { + "taxonKey": 168114725, + "vernacularName": "vanleg piggstorr", + "language": "nno", + "country": "NO", + "preferred": false + }, + { + "taxonKey": 168114725, + "vernacularName": "vanlig piggstarr", + "language": "nob", + "country": "NO", + "preferred": false + } + ], + "168114860": [ + { + "taxonKey": 168114860, + "vernacularName": "n\u00e1stelukti", + "language": "sme", + "country": "NO", + "preferred": true + }, + { + "taxonKey": 168114860, + "vernacularName": "stjernestarr", + "language": "nob", + "country": "NO", + "preferred": true + }, + { + "taxonKey": 168114860, + "vernacularName": "stjernestorr", + "language": "nno", + "country": "NO", + "preferred": true + } + ], + "168211569": [], + "176242662": [], + "176548398": [], + "176573191": [], + "179103017": [], + "180133121": [ + { + "taxonKey": 180133121, + "vernacularName": "Spiked Sedge", + "language": "eng", + "preferred": true + }, + { + "taxonKey": 180133121, + "vernacularName": "Hesgen Dywysennog Borffor", + "language": "cym", + "preferred": false + }, + { + "taxonKey": 180133121, + "vernacularName": "Hesgen Ysbigog Borffor", + "language": "cym", + "preferred": false + } + ], + "206105159": [], + "211405057": [], + "211412197": [], + "211412948": [], + "213952250": [], + "213955316": [], + "221885990": [ + { + "taxonKey": 221885990, + "vernacularName": "La\u00eeche en \u00e9pi", + "language": "fra", + "country": "FR", + "area": "fra", + "source": "BDTFX v1.01" + }, + { + "taxonKey": 221885990, + "vernacularName": "Spiked Sedge", + "language": "eng", + "country": "GB", + "area": "eng", + "source": "NHM" + } + ], + "221886838": [ + { + "taxonKey": 221886838, + "vernacularName": "La\u00eeche de Paira", + "language": "fra", + "country": "FR", + "area": "fra", + "source": "BDTFX v1.01" + }, + { + "taxonKey": 221886838, + "vernacularName": "Small-fruited Prickly-sedge", + "language": "eng", + "country": "GB", + "area": "eng" + } + ], + "221889248": [ + { + "taxonKey": 221889248, + "vernacularName": "La\u00eeche \u00e9toil\u00e9e, La\u00eeche-h\u00e9risson, La\u00eeche \u00e9pineuse", + "language": "fra", + "country": "FR", + "area": "fra", + "source": "BDTFX v1.01" + }, + { + "taxonKey": 221889248, + "vernacularName": "Star Sedge", + "language": "eng", + "country": "GB", + "area": "eng", + "source": "NHM" + } + ], + "224053913": [ + { + "taxonKey": 224053913, + "vernacularName": "Carice contigua", + "language": "ita" + }, + { + "taxonKey": 224053913, + "vernacularName": "Laiche pointue", + "language": "fra" + }, + { + "taxonKey": 224053913, + "vernacularName": "Stachel-Segge", + "language": "deu" + } + ], + "224776247": [ + { + "taxonKey": 224776247, + "vernacularName": "Carice contigua", + "language": "ita" + }, + { + "taxonKey": 224776247, + "vernacularName": "Laiche pointue", + "language": "fra" + }, + { + "taxonKey": 224776247, + "vernacularName": "Stachel-Segge", + "language": "deu" + } + ], + "239712335": [], + "239766740": [], + "239810880": [], + "239899602": [], + "239901161": [], + "240241066": [], + "240469724": [], + "240476050": [], + "240540573": [], + "240621298": [], + "241006592": [], + "241059491": [], + "241062168": [], + "241100745": [], + "241176967": [], + "241268823": [], + "241348670": [], + "241388226": [], + "241465330": [], + "241553298": [], + "241601934": [], + "242137566": [], + "269916461": [], + "296338562": [ + { + "taxonKey": 296338562, + "vernacularName": "bristle-fruited sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "carex \u00e9toil\u00e9", + "language": "fra" + }, + { + "taxonKey": 296338562, + "vernacularName": "large-fruited star sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "little prickly sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "prickly sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "spiny star sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "star sedge", + "language": "eng" + }, + { + "taxonKey": 296338562, + "vernacularName": "stellate sedge", + "language": "eng" + } + ], + "296339203": [ + { + "taxonKey": 296339203, + "vernacularName": "Groene bermzegge", + "language": "nld" + }, + { + "taxonKey": 296339203, + "vernacularName": "Groene bermzegge subsp. divulsa", + "language": "nld" + }, + { + "taxonKey": 296339203, + "vernacularName": "Hesg Llwyd", + "language": "cym" + }, + { + "taxonKey": 296339203, + "vernacularName": "Hesgen Lwyd", + "language": "cym" + }, + { + "taxonKey": 296339203, + "vernacularName": "Hesgen Lwydlas", + "language": "cym" + }, + { + "taxonKey": 296339203, + "vernacularName": "IJle bermzegge", + "language": "nld" + }, + { + "taxonKey": 296339203, + "vernacularName": "Laiche a epis separes", + "language": "fra" + }, + { + "taxonKey": 296339203, + "vernacularName": "Mellembrudt star", + "language": "dan" + }, + { + "taxonKey": 296339203, + "vernacularName": "Unterbrochenaehrige Stachel-Segge", + "language": "deu" + }, + { + "taxonKey": 296339203, + "vernacularName": "carex \u00e0 \u00e9pis s\u00e9par\u00e9s", + "language": "fra" + }, + { + "taxonKey": 296339203, + "vernacularName": "grassland sedge", + "language": "eng" + }, + { + "taxonKey": 296339203, + "vernacularName": "grey sedge", + "language": "eng" + }, + { + "taxonKey": 296339203, + "vernacularName": "laiche \u00e0 \u00e9pis s\u00e9par\u00e9s", + "language": "fra" + }, + { + "taxonKey": 296339203, + "vernacularName": "la\u00eeche \u00e0 utricules divergents", + "language": "fra" + }, + { + "taxonKey": 296339203, + "vernacularName": "la\u00eeche \u00e9cart\u00e9e", + "language": "fra" + }, + { + "taxonKey": 296339203, + "vernacularName": "l\u00e5ngstarr", + "language": "swe" + }, + { + "taxonKey": 296339203, + "vernacularName": "separated sedge", + "language": "eng" + }, + { + "taxonKey": 296339203, + "vernacularName": "unterbrochen\u00e4hrige Segge", + "language": "deu" + } + ], + "312830004": [], + "312861452": [], + "312870922": [], + "312909330": [], + "313586976": [], + "313595144": [], + "313615848": [], + "314607274": [] + }, + "expansionNames": [ + "Carex angustior Mack.", + "Carex angustior var. gracilenta R.T.Clausen & Wahl", + "Carex astracanica Willd. ex Kunth", + "Carex basilata Ohwi", + "Carex basilata Ohwi, 1942", + "Carex brongniartii Kunth", + "Carex brongniartii Kunth, 1837", + "Carex brotherorum Christ", + "Carex bullockiana Nelmes, 1959", + "Carex caflischii Br\u00fcgger", + "Carex canescens Huds.", + "Carex cephalantha (L.H.Bailey) E.P.Bicknell", + "Carex compacta Lam.", + "Carex contigua Hoppe", + "Carex contigua Hoppe, 1835", + "Carex contigua subsp. pairae (F.W.Schultz) Degen, 1936", + "Carex convexa Kit.", + "Carex divulsa Gaudin", + "Carex divulsa Stokes", + "Carex divulsa Stokes, 1787", + "Carex divulsa f. angustifolia (Podp.) So\u00f3", + "Carex divulsa f. guestphalica (Rchb.) K\u00fck.", + "Carex divulsa f. misera (K\u00fck. ex Vollm.) K\u00fck.", + "Carex divulsa f. polycarpa (Vollm.) K\u00fck.", + "Carex divulsa subsp. divulsa", + "Carex divulsa subsp. leersiana Rauschert", + "Carex divulsa subsp. orsiniana (Ten.) K.Richt.", + "Carex divulsa subsp. virens (Lam.) Nyman", + "Carex divulsa var. approximata Legrand", + "Carex divulsa var. congesta Gren.", + "Carex divulsa var. guestphalica (Boenn. ex Rchb.) Nyman", + "Carex divulsa var. guestphalica (Rchb.) F.W.Schultz ex Asch. & Graebn.", + "Carex divulsa var. javanica Nelmes", + "Carex divulsa var. misera K\u00fck. ex Vollm.", + "Carex divulsa var. polycarpa Vollm.", + "Carex divulsa var. virens (Lam.) Durieu", + "Carex divulsa var. virens (Lam.) M\u00e9rat", + "Carex divulsa var. virens (Lam.) Steud.", + "Carex duriaei (F.W.Schultz) F.W.Schultz", + "Carex echinata Murray", + "Carex echinata Murray, 1770", + "Carex echinata f. brevispicata Podp.", + "Carex echinata f. chlorocarpa Podp.", + "Carex echinata f. pseudodivulsa (F.W.Schultz) Suess.", + "Carex echinata f. remotiuscula Podp.", + "Carex echinata f. subalpina Almq.", + "Carex echinata prol. grypos (Schkuhr) Rouy", + "Carex echinata race grypos (Schkuhr) Rouy, 1912", + "Carex echinata subsp. echinata", + "Carex echinata subsp. gasparrinii (Parl.) K.Richt.", + "Carex echinata subsp. grypos (Schkuhr) Arcang.", + "Carex echinata subsp. grypos (Schkuhr) Arcang., 1882", + "Carex echinata subsp. grypos (Schkuhr) K.Richt.", + "Carex echinata subsp. hydrophila (Dumort.) K.Richt.", + "Carex echinata var. angustata (J.Carey) L.H.Bailey", + "Carex echinata var. cephalantha L.H.Bailey", + "Carex echinata var. elata Maire ex Rouy", + "Carex echinata var. elata Maire ex Rouy, 1904", + "Carex echinata var. excelsior (L.H.Bailey) Fernald", + "Carex echinata var. grypos (Schkuhr) Fiori", + "Carex echinata var. grypos (Schkuhr) Nyman", + "Carex echinata var. grypos (Schkuhr) Rhiner", + "Carex echinata var. grypsos (Schkuhr) Nyman, 1882", + "Carex echinata var. gypsos (Schkuhr) Nyman", + "Carex echinata var. hydrophila (Dumort.) Nyman", + "Carex echinata var. microstachys Boeckeler", + "Carex echinata var. ormantha Fernald", + "Carex echinata var. perileia (S.T.Blake) P.Royen", + "Carex echinata var. phyllomanica (W.Boott) B.Boivin", + "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz", + "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz, 1863", + "Carex echinata var. tenuior K\u00fck.", + "Carex fasciculata Link ex Schkuhr", + "Carex fasciculata Willd.", + "Carex gajonum Nelmes", + "Carex gasparrinii Parl.", + "Carex glomerata Gilib.", + "Carex grypos Schkuhr", + "Carex grypos Schkuhr, 1806", + "Carex grypos var. nana Christ ex Briq.", + "Carex guestphalica (Rchb.) Boenn. ex O.Lang", + "Carex guestphalica (Rchb.) Boenn. ex W.D.J.Koch", + "Carex guestphalica Boenn. ex W.D.J.Koch", + "Carex hawaiiensis H.St.John", + "Carex hispida subsp. retusa (Degl.) Arcang.", + "Carex hydrophila Dumort.", + "Carex hypoxanthos Steud.", + "Carex interior var. josselynii Fernald", + "Carex intermedia Retz.", + "Carex josselynii (Fernald) Mack. ex Pease", + "Carex laricina Mack. ex Bright", + "Carex leersii Willd.", + "Carex leersii Willd., 1787", + "Carex leersii var. angustata (J.Carey) Burnham", + "Carex leersii var. angustata (J.Carey) Mack.", + "Carex leersii var. cephalantha (L.H.Bailey) J.K.Henry", + "Carex leptophylla Heuff.", + "Carex loliacea Schkuhr, 1801", + "Carex lumnitzeri (Rouy) V.I.Krecz.", + "Carex mertensis Weihe ex Kunth", + "Carex minganinsularum Raymond", + "Carex muehlenbergii Brongn.", + "Carex muricata", + "Carex muricata Desf.", + "Carex muricata Ehrh., 1791", + "Carex muricata Huds.", + "Carex muricata Huds. auct. non Huds.", + "Carex muricata Jungh.", + "Carex muricata L.", + "Carex muricata L. subsp. lamprocarpa \u010celak", + "Carex muricata L., 1753", + "Carex muricata L.Sp.Pl", + "Carex muricata Leers", + "Carex muricata Leers auct. non Leers", + "Carex muricata Linnaeus", + "Carex muricata Schltdl.", + "Carex muricata Schltdl. & Cham.", + "Carex muricata f. angustifolia Podp.", + "Carex muricata prol. lumnitzeri Rouy", + "Carex muricata subsp. ashokae", + "Carex muricata subsp. ashokae Molina Gonz., Acedo & Llamas", + "Carex muricata subsp. ashokii MolinaGonz. et al.", + "Carex muricata subsp. cephalantha (L.H.Bailey) R.T.Clausen", + "Carex muricata subsp. cesanensis", + "Carex muricata subsp. cesanensis A.M.Molina et al.", + "Carex muricata subsp. cesanensis Molina Gonz., Acedo & Llamas", + "Carex muricata subsp. contigua (Hoppe) H.Lindb.", + "Carex muricata subsp. contigua (Hoppe) Moravec", + "Carex muricata subsp. divulsa (Stokes) Bonnier & Layens", + "Carex muricata subsp. divulsa (Stokes) Celak.", + "Carex muricata subsp. divulsa (Stokes) Corb.", + "Carex muricata subsp. divulsa (Stokes) Wahlenb.", + "Carex muricata subsp. lamprocarpa", + "Carex muricata subsp. lamprocarpa (Wallr.) Celak.", + "Carex muricata subsp. lamprocarpa Celakovsky", + "Carex muricata subsp. lumnitzeri (Rouy) So\u00f3", + "Carex muricata subsp. macrocarpa Neuman", + "Carex muricata subsp. macrocarpa Neuman, 1901", + "Carex muricata subsp. microcarpa Neuman", + "Carex muricata subsp. muricata", + "Carex muricata subsp. orsiniana (Ten.) Nyman", + "Carex muricata subsp. pairae", + "Carex muricata subsp. virens (Lam.) K.Richt.", + "Carex muricata subsp. virens (Lam.) \u010celak.", + "Carex muricata var. alpina Gaudin", + "Carex muricata var. angustata (J.Carey) J.Carey ex Gleason", + "Carex muricata var. angustifolia Podp.", + "Carex muricata var. basilata (Ohwi) Y.L.Chou", + "Carex muricata var. cephalantha (L.H.Bailey) Wiegand & Eames", + "Carex muricata var. densa Wallr.", + "Carex muricata var. depauperata Hampe", + "Carex muricata var. divulsa (Stokes) Wahlenb.", + "Carex muricata var. guestphalica (Boenn. ex O.Lang) Neuman", + "Carex muricata var. lamprocarpa Wallr.", + "Carex muricata var. laricina (Mack. ex Bright) Gleason", + "Carex muricata var. lumnitzeri Rouy", + "Carex muricata var. monostachya Asch.", + "Carex muricata var. muricata", + "Carex muricata var. nemorosa Gaudin", + "Carex muricata var. subramosa Neilr.", + "Carex muricata var. virens (Lam.) Rchb.", + "Carex muricata var. virens (Lam.) W.D.J.Koch", + "Carex muricata var. virens Andersson", + "Carex nemorosa Lumn.", + "Carex obtusangula Salzm. ex Boott", + "Carex omiana var. perileia (S.T.Blake) T.Koyama", + "Carex ormantha (Fernald) Mack.", + "Carex orsiniana Ten.", + "Carex pairae F.W.Schultz, 1868", + "Carex pairae f. brevispicata (Podp.) So\u00f3", + "Carex pairae f. chlorocarpa (Podp.) So\u00f3", + "Carex pairae f. remotiuscula (Podp.) So\u00f3", + "Carex pairae subsp. borealis Hyl.", + "Carex pairae var. javanica Nelmes", + "Carex pairaei subsp. borealis Hyl.", + "Carex perileia S.T.Blake", + "Carex persica Nelmes", + "Carex phyllomanica W.Boott", + "Carex phyllomanica var. angustata (J.Carey) B.Boivin", + "Carex phyllomanica var. ormantha (Fernald) B.Boivin", + "Carex provincialis Degl.", + "Carex reflexa D.Dietr. ex Kunth", + "Carex retusa Degl.", + "Carex riparia subsp. fasciculata (Link ex Schkuhr) K.Richt.", + "Carex scirpoides var. josselynii (Fernald) Fernald", + "Carex serotina Ten.", + "Carex serrulata Mutel", + "Carex spicata Huds.", + "Carex spicata Huds., 1762", + "Carex spicata Hudson", + "Carex spicata Thuill.", + "Carex spicata subsp. lumnitzeri (Rouy) So\u00f3", + "Carex stellulata Gooden.", + "Carex stellulata Gooden., 1794", + "Carex stellulata M.Bieb.", + "Carex stellulata f. excelsior (L.H.Bailey) K\u00fck.", + "Carex stellulata f. hydrophila (Dumort.) Asch. & Graebn.", + "Carex stellulata f. hydrophila (Dumort.) Vollm.", + "Carex stellulata f. hylogiton Asch. & Graebn.", + "Carex stellulata f. longibracteata Zapal.", + "Carex stellulata f. oligantha (Callm\u00e9) K\u00fck.", + "Carex stellulata f. oligantha Callm\u00e9", + "Carex stellulata f. pseudodivulsa F.W.Schultz", + "Carex stellulata subsp. grypos (Schkuhr) Gaudin", + "Carex stellulata subsp. pallens Gaudin", + "Carex stellulata var. alpestris Gaudin", + "Carex stellulata var. angustata J.Carey", + "Carex stellulata var. australis K\u00fck.", + "Carex stellulata var. cephalantha (L.H.Bailey) Fernald", + "Carex stellulata var. excelsior (L.H.Bailey) Fernald", + "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch", + "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch, 1847", + "Carex stellulata var. masculina Gray", + "Carex stellulata var. oligantha Callm\u00e9", + "Carex stellulata var. ormantha (Fernald) Fernald", + "Carex stellulata var. pseudodivulsa F.W.Schultz", + "Carex stellulata var. pseudodivulsa F.W.Schultz, 1845", + "Carex stellulata var. scirpina Tuck.", + "Carex stellulata var. subalpina Asch. & Graebn.", + "Carex sterilis var. cephalantha (L.H.Bailey) L.H.Bailey", + "Carex sterilis var. excelsior L.H.Bailey", + "Carex subramosa Willd. ex Kunth", + "Carex svenonis Skottsb.", + "Carex svenonis var. alakaiensis Skottsb.", + "Carex tenuissima Schur", + "Carex tergestina Hoppe ex Boott", + "Carex virens", + "Carex virens Lam.", + "Carex virens Steud.", + "Carex virens var. divulsa (Stokes) F.W.Schultz", + "Carex virens var. duriaei F.W.Schultz", + "Carex virens var. guestphalica (Rchb.) Garcke", + "Carex virens var. major G.Mey.", + "Carex viridis Spenn.", + "Carex viridis var. divulsa (Stokes) Spenn.", + "Carex viridis var. longibracteata Spenn.", + "Carex vulpina Hohen.", + "Carex vulpina L.", + "Carex vulpina f. aristata Asch.", + "Carex vulpina f. capitulata (Peterm.) So\u00f3", + "Carex vulpina f. elongata Andersson", + "Carex vulpina f. laeviuscula Sanio ex Asch. & Graebn.", + "Carex vulpina f. minor Peterm.", + "Carex vulpina f. nemorosa (Gaudin) W.D.J.Koch", + "Carex vulpina subsp. nemorosa (Gaudin) K.Richt.", + "Carex vulpina subsp. nemorosa (Gaudin) O.Schwarz", + "Carex vulpina subsp. stribrnyi Velen.", + "Carex vulpina var. compacta (Lam.) Velen.", + "Carex vulpina var. crassinervis (Schur) K\u00fck.", + "Carex vulpina var. divulsa Celak.", + "Carex vulpina var. gracilis Gaudin", + "Carex vulpina var. littoralis Nolte ex Asch. & Graebn.", + "Carex vulpina var. longibracteata Beck", + "Carex vulpina var. nemorosa Gaudin", + "Carex vulpina var. pallidior Meinsh.", + "Carex vulpina var. remotiflora Lange", + "Carex vulpina var. stribrnyi (Velen.) K\u00fck.", + "Carex vulpina var. tenuior Ledeb.", + "Carex vulpina var. vulgaris Celak.", + "Carice contigua", + "Caricina divulsa (Stokes) St.-Lag.", + "Caricina muricata (L.) St.-Lag.", + "Caricina stellulata (Gooden.) St.-Lag.", + "Caricina stellulata (Gooden.) St.-Lag., 1889", + "Desmiograstis divulsa (Stokes) B\u00f6rner", + "Desmiograstis echinata (Murray) B\u00f6rner", + "Desmiograstis stellulata (Gooden.) B\u00f6rner", + "Edritria vulpina (L.) Raf.", + "Groene bermzegge", + "Groene bermzegge subsp. divulsa", + "Hesg Llwyd", + "Hesg Ysbigog", + "Hesgen Dywysennog Borffor", + "Hesgen Lwyd", + "Hesgen Lwydlas", + "Hesgen Ysbigog", + "Hesgen Ysbigog Borffor", + "IJle bermzegge", + "Laiche a epis separes", + "Laiche pointue", + "La\u00eeche de Paira", + "La\u00eeche en \u00e9pi", + "La\u00eeche \u00e9pineuse", + "La\u00eeche \u00e9toil\u00e9e, La\u00eeche-h\u00e9risson, La\u00eeche \u00e9pineuse", + "Lesser Prickly Sedge", + "Mellembrudt star", + "Muricate Sedge", + "Prickly Sedge", + "Rough Sedge", + "Rough sedge", + "Rough-pointed Sedge", + "Small-fruited Prickly-sedge", + "Sparrige Segge", + "Spiked Sedge", + "Stachel-Segge", + "Star Sedge", + "Unterbrochenaehrige Stachel-Segge", + "Vignea altissima Schur", + "Vignea angustior (Mack.) Soj\u00e1k", + "Vignea divulsa (Stokes) Rchb.", + "Vignea echinata (Murray) Fourr.", + "Vignea echinata (Murray) Fourr., 1869", + "Vignea grypos (Schkuhr) Rchb.", + "Vignea guestphalica Rchb.", + "Vignea hydrophila (Dumort.) Rchb.", + "Vignea muricata (L.) Rchb.", + "Vignea muricata subsp. lamprocarpa", + "Vignea muricata subsp. lamprocarpa (Wallr.) Soj\u00e1k", + "Vignea nemorosa (Gaudin) Rchb.", + "Vignea perileia (S.T.Blake) Soj\u00e1k", + "Vignea persica (Nelmes) Soj\u00e1k", + "Vignea spicata (Huds.) Soj\u00e1k, 1980", + "Vignea stellulata (Gooden.) Rchb.", + "Vignea tenuissima Schur", + "Vignea virens (Lam.) Rchb.", + "Vignea vulpina (L.) Rchb.", + "Vignea vulpina f. capitulata Peterm.", + "Vignea vulpina var. crassinervis Schur", + "bristle-fruited sedge", + "carex muricat", + "carex muriqu\u00e9", + "carex \u00e0 \u00e9pis s\u00e9par\u00e9s", + "carex \u00e9toil\u00e9", + "dichte bermzegge", + "grassland sedge", + "grey sedge", + "laiche \u00e0 \u00e9pis s\u00e9par\u00e9s", + "large-fruited star sedge", + "la\u00eeche de paira", + "la\u00eeche \u00e0 utricules divergents", + "la\u00eeche \u00e9cart\u00e9e", + "lesser prickly sedge", + "little prickly sedge", + "l\u00e5ngstarr", + "muricate sedge", + "m\u00f6rk piggstarr", + "m\u00f6rk sn\u00e5rstarr", + "m\u00f8rk piggstarr", + "m\u00f8rk piggstorr", + "n\u00e1stelukti", + "piggstarr", + "piggstorr", + "prickly sedge", + "rough sedge", + "rough-pointed sedge", + "separated sedge", + "spiny star sedge", + "star sedge", + "stellate sedge", + "stjernestarr", + "stjernestorr", + "stjernstar", + "stj\u00e4rnstarr", + "tennstar", + "t\u00f6rr\u00f6sara", + "unterbrochen\u00e4hrige Segge", + "vanleg piggstorr", + "vanlig piggstarr" + ], + "expansionNamesByKind": { + "acceptedScientificNames": [ + "Carex brongniartii Kunth", + "Carex brongniartii Kunth, 1837", + "Carex divulsa Stokes", + "Carex divulsa Stokes, 1787", + "Carex echinata Murray", + "Carex echinata Murray, 1770", + "Carex echinata subsp. echinata", + "Carex muricata", + "Carex muricata Ehrh., 1791", + "Carex muricata L.", + "Carex muricata L.Sp.Pl", + "Carex muricata Linnaeus", + "Carex pairae F.W.Schultz, 1868", + "Carex spicata Huds.", + "Carex spicata Huds., 1762", + "Carex vulpina L." + ], + "synonyms": [ + "Carex angustior Mack.", + "Carex angustior var. gracilenta R.T.Clausen & Wahl", + "Carex astracanica Willd. ex Kunth", + "Carex basilata Ohwi", + "Carex basilata Ohwi, 1942", + "Carex brotherorum Christ", + "Carex bullockiana Nelmes, 1959", + "Carex caflischii Br\u00fcgger", + "Carex canescens Huds.", + "Carex cephalantha (L.H.Bailey) E.P.Bicknell", + "Carex compacta Lam.", + "Carex contigua Hoppe", + "Carex contigua Hoppe, 1835", + "Carex contigua subsp. pairae (F.W.Schultz) Degen, 1936", + "Carex convexa Kit.", + "Carex divulsa Gaudin", + "Carex divulsa f. angustifolia (Podp.) So\u00f3", + "Carex divulsa f. guestphalica (Rchb.) K\u00fck.", + "Carex divulsa f. misera (K\u00fck. ex Vollm.) K\u00fck.", + "Carex divulsa f. polycarpa (Vollm.) K\u00fck.", + "Carex divulsa subsp. divulsa", + "Carex divulsa subsp. orsiniana (Ten.) K.Richt.", + "Carex divulsa subsp. virens (Lam.) Nyman", + "Carex divulsa var. approximata Legrand", + "Carex divulsa var. congesta Gren.", + "Carex divulsa var. guestphalica (Boenn. ex Rchb.) Nyman", + "Carex divulsa var. guestphalica (Rchb.) F.W.Schultz ex Asch. & Graebn.", + "Carex divulsa var. javanica Nelmes", + "Carex divulsa var. misera K\u00fck. ex Vollm.", + "Carex divulsa var. polycarpa Vollm.", + "Carex divulsa var. virens (Lam.) Durieu", + "Carex divulsa var. virens (Lam.) M\u00e9rat", + "Carex divulsa var. virens (Lam.) Steud.", + "Carex duriaei (F.W.Schultz) F.W.Schultz", + "Carex echinata f. brevispicata Podp.", + "Carex echinata f. chlorocarpa Podp.", + "Carex echinata f. pseudodivulsa (F.W.Schultz) Suess.", + "Carex echinata f. remotiuscula Podp.", + "Carex echinata f. subalpina Almq.", + "Carex echinata prol. grypos (Schkuhr) Rouy", + "Carex echinata race grypos (Schkuhr) Rouy, 1912", + "Carex echinata subsp. gasparrinii (Parl.) K.Richt.", + "Carex echinata subsp. grypos (Schkuhr) Arcang.", + "Carex echinata subsp. grypos (Schkuhr) Arcang., 1882", + "Carex echinata subsp. grypos (Schkuhr) K.Richt.", + "Carex echinata subsp. hydrophila (Dumort.) K.Richt.", + "Carex echinata var. angustata (J.Carey) L.H.Bailey", + "Carex echinata var. cephalantha L.H.Bailey", + "Carex echinata var. elata Maire ex Rouy", + "Carex echinata var. elata Maire ex Rouy, 1904", + "Carex echinata var. excelsior (L.H.Bailey) Fernald", + "Carex echinata var. grypos (Schkuhr) Fiori", + "Carex echinata var. grypos (Schkuhr) Nyman", + "Carex echinata var. grypos (Schkuhr) Rhiner", + "Carex echinata var. grypsos (Schkuhr) Nyman, 1882", + "Carex echinata var. gypsos (Schkuhr) Nyman", + "Carex echinata var. hydrophila (Dumort.) Nyman", + "Carex echinata var. microstachys Boeckeler", + "Carex echinata var. ormantha Fernald", + "Carex echinata var. perileia (S.T.Blake) P.Royen", + "Carex echinata var. phyllomanica (W.Boott) B.Boivin", + "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz", + "Carex echinata var. pseudodivulsa (F.W.Schultz) F.W.Schultz, 1863", + "Carex echinata var. tenuior K\u00fck.", + "Carex fasciculata Link ex Schkuhr", + "Carex fasciculata Willd.", + "Carex gajonum Nelmes", + "Carex gasparrinii Parl.", + "Carex glomerata Gilib.", + "Carex grypos Schkuhr", + "Carex grypos Schkuhr, 1806", + "Carex grypos var. nana Christ ex Briq.", + "Carex guestphalica (Rchb.) Boenn. ex O.Lang", + "Carex guestphalica (Rchb.) Boenn. ex W.D.J.Koch", + "Carex guestphalica Boenn. ex W.D.J.Koch", + "Carex hawaiiensis H.St.John", + "Carex hispida subsp. retusa (Degl.) Arcang.", + "Carex hydrophila Dumort.", + "Carex hypoxanthos Steud.", + "Carex interior var. josselynii Fernald", + "Carex intermedia Retz.", + "Carex josselynii (Fernald) Mack. ex Pease", + "Carex laricina Mack. ex Bright", + "Carex leersii Willd.", + "Carex leersii Willd., 1787", + "Carex leersii var. angustata (J.Carey) Burnham", + "Carex leersii var. angustata (J.Carey) Mack.", + "Carex leersii var. cephalantha (L.H.Bailey) J.K.Henry", + "Carex leptophylla Heuff.", + "Carex loliacea Schkuhr, 1801", + "Carex lumnitzeri (Rouy) V.I.Krecz.", + "Carex mertensis Weihe ex Kunth", + "Carex minganinsularum Raymond", + "Carex muehlenbergii Brongn.", + "Carex muricata", + "Carex muricata Desf.", + "Carex muricata Huds.", + "Carex muricata Huds. auct. non Huds.", + "Carex muricata Jungh.", + "Carex muricata L., 1753", + "Carex muricata Leers", + "Carex muricata Leers auct. non Leers", + "Carex muricata Schltdl.", + "Carex muricata Schltdl. & Cham.", + "Carex muricata f. angustifolia Podp.", + "Carex muricata prol. lumnitzeri Rouy", + "Carex muricata subsp. cephalantha (L.H.Bailey) R.T.Clausen", + "Carex muricata subsp. contigua (Hoppe) H.Lindb.", + "Carex muricata subsp. contigua (Hoppe) Moravec", + "Carex muricata subsp. divulsa (Stokes) Bonnier & Layens", + "Carex muricata subsp. divulsa (Stokes) Celak.", + "Carex muricata subsp. divulsa (Stokes) Corb.", + "Carex muricata subsp. divulsa (Stokes) Wahlenb.", + "Carex muricata subsp. lamprocarpa", + "Carex muricata subsp. lamprocarpa (Wallr.) Celak.", + "Carex muricata subsp. lumnitzeri (Rouy) So\u00f3", + "Carex muricata subsp. macrocarpa Neuman", + "Carex muricata subsp. macrocarpa Neuman, 1901", + "Carex muricata subsp. microcarpa Neuman", + "Carex muricata subsp. muricata", + "Carex muricata subsp. orsiniana (Ten.) Nyman", + "Carex muricata subsp. pairae", + "Carex muricata subsp. virens (Lam.) K.Richt.", + "Carex muricata subsp. virens (Lam.) \u010celak.", + "Carex muricata var. alpina Gaudin", + "Carex muricata var. angustata (J.Carey) J.Carey ex Gleason", + "Carex muricata var. angustifolia Podp.", + "Carex muricata var. basilata (Ohwi) Y.L.Chou", + "Carex muricata var. cephalantha (L.H.Bailey) Wiegand & Eames", + "Carex muricata var. densa Wallr.", + "Carex muricata var. depauperata Hampe", + "Carex muricata var. divulsa (Stokes) Wahlenb.", + "Carex muricata var. guestphalica (Boenn. ex O.Lang) Neuman", + "Carex muricata var. lamprocarpa Wallr.", + "Carex muricata var. laricina (Mack. ex Bright) Gleason", + "Carex muricata var. lumnitzeri Rouy", + "Carex muricata var. monostachya Asch.", + "Carex muricata var. muricata", + "Carex muricata var. nemorosa Gaudin", + "Carex muricata var. subramosa Neilr.", + "Carex muricata var. virens (Lam.) Rchb.", + "Carex muricata var. virens (Lam.) W.D.J.Koch", + "Carex muricata var. virens Andersson", + "Carex nemorosa Lumn.", + "Carex obtusangula Salzm. ex Boott", + "Carex omiana var. perileia (S.T.Blake) T.Koyama", + "Carex ormantha (Fernald) Mack.", + "Carex orsiniana Ten.", + "Carex pairae f. brevispicata (Podp.) So\u00f3", + "Carex pairae f. chlorocarpa (Podp.) So\u00f3", + "Carex pairae f. remotiuscula (Podp.) So\u00f3", + "Carex pairae subsp. borealis Hyl.", + "Carex pairae var. javanica Nelmes", + "Carex pairaei subsp. borealis Hyl.", + "Carex perileia S.T.Blake", + "Carex persica Nelmes", + "Carex phyllomanica W.Boott", + "Carex phyllomanica var. angustata (J.Carey) B.Boivin", + "Carex phyllomanica var. ormantha (Fernald) B.Boivin", + "Carex provincialis Degl.", + "Carex reflexa D.Dietr. ex Kunth", + "Carex retusa Degl.", + "Carex riparia subsp. fasciculata (Link ex Schkuhr) K.Richt.", + "Carex scirpoides var. josselynii (Fernald) Fernald", + "Carex serotina Ten.", + "Carex serrulata Mutel", + "Carex spicata Hudson", + "Carex spicata Thuill.", + "Carex spicata subsp. lumnitzeri (Rouy) So\u00f3", + "Carex stellulata Gooden.", + "Carex stellulata Gooden., 1794", + "Carex stellulata M.Bieb.", + "Carex stellulata f. excelsior (L.H.Bailey) K\u00fck.", + "Carex stellulata f. hydrophila (Dumort.) Asch. & Graebn.", + "Carex stellulata f. hydrophila (Dumort.) Vollm.", + "Carex stellulata f. hylogiton Asch. & Graebn.", + "Carex stellulata f. longibracteata Zapal.", + "Carex stellulata f. oligantha (Callm\u00e9) K\u00fck.", + "Carex stellulata f. oligantha Callm\u00e9", + "Carex stellulata f. pseudodivulsa F.W.Schultz", + "Carex stellulata subsp. grypos (Schkuhr) Gaudin", + "Carex stellulata subsp. pallens Gaudin", + "Carex stellulata var. alpestris Gaudin", + "Carex stellulata var. angustata J.Carey", + "Carex stellulata var. australis K\u00fck.", + "Carex stellulata var. cephalantha (L.H.Bailey) Fernald", + "Carex stellulata var. excelsior (L.H.Bailey) Fernald", + "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch", + "Carex stellulata var. grypos (Schkuhr) W.D.J.Koch, 1847", + "Carex stellulata var. masculina Gray", + "Carex stellulata var. oligantha Callm\u00e9", + "Carex stellulata var. ormantha (Fernald) Fernald", + "Carex stellulata var. pseudodivulsa F.W.Schultz", + "Carex stellulata var. pseudodivulsa F.W.Schultz, 1845", + "Carex stellulata var. scirpina Tuck.", + "Carex stellulata var. subalpina Asch. & Graebn.", + "Carex sterilis var. cephalantha (L.H.Bailey) L.H.Bailey", + "Carex sterilis var. excelsior L.H.Bailey", + "Carex subramosa Willd. ex Kunth", + "Carex svenonis Skottsb.", + "Carex svenonis var. alakaiensis Skottsb.", + "Carex tenuissima Schur", + "Carex tergestina Hoppe ex Boott", + "Carex virens", + "Carex virens Lam.", + "Carex virens Steud.", + "Carex virens var. divulsa (Stokes) F.W.Schultz", + "Carex virens var. duriaei F.W.Schultz", + "Carex virens var. guestphalica (Rchb.) Garcke", + "Carex virens var. major G.Mey.", + "Carex viridis Spenn.", + "Carex viridis var. divulsa (Stokes) Spenn.", + "Carex viridis var. longibracteata Spenn.", + "Carex vulpina Hohen.", + "Carex vulpina f. aristata Asch.", + "Carex vulpina f. capitulata (Peterm.) So\u00f3", + "Carex vulpina f. elongata Andersson", + "Carex vulpina f. laeviuscula Sanio ex Asch. & Graebn.", + "Carex vulpina f. minor Peterm.", + "Carex vulpina f. nemorosa (Gaudin) W.D.J.Koch", + "Carex vulpina subsp. nemorosa (Gaudin) K.Richt.", + "Carex vulpina subsp. nemorosa (Gaudin) O.Schwarz", + "Carex vulpina subsp. stribrnyi Velen.", + "Carex vulpina var. compacta (Lam.) Velen.", + "Carex vulpina var. crassinervis (Schur) K\u00fck.", + "Carex vulpina var. divulsa Celak.", + "Carex vulpina var. gracilis Gaudin", + "Carex vulpina var. littoralis Nolte ex Asch. & Graebn.", + "Carex vulpina var. longibracteata Beck", + "Carex vulpina var. nemorosa Gaudin", + "Carex vulpina var. pallidior Meinsh.", + "Carex vulpina var. remotiflora Lange", + "Carex vulpina var. stribrnyi (Velen.) K\u00fck.", + "Carex vulpina var. tenuior Ledeb.", + "Carex vulpina var. vulgaris Celak.", + "Caricina divulsa (Stokes) St.-Lag.", + "Caricina muricata (L.) St.-Lag.", + "Caricina stellulata (Gooden.) St.-Lag.", + "Caricina stellulata (Gooden.) St.-Lag., 1889", + "Desmiograstis divulsa (Stokes) B\u00f6rner", + "Desmiograstis echinata (Murray) B\u00f6rner", + "Desmiograstis stellulata (Gooden.) B\u00f6rner", + "Edritria vulpina (L.) Raf.", + "Vignea altissima Schur", + "Vignea angustior (Mack.) Soj\u00e1k", + "Vignea divulsa (Stokes) Rchb.", + "Vignea echinata (Murray) Fourr.", + "Vignea echinata (Murray) Fourr., 1869", + "Vignea grypos (Schkuhr) Rchb.", + "Vignea guestphalica Rchb.", + "Vignea hydrophila (Dumort.) Rchb.", + "Vignea muricata (L.) Rchb.", + "Vignea muricata subsp. lamprocarpa", + "Vignea muricata subsp. lamprocarpa (Wallr.) Soj\u00e1k", + "Vignea nemorosa (Gaudin) Rchb.", + "Vignea perileia (S.T.Blake) Soj\u00e1k", + "Vignea persica (Nelmes) Soj\u00e1k", + "Vignea spicata (Huds.) Soj\u00e1k, 1980", + "Vignea stellulata (Gooden.) Rchb.", + "Vignea tenuissima Schur", + "Vignea virens (Lam.) Rchb.", + "Vignea vulpina (L.) Rchb.", + "Vignea vulpina f. capitulata Peterm.", + "Vignea vulpina var. crassinervis Schur" + ], + "subordinateTaxa": [ + "Carex divulsa subsp. leersiana Rauschert", + "Carex echinata subsp. echinata", + "Carex muricata L. subsp. lamprocarpa \u010celak", + "Carex muricata subsp. ashokae", + "Carex muricata subsp. ashokae Molina Gonz., Acedo & Llamas", + "Carex muricata subsp. ashokii MolinaGonz. et al.", + "Carex muricata subsp. cesanensis", + "Carex muricata subsp. cesanensis A.M.Molina et al.", + "Carex muricata subsp. cesanensis Molina Gonz., Acedo & Llamas", + "Carex muricata subsp. lamprocarpa", + "Carex muricata subsp. lamprocarpa Celakovsky", + "Carex muricata subsp. muricata" + ], + "vernacularNames": [ + "Carice contigua", + "Groene bermzegge", + "Groene bermzegge subsp. divulsa", + "Hesg Llwyd", + "Hesg Ysbigog", + "Hesgen Dywysennog Borffor", + "Hesgen Lwyd", + "Hesgen Lwydlas", + "Hesgen Ysbigog", + "Hesgen Ysbigog Borffor", + "IJle bermzegge", + "Laiche a epis separes", + "Laiche pointue", + "La\u00eeche de Paira", + "La\u00eeche en \u00e9pi", + "La\u00eeche \u00e9pineuse", + "La\u00eeche \u00e9toil\u00e9e, La\u00eeche-h\u00e9risson, La\u00eeche \u00e9pineuse", + "Lesser Prickly Sedge", + "Mellembrudt star", + "Muricate Sedge", + "Prickly Sedge", + "Rough Sedge", + "Rough sedge", + "Rough-pointed Sedge", + "Small-fruited Prickly-sedge", + "Sparrige Segge", + "Spiked Sedge", + "Stachel-Segge", + "Star Sedge", + "Unterbrochenaehrige Stachel-Segge", + "bristle-fruited sedge", + "carex muricat", + "carex muriqu\u00e9", + "carex \u00e0 \u00e9pis s\u00e9par\u00e9s", + "carex \u00e9toil\u00e9", + "dichte bermzegge", + "grassland sedge", + "grey sedge", + "laiche \u00e0 \u00e9pis s\u00e9par\u00e9s", + "large-fruited star sedge", + "la\u00eeche de paira", + "la\u00eeche \u00e0 utricules divergents", + "la\u00eeche \u00e9cart\u00e9e", + "lesser prickly sedge", + "little prickly sedge", + "l\u00e5ngstarr", + "muricate sedge", + "m\u00f6rk piggstarr", + "m\u00f6rk sn\u00e5rstarr", + "m\u00f8rk piggstarr", + "m\u00f8rk piggstorr", + "n\u00e1stelukti", + "piggstarr", + "piggstorr", + "prickly sedge", + "rough sedge", + "rough-pointed sedge", + "separated sedge", + "spiny star sedge", + "star sedge", + "stellate sedge", + "stjernestarr", + "stjernestorr", + "stjernstar", + "stj\u00e4rnstarr", + "tennstar", + "t\u00f6rr\u00f6sara", + "unterbrochen\u00e4hrige Segge", + "vanleg piggstorr", + "vanlig piggstarr" + ] + }, + "excludedNonExactSearchHits": [ + { + "key": 221886855, + "scientificName": "Carex muricata subsp. lamprocarpa", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "?elak., 1879", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 221886838, + "accepted": "Carex pairae F.W.Schultz, 1868", + "numDescendants": 0 + }, + { + "key": 141267127, + "scientificName": "Carex muricata L. subsp. muricata", + "canonicalName": "Carex muricata muricata", + "authorship": null, + "rank": "SUBSPECIES", + "taxonomicStatus": "ACCEPTED", + "acceptedKey": null, + "accepted": null, + "numDescendants": 0 + }, + { + "key": 2722645, + "scientificName": "Carex muricata subsp. macrocarpa Neuman", + "canonicalName": "Carex muricata macrocarpa", + "authorship": "Neuman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 7070266, + "accepted": "Carex spicata subsp. spicata", + "numDescendants": 0 + }, + { + "key": 186714614, + "scientificName": "Carex muricata subsp. microcarpa Neuman", + "canonicalName": "Carex muricata microcarpa", + "authorship": "Neuman", + "rank": "SUBSPECIES", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412197, + "accepted": "Carex echinata Murray", + "numDescendants": 0 + }, + { + "key": 2728510, + "scientificName": "Carex muricata var. lamprocarpa Wallr.", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "Wallr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 7227181, + "accepted": "Carex muricata subsp. muricata", + "numDescendants": 0 + }, + { + "key": 2726765, + "scientificName": "Carex muricata var. indica Boott", + "canonicalName": "Carex muricata indica", + "authorship": "Boott", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 2725162, + "accepted": "Carex wallichiana Spreng.", + "numDescendants": 0 + }, + { + "key": 2729881, + "scientificName": "Carex muricata var. gracilis Boott", + "canonicalName": "Carex muricata gracilis", + "authorship": "Boott", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 2729879, + "accepted": "Carex hookeriana Dewey", + "numDescendants": 0 + }, + { + "key": 9359393, + "scientificName": "Carex muricata var. gracilis Gray", + "canonicalName": "Carex muricata gracilis", + "authorship": "Gray", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 7227181, + "accepted": "Carex muricata subsp. muricata", + "numDescendants": 0 + }, + { + "key": 217523186, + "scientificName": "Carex muricata var. americana L.H.Bailey", + "canonicalName": "Carex muricata americana", + "authorship": "L.H.Bailey", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 213956240, + "accepted": "Carex hookeriana Dewey, 1836", + "numDescendants": 0 + }, + { + "key": 186706591, + "scientificName": "Carex muricata var. loliacea Schkuhr", + "canonicalName": "Carex muricata loliacea", + "authorship": "Schkuhr", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211408095, + "accepted": "Carex pairae F.W.Schultz", + "numDescendants": 0 + }, + { + "key": 186707976, + "scientificName": "Carex muricata var. lamprocarpa Wallr.", + "canonicalName": "Carex muricata lamprocarpa", + "authorship": "Wallr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211408666, + "accepted": "Carex muricata L.", + "numDescendants": 0 + }, + { + "key": 186714882, + "scientificName": "Carex muricata f. angustifolia Podp.", + "canonicalName": "Carex muricata angustifolia", + "authorship": "Podp.", + "rank": "FORM", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 211412361, + "accepted": "Carex divulsa Stokes", + "numDescendants": 0 + }, + { + "key": 303009169, + "scientificName": "Carex muricata var. virens Andersson", + "canonicalName": "Carex muricata virens", + "authorship": "Andersson", + "rank": "VARIETY", + "taxonomicStatus": "HETEROTYPIC_SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916561, + "scientificName": "Carex muricata var. depauperata Hampe", + "canonicalName": "Carex muricata depauperata", + "authorship": "Hampe", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269916723, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269916461, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 269937281, + "scientificName": "Carex muricata var. interrupta Wallr.", + "canonicalName": "Carex muricata interrupta", + "authorship": "Wallr.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 269937269, + "accepted": "Carex pairae F.W.Schultz", + "numDescendants": 0 + }, + { + "key": 207001541, + "scientificName": "Carex muricata var. gracilis Boott", + "canonicalName": "Carex muricata gracilis", + "authorship": "Boott", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 207001538, + "accepted": "Carex hookeriana Dewey", + "numDescendants": 0 + }, + { + "key": 207005944, + "scientificName": "Carex muricata var. depauperata Hampe", + "canonicalName": "Carex muricata depauperata", + "authorship": "Hampe", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 207005700, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 207005953, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 207005700, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + }, + { + "key": 296338754, + "scientificName": "Carex muricata var. monostachya Asch.", + "canonicalName": "Carex muricata monostachya", + "authorship": "Asch.", + "rank": "VARIETY", + "taxonomicStatus": "SYNONYM", + "acceptedKey": 296338562, + "accepted": "Carex echinata subsp. echinata", + "numDescendants": 0 + } + ] + } + ] +} diff --git a/.dev/storage/corpora/biofid-mini/corpusConfig.json b/.dev/storage/corpora/biofid-mini/corpusConfig.json new file mode 100755 index 00000000..d563f9bf --- /dev/null +++ b/.dev/storage/corpora/biofid-mini/corpusConfig.json @@ -0,0 +1,43 @@ +{ + "name": "biofid-mini-kubernetes", + "author": "Fachinformationsdienst Biodiversitätsforschung (BIOfid)", + "language": "de-DE", + "description": "Im Aufbau!

Im Rahmen des Fachinformationsdienstes (FID) Biodiversitätsforschung digitalisiert die Universitätsbibliothek Johann Christian Senckenberg (Frankfurt am Main) Literatur zur Biodiversität mit Schwerpunkt auf Zeitschriften des 20. Jahrhunderts.

Zu geringen Anteilen umfasst die Sammlung Biodiversität auch Teile der älteren, urheberrechtsfreien Literatur, die bislang noch nirgends digitalisiert wurde.

Ein wesentlicher Zweck der Digitalisierung im FID ist – neben einer besseren Verfügbarkeit der Inhalte – die Schaffung eines Korpus für ein Pilotvorhaben zum Text-Mining in Biodiversitäts-Literatur. Dabei steht Literatur zur Diversität von Gefäßpflanzen, Schmetterlingen und Vögeln in Mitteleuropa im Vordergrund.

Der FID Biodiversitätsforschung wird durch die Universitätsbibliothek Johann Christian Senckenberg gemeinsam mit der Senckenberg Gesellschaft für Naturforschung und der AG Texttechnologie am Institut für Informatik der Goethe-Universität in den Jahren 2017 bis 2020 aufgebaut.

Der FID einschließlich der Digitalisierung von Biodiversitäts-Literatur wird von der Deutschen Forschungsgemeinschaft (DFG) gefördert.", + "annotations": { + "annotatorMetadata": false, + "uceMetadata": true, + "logicalLinks": false, + "OCRPage": true, + "OCRParagraph": true, + "OCRBlock": true, + "OCRLine": true, + "taxon": { + "annotated": true, + "biofidOnthologyAnnotated": true + }, + "srLink": false, + "lemma": false, + "namedEntity": true, + "geoNames": true, + "sentence": true, + "time": true, + "sentiment": false, + "emotion": false, + "wikipediaLink": false, + "completeNegation": false, + "cue": false, + "event": false, + "focus": false, + "scope": false, + "xscope": false, + "unifiedTopic": false + }, + "addToExistingCorpus": true, + "other": { + "availableOnFrankfurtUniversityCollection": false, + "includeTopicDistribution": false, + "enableEmbeddings": true, + "enableRAGBot": false, + "enableS3Storage": false + } +} diff --git a/.dev/storage/corpora/biofid-production/corpusConfig.json b/.dev/storage/corpora/biofid-production/corpusConfig.json new file mode 100644 index 00000000..5c156cd8 --- /dev/null +++ b/.dev/storage/corpora/biofid-production/corpusConfig.json @@ -0,0 +1,43 @@ +{ + "name": "biofid-production", + "author": "Fachinformationsdienst Biodiversitaetsforschung (BIOfid)", + "language": "de-DE", + "description": "Im Aufbau!

Im Rahmen des Fachinformationsdienstes (FID) Biodiversitaetsforschung digitalisiert die Universitaetsbibliothek Johann Christian Senckenberg (Frankfurt am Main) Literatur zur Biodiversitaet mit Schwerpunkt auf Zeitschriften des 20. Jahrhunderts.

Zu geringen Anteilen umfasst die Sammlung Biodiversitaet auch Teile der aelteren, urheberrechtsfreien Literatur, die bislang noch nirgends digitalisiert wurde.

Ein wesentlicher Zweck der Digitalisierung im FID ist neben einer besseren Verfuegbarkeit der Inhalte die Schaffung eines Korpus fuer ein Pilotvorhaben zum Text-Mining in Biodiversitaets-Literatur. Dabei steht Literatur zur Diversitaet von Gefaesspflanzen, Schmetterlingen und Voegeln in Mitteleuropa im Vordergrund.", + "annotations": { + "annotatorMetadata": false, + "uceMetadata": true, + "logicalLinks": false, + "OCRPage": true, + "OCRParagraph": true, + "OCRBlock": true, + "OCRLine": true, + "taxon": { + "annotated": true, + "biofidOnthologyAnnotated": true + }, + "srLink": false, + "lemma": false, + "namedEntity": true, + "geoNames": true, + "sentence": true, + "time": true, + "sentiment": false, + "emotion": false, + "wikipediaLink": false, + "completeNegation": false, + "cue": false, + "event": false, + "focus": false, + "scope": false, + "xscope": false, + "unifiedTopic": false + }, + "addToExistingCorpus": true, + "other": { + "availableOnFrankfurtUniversityCollection": false, + "includeTopicDistribution": false, + "enableEmbeddings": true, + "enableRAGBot": false, + "enableS3Storage": false + } +} diff --git a/.dev/storage/gnfinder_fix_candidates.sample.jsonl b/.dev/storage/gnfinder_fix_candidates.sample.jsonl new file mode 100644 index 00000000..3f9407e1 --- /dev/null +++ b/.dev/storage/gnfinder_fix_candidates.sample.jsonl @@ -0,0 +1,15 @@ +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 9422, "end": 9434, "abbreviation": "P. fluitans", "inferredGenus": "Potamogeton", "genusSource": "Potamogeton L.", "expandedCandidate": "Potamogeton fluitans", "gbif": {"match": {"usageKey": 7629840, "scientificName": "Potamogeton \u00d7 fluitans Roth", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 98, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=3", "acceptedUsageKey": null}, "topSearchHits": [{"key": 221934718, "scientificName": "Potamogeton fluitans", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 221934686, "accepted": "Potamogeton nodosus Poir., 1816"}, {"key": 313587709, "scientificName": "Potamogeton fluitans", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 221934715, "scientificName": "Potamogeton fluitans", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 221934686, "accepted": "Potamogeton nodosus Poir., 1816"}, {"key": 160913882, "scientificName": "Potamogeton fluitans Roth", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 254785501, "scientificName": "Potamogeton fluitans Roth", "canonicalName": "Potamogeton fluitans", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 13906, "end": 13918, "abbreviation": "S. palustris", "inferredGenus": "Scirpus", "genusSource": "Scirpus Tourn.", "expandedCandidate": "Scirpus palustris", "gbif": {"match": {"usageKey": 2717003, "scientificName": "Scirpus palustris L.", "canonicalName": "Scirpus palustris", "rank": "SPECIES", "status": "SYNONYM", "matchType": "EXACT", "confidence": 98, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=0; score=114; nextMatch=5", "acceptedUsageKey": 2717002}, "topSearchHits": [{"key": 116780027, "scientificName": "Scirpus palustris L.", "canonicalName": "Scirpus palustris", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 116780018, "accepted": "Eleocharis palustris Gruppe"}, {"key": 102195037, "scientificName": "Scirpus palustris L.", "canonicalName": "Scirpus palustris", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 102195036, "accepted": "Eleocharis palustris (L.) Roem. & Schult."}, {"key": 101295185, "scientificName": "Scirpus palustris L.", "canonicalName": "Scirpus palustris", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 243438338, "scientificName": "Scirpus palustris", "canonicalName": "Scirpus palustris", "rank": null, "taxonomicStatus": "SYNONYM", "acceptedKey": 103033814, "accepted": "Eleocharis palustris"}, {"key": 317182621, "scientificName": "Scirpus palustris", "canonicalName": "Scirpus palustris", "rank": null, "taxonomicStatus": "DOUBTFUL", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 13971, "end": 13982, "abbreviation": "S. fluitans", "inferredGenus": "Scirpus", "genusSource": "Scirpus Tourn.", "expandedCandidate": "Scirpus fluitans", "gbif": {"match": {"usageKey": 2713177, "scientificName": "Scirpus fluitans L.", "canonicalName": "Scirpus fluitans", "rank": "SPECIES", "status": "SYNONYM", "matchType": "EXACT", "confidence": 98, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=0; score=114; nextMatch=5", "acceptedUsageKey": 2713142}, "topSearchHits": [{"key": 267481641, "scientificName": "Scirpus fluitans", "canonicalName": "Scirpus fluitans", "rank": null, "taxonomicStatus": "DOUBTFUL", "acceptedKey": null, "accepted": null}, {"key": 317182604, "scientificName": "Scirpus fluitans", "canonicalName": "Scirpus fluitans", "rank": null, "taxonomicStatus": "DOUBTFUL", "acceptedKey": null, "accepted": null}, {"key": 243438136, "scientificName": "Scirpus fluitans", "canonicalName": "Scirpus fluitans", "rank": null, "taxonomicStatus": "SYNONYM", "acceptedKey": 103033232, "accepted": "Isolepis fluitans"}, {"key": 296388188, "scientificName": "Scirpus fluitans L.", "canonicalName": "Scirpus fluitans", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 296388127, "accepted": "Isolepis fluitans (L.) R.Br."}, {"key": 269983731, "scientificName": "Scirpus fluitans L.", "canonicalName": "Scirpus fluitans", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 269983655, "accepted": "Isolepis fluitans (L.) R.Br."}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 16087, "end": 16097, "abbreviation": "C. stricta", "inferredGenus": "Carex", "genusSource": "Carex L.", "expandedCandidate": "Carex stricta", "gbif": {"match": {"usageKey": 2722325, "scientificName": "Carex stricta Lam.", "canonicalName": "Carex stricta", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 97, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=0", "acceptedUsageKey": null}, "topSearchHits": [{"key": 103032362, "scientificName": "Carex stricta", "canonicalName": "Carex stricta", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 100013761, "scientificName": "Carex stricta Lamarck", "canonicalName": "Carex stricta", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 102194339, "scientificName": "Carex stricta Lam.", "canonicalName": "Carex stricta", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 207005576, "scientificName": "Carex stricta Gooden.", "canonicalName": "Carex stricta", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 207005502, "accepted": "Carex elata All."}, {"key": 148957784, "scientificName": "Carex stricta Gooden.", "canonicalName": "Carex stricta", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 160028026, "accepted": "Carex elata All."}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 16105, "end": 16116, "abbreviation": "C. muricata", "inferredGenus": "Carex", "genusSource": "Carex L.", "expandedCandidate": "Carex muricata", "gbif": {"match": {"usageKey": 2722926, "scientificName": "Carex muricata L.", "canonicalName": "Carex muricata", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 97, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=0", "acceptedUsageKey": null}, "topSearchHits": [{"key": 148957453, "scientificName": "Carex muricata", "canonicalName": "Carex muricata", "rank": "SPECIES", "taxonomicStatus": "MISAPPLIED", "acceptedKey": 160027865, "accepted": "Carex spicata Huds."}, {"key": 312830004, "scientificName": "Carex muricata L.", "canonicalName": "Carex muricata", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 312861452, "scientificName": "Carex muricata L.", "canonicalName": "Carex muricata", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 312909330, "scientificName": "Carex muricata L.", "canonicalName": "Carex muricata", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 239712335, "scientificName": "Carex muricata L.", "canonicalName": "Carex muricata", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 16121, "end": 16130, "abbreviation": "C. virens", "inferredGenus": "Carex", "genusSource": "Carex L.", "expandedCandidate": "Carex virens", "gbif": {"match": {"usageKey": 2721893, "scientificName": "Carex L.", "canonicalName": "Carex", "rank": "GENUS", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 97, "note": "nextMatch=0", "acceptedUsageKey": null}, "topSearchHits": [{"key": 221886845, "scientificName": "Carex virens", "canonicalName": "Carex virens", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 221886838, "accepted": "Carex pairae F.W.Schultz, 1868"}, {"key": 7816530, "scientificName": "Carex virens Lam.", "canonicalName": "Carex virens", "rank": "SPECIES", "taxonomicStatus": "HETEROTYPIC_SYNONYM", "acceptedKey": 2722914, "accepted": "Carex divulsa Stokes"}, {"key": 207013414, "scientificName": "Carex virens Thuill.", "canonicalName": "Carex virens", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 207013279, "accepted": "Carex acuta L."}, {"key": 269927322, "scientificName": "Carex virens Thuill.", "canonicalName": "Carex virens", "rank": "SPECIES", "taxonomicStatus": "HETEROTYPIC_SYNONYM", "acceptedKey": 269927033, "accepted": "Carex acuta L."}, {"key": 101264960, "scientificName": "Carex virens Lam.", "canonicalName": "Carex virens", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 18776, "end": 18785, "abbreviation": "R. repens", "inferredGenus": "Ranunculaceae", "genusSource": "Ranunculaceae", "expandedCandidate": "Ranunculaceae repens", "gbif": {"match": {"usageKey": 2410, "scientificName": "Ranunculaceae", "canonicalName": "Ranunculaceae", "rank": "FAMILY", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 94, "note": "Similarity: name=100; classification=-2; rank=0; status=1; score=99; singleMatch=5", "acceptedUsageKey": null}, "topSearchHits": []}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 18851, "end": 18863, "abbreviation": "R. nemorosus", "inferredGenus": "Ranunculaceae", "genusSource": "Ranunculaceae", "expandedCandidate": "Ranunculaceae nemorosus", "gbif": {"match": {"usageKey": 2410, "scientificName": "Ranunculaceae", "canonicalName": "Ranunculaceae", "rank": "FAMILY", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 94, "note": "Similarity: name=100; classification=-2; rank=0; status=1; score=99; singleMatch=5", "acceptedUsageKey": null}, "topSearchHits": []}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 20493, "end": 20506, "abbreviation": "V. silvestris", "inferredGenus": "Vitis", "genusSource": "Vitis L.", "expandedCandidate": "Vitis silvestris", "gbif": {"match": {"usageKey": 5658839, "scientificName": "Vitis silvestris Roth", "canonicalName": "Vitis silvestris", "rank": "SPECIES", "status": "SYNONYM", "matchType": "EXACT", "confidence": 98, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=0; score=114; nextMatch=4", "acceptedUsageKey": 5372392}, "topSearchHits": [{"key": 101303166, "scientificName": "Vitis silvestris C.C. Gmelin", "canonicalName": "Vitis silvestris", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 105744055, "scientificName": "Vitis silvestris Roth", "canonicalName": "Vitis silvestris", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 118248521, "scientificName": "Vitis silvestris Roth", "canonicalName": "Vitis silvestris", "rank": "SPECIES", "taxonomicStatus": "DOUBTFUL", "acceptedKey": null, "accepted": null}, {"key": 5658839, "scientificName": "Vitis silvestris Roth", "canonicalName": "Vitis silvestris", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 5372392, "accepted": "Vitis vinifera L."}, {"key": 207870897, "scientificName": "Vitis silvestris Roth", "canonicalName": "Vitis silvestris", "rank": "SPECIES", "taxonomicStatus": "SYNONYM", "acceptedKey": 207870633, "accepted": "Vitis vinifera L."}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 20833, "end": 20849, "abbreviation": "M. alterniflorum", "inferredGenus": "Myriophyllum", "genusSource": "Myriophyllum L.", "expandedCandidate": "Myriophyllum alterniflorum", "gbif": {"match": {"usageKey": 5361773, "scientificName": "Myriophyllum alterniflorum DC.", "canonicalName": "Myriophyllum alterniflorum", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 99, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=5", "acceptedUsageKey": null}, "topSearchHits": [{"key": 176241590, "scientificName": "Myriophyllum alterniflorum", "canonicalName": "Myriophyllum alterniflorum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 206099287, "scientificName": "Myriophyllum alterniflorum", "canonicalName": "Myriophyllum alterniflorum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 103418055, "scientificName": "Myriophyllum alterniflorum", "canonicalName": "Myriophyllum alterniflorum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 179060715, "scientificName": "Myriophyllum alterniflorum", "canonicalName": "Myriophyllum alterniflorum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 164941972, "scientificName": "Myriophyllum alterniflorum", "canonicalName": "Myriophyllum alterniflorum", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 20947, "end": 20958, "abbreviation": "M. spicatum", "inferredGenus": "Myriophyllum", "genusSource": "Myriophyllum L.", "expandedCandidate": "Myriophyllum spicatum", "gbif": {"match": {"usageKey": 5361760, "scientificName": "Myriophyllum spicatum L.", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 97, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=0", "acceptedUsageKey": null}, "topSearchHits": [{"key": 103418196, "scientificName": "Myriophyllum spicatum", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 313587999, "scientificName": "Myriophyllum spicatum", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 179060682, "scientificName": "Myriophyllum spicatum", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 313595013, "scientificName": "Myriophyllum spicatum", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 176241589, "scientificName": "Myriophyllum spicatum", "canonicalName": "Myriophyllum spicatum", "rank": "SPECIES", "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 22294, "end": 22302, "abbreviation": "S. nigra", "inferredGenus": "Sambucus", "genusSource": "Sambucus L.", "expandedCandidate": "Sambucus nigra", "gbif": {"match": {"usageKey": 2888728, "scientificName": "Sambucus nigra L.", "canonicalName": "Sambucus nigra", "rank": "SPECIES", "status": "ACCEPTED", "matchType": "EXACT", "confidence": 97, "note": "Similarity: name=110; authorship=0; classification=-2; rank=6; status=1; score=115; nextMatch=0", "acceptedUsageKey": null}, "topSearchHits": [{"key": 164942831, "scientificName": "Sambucus nigra", "canonicalName": "Sambucus nigra", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 164942821, "scientificName": "Sambucus nigra", "canonicalName": "Sambucus nigra", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 164942830, "scientificName": "Sambucus nigra", "canonicalName": "Sambucus nigra", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 164942822, "scientificName": "Sambucus nigra", "canonicalName": "Sambucus nigra", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}, {"key": 164942819, "scientificName": "Sambucus nigra", "canonicalName": "Sambucus nigra", "rank": null, "taxonomicStatus": "ACCEPTED", "acceptedKey": null, "accepted": null}]}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 22375, "end": 22387, "abbreviation": "S. pulchella", "inferredGenus": "Sambucus", "genusSource": "Sambucus L.", "expandedCandidate": "Sambucus pulchella", "gbif": {"match": {"usageKey": 2888721, "scientificName": "Sambucus L.", "canonicalName": "Sambucus", "rank": "GENUS", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 94, "note": "Similarity: name=100; classification=-2; rank=0; status=1; score=99; nextMatch=5", "acceptedUsageKey": null}, "topSearchHits": []}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 23149, "end": 23157, "abbreviation": "S. nigra", "inferredGenus": "Sonchus", "genusSource": "Sonchus oleraceus L.", "expandedCandidate": "Sonchus nigra", "gbif": {"match": {"usageKey": 3105646, "scientificName": "Sonchus L.", "canonicalName": "Sonchus", "rank": "GENUS", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 94, "note": "Similarity: name=100; classification=-2; rank=0; status=1; score=99; nextMatch=5", "acceptedUsageKey": null}, "topSearchHits": []}} +{"documentId": "10773164", "file": ".dev/storage/corpora/biofid-production/input/10773164.xmi.bz2", "begin": 41453, "end": 41466, "abbreviation": "R. sceleratus", "inferredGenus": "Ranunculaceae", "genusSource": "Ranunculaceae", "expandedCandidate": "Ranunculaceae sceleratus", "gbif": {"match": {"usageKey": 2410, "scientificName": "Ranunculaceae", "canonicalName": "Ranunculaceae", "rank": "FAMILY", "status": "ACCEPTED", "matchType": "HIGHERRANK", "confidence": 94, "note": "Similarity: name=100; classification=-2; rank=0; status=1; score=99; singleMatch=5", "acceptedUsageKey": null}, "topSearchHits": []}} diff --git a/.dev/tmp/tdb_remote_manifest.tsv b/.dev/tmp/tdb_remote_manifest.tsv new file mode 100644 index 00000000..9f7a4dae --- /dev/null +++ b/.dev/tmp/tdb_remote_manifest.tsv @@ -0,0 +1,41 @@ +Data-0002/GOSP.bpt 24 +Data-0002/GOSP.dat 8388608 +Data-0002/GOSP.idn 8388608 +Data-0002/GPOS.bpt 24 +Data-0002/GPOS.dat 8388608 +Data-0002/GPOS.idn 8388608 +Data-0002/GPU.bpt 24 +Data-0002/GPU.dat 8388608 +Data-0002/GPU.idn 8388608 +Data-0002/GSPO.bpt 24 +Data-0002/GSPO.dat 8388608 +Data-0002/GSPO.idn 8388608 +Data-0002/journal.jrnl 0 +Data-0002/nodes.bpt 24 +Data-0002/nodes.dat 6341787648 +Data-0002/nodes-data.bdf 16 +Data-0002/nodes-data.obj 857845295 +Data-0002/nodes.idn 58720256 +Data-0002/OSP.bpt 24 +Data-0002/OSP.dat 3464495104 +Data-0002/OSPG.bpt 24 +Data-0002/OSPG.dat 8388608 +Data-0002/OSPG.idn 8388608 +Data-0002/OSP.idn 67108864 +Data-0002/POS.bpt 24 +Data-0002/POS.dat 3539992576 +Data-0002/POSG.bpt 24 +Data-0002/POSG.dat 8388608 +Data-0002/POSG.idn 8388608 +Data-0002/POS.idn 41943040 +Data-0002/prefixes.bpt 24 +Data-0002/prefixes.dat 8388608 +Data-0002/prefixes-data.bdf 16 +Data-0002/prefixes-data.obj 592 +Data-0002/prefixes.idn 8388608 +Data-0002/SPO.bpt 24 +Data-0002/SPO.dat 4420796416 +Data-0002/SPOG.bpt 24 +Data-0002/SPOG.dat 8388608 +Data-0002/SPOG.idn 8388608 +Data-0002/SPO.idn 41943040 diff --git a/.dev/tmp/tdb_todo.txt b/.dev/tmp/tdb_todo.txt new file mode 100644 index 00000000..c7ef56fc --- /dev/null +++ b/.dev/tmp/tdb_todo.txt @@ -0,0 +1,16 @@ +Data-0002/journal.jrnl +Data-0002/nodes.bpt +Data-0002/nodes.dat +Data-0002/nodes-data.bdf +Data-0002/nodes-data.obj +Data-0002/nodes.idn +Data-0002/prefixes.bpt +Data-0002/prefixes.dat +Data-0002/prefixes-data.bdf +Data-0002/prefixes-data.obj +Data-0002/prefixes.idn +Data-0002/SPO.dat +Data-0002/SPOG.bpt +Data-0002/SPOG.dat +Data-0002/SPOG.idn +Data-0002/SPO.idn diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2b6f8bcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.dev/storage +uce.portal/**/target +uce.portal/uce.corpus-importer/logs +node_modules diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..3acef44c --- /dev/null +++ b/.env.example @@ -0,0 +1,297 @@ +# ========================= +# Docker Compose variables +# ========================= +# +# ------------------------- +# Optional NGINX: enable the nginx reverse proxy (nginx-proxy) only when you want virtual-host routing. +# ------------------------- +# Start it with: `docker compose --profile proxy up -d` or +# +NGINX_PROXY_HTTP_PORT=80 + +# Docker Compose Profile toggle: +# - e.g.: `COMPOSE_PROFILES=local,fuseki,minio,rag` +# UCE - Profiles: +# local: Starts uce-web, uce-keycloak-auth, uce-keycloak-config, uce-postgresql-db | Default UCE-WEB with auth & local compose postgres-db +# remote: Starts uce-web, uce-keycloak-auth, uce-keycloak-config | Default UCE-WEB with remote auth & remote postgres-db +# remotedb: Starts uce-web, uce-keycloak-auth, uce-keycloak-config | Default UCE-WEB with local auth & remote postgres-db +# remotekc: Starts uce-web, uce-postgresql-db | Default UCE-WEB with remote auth & local postgres-db +# ssh: Starts uce-web, uce-keycloak-auth, uce-keycloak-config, uce-ssh-pg-db-tunnel | Default UCE-WEB with auth & remote postgres-db through ssh tunnel +# proxy: Starts nginx, uce-web, uce-keycloak-auth, uce-keycloak-config | remote + nginx. add ",db" for local compose pg-db. +# db: Starts only uce-postgresql-db +# import: Starts only uce-importer +# minio: Starts only uce-minio-storage +# fuseki: Starts only uce-fuseki-sparql +# rag: Starts only uce-rag-service +# keycloak-csv: Starts only uce-keycloak-sync-from-csv +COMPOSE_PROFILES=local,fuseki +# +# ------------------------- +# UCE +# ------------------------- +# +# OPTIONAL CUSTOM IMAGE: override only if you want a different image/tag. +# UCE_WEB_IMAGE=docker.texttechnologylab.org/uce-web-deploy:0.0.1 +# +# Canonical public host/scheme primitives used to derive runtime URLs in compose. +UCE_SCHEME=http +UCE_PUBLIC_HOST=localhost +# +# Published host port for the `uce-web` container. +UCE_PUBLISHED_PORT=8397 +# Internal port that the UCE process binds to inside the container. +UCE_INTERNAL_PORT=4567 +# +# Host path to the UCE config JSON mounted into the container as `/app/config/uceConfig.json` +UCE_CONFIG_PATH=./.dev/storage/UceConfig.json +# Optional: override host source paths for web-mounted live assets/scripts. +# UCE_TEMPLATES_HOST_PATH=./uce.portal/resources/templates +# UCE_DATABASE_SCRIPTS_HOST_PATH=./database +# +# ------------------------- +# Optional: SSH configs for tunneling postgres-db through ssh +# ------------------------- +# +SSH_HOST= +SSH_REMOTE_DB_HOST=geltlin.hucompute.org +SSH_REMOTE_DB_PORT= +SSH_LOCAL_PORT= +# SSH authentication with either password or key +SSH_PASSWORD= +# UCE_SSH_KEY_PATH= +# +# ------------------------- +# Keycloak +# ------------------------- +# +# OPTIONAL CUSTOM IMAGE: override only if you want a different image/tag. +# KEYCLOAK_IMAGE=quay.io/keycloak/keycloak:26.2.5 +# +# UCE auth wiring (uceConfig.json env overrides). +# These settings are consumed by the portal/UI layer to enable auth and to build correct browser redirects. +# They must describe the "public" URLs as seen by the user's browser (not container-internal hostnames). +# +KEYCLOAK_PUBLIC_SCHEME=http +KEYCLOAK_PUBLIC_HOST=localhost +# +# Published host port for the `uce-keycloak-auth` container. +KEYCLOAK_PUBLISHED_PORT=8399 +# (Optional) Internal port that the Keycloak process binds to inside the container. +KEYCLOAK_INTERNAL_PORT=8080 +KEYCLOAK_INTERNAL_SCHEME=http +KEYCLOAK_INTERNAL_HOST=uce-keycloak-auth +KEYCLOAK_MGMT_PORT=9000 +# +UCE_AUTH_ENABLED=true +# +# UCE runtime Keycloak client config (CommonConfig/common.conf overrides). +# This is what the *UCE backend* uses to validate tokens / perform OIDC flows against Keycloak. +# The `uce-keycloak-config` helper will also apply this secret to the Keycloak client if it is set. +KEYCLOAK_CREDENTIALS_SECRET= +# +# Keycloak bootstrap admin user for the Keycloak container. +# Used for initial Keycloak setup and by the `uce-keycloak-config` helper to log in and patch client settings. +# This is the Keycloak "admin console" user, not an application user. +KC_ADMIN_USERNAME=admin +KC_ADMIN_PW=CHANGE_ME + +# `uce-keycloak-config` target realm + client. +# These identify *which* realm and *which* client in Keycloak should be updated (redirect URIs, web origins, etc.). +# - Realm: a Keycloak "tenant"/namespace (e.g. `uce`) +# - Client: the OIDC client representing the UCE web app (e.g. `uce-web`) +KC_REALM=uce +KC_CLIENT_ID=uce-web +# +# Realm import file (imported on Keycloak container startup) +KC_REALM_IMPORT_PATH=./auth/uce-realm.json +# +# Values: forwarded | xforwarded +KC_PROXY_HEADERS=xforwarded +# +# For debugging to test functionality of expiring sessions. (seconds) (No leading spaces!) +# KC_SSO_SESSION_IDLE_TIMEOUT= +# +# Optional: Realm "Require SSL" setting. Set to `none` to allow plain HTTP logins for local/dev. +# Allowed values: none | external | all +KC_SSL_REQUIRED=none +# +# Unsure if this changes anything: pin the external hostname Keycloak should advertise (otherwise it derives it from requests). +# Set this to your public Keycloak host, e.g. `auth.feedback.core.texttechnologylab.org`. +# KC_HOSTNAME= +# +# ------------------------- +# PostgreSQL +# ------------------------- +# +# Usually not needed: override only if you want a different image/tag or published port. +# POSTGRES_IMAGE=docker.texttechnologylab.org/uce/uce-postgresql:latest +# +# Canonical DB connection primitives used by compose to derive legacy JDBC vars. +DB_HOST=uce-postgresql-db +DB_PORT=5432 +DB_NAME=uce +DB_USER=postgres +DB_PASSWORD= +# +# PostgreSQL custom config file (mounted into the DB container) +POSTGRESQL_CONFIG=./database/postgresql.conf +# +# UCE -> PostgreSQL client (CommonConfig/common.conf overrides) +# Settings UCE requires to communicate with PostgreSQL-DB. +POSTGRESQL_CONNECTION_DRIVER_CLASS=org.postgresql.Driver +POSTGRESQL_DIALECT=org.hibernate.dialect.PostgreSQLDialect +POSTGRESQL_HIBERNATE_CURRENT_SESSION_CONTEXT_CLASS=thread +POSTGRESQL_HIBERNATE_SHOW_SQL=false +POSTGRESQL_HIBERNATE_FORMAT_SQL=true +POSTGRESQL_HIBERNATE_HBM2DDL_AUTO=update +POSTGRESQL_ENRICHMENT_LOCATION_MAX=200 +# +# ------------------------- +# Importer +# ------------------------- +# +# Importer image/tag +# IMPORTER_IMAGE=docker.texttechnologylab.org/uce-importer-deploy:0.0.1 +# +IMPORTER_THREADS=4 +# +# Importer-only mode (profile `import`): +# - Runs only the importer container and connects to an *external* PostgreSQL via `POSTGRESQL_HIBERNATE_CONNECTION_URL`. +# - Start it with: `docker compose --profile import up -d` or just set +# +# Host folder mounted read-only into the importer as `/app/input`. +IMPORTER_CORPORA_HOST_PATH=./.dev/storage/corpora/biofid-mini +# Optional DB folder for importer-mounted SQL helpers. Can be empty. +IMPORTER_DATABASE_HOST_PATH= +# +# Which path inside the container should be treated as the import root (`-src`). +# If you set this, it must point inside the container (default is `/app/input`). +# IMPORTER_IMPORT_SRC=/app/input +# +# Importer instance id (must be `1` currently). +IMPORTER_NUMBER=1 +# +# Optional: CAS view name (`-view`). Leave empty to use the default view. +# IMPORTER_CAS_VIEW= +# +# +# ------------------------- +# MinIO +# ------------------------- +# +# MinIO persistent data directory (only relevant if you run the minio service) +# Only needed if you run the (currently commented) MinIO service in docker-compose. +# MINIO_STORAGE_DATA=./data/minio +# +# UCE -> MinIO client (CommonConfig/common.conf overrides) +# MINIO_ENDPOINT= +# MINIO_USERNAME= +# MINIO_PWD=CHANGE_ME +# MINIO_BUCKET=cas +# +# +# ------------------------- +# Optional: Keycloak container settings +# ------------------------- +# +# Optional: Keycloak container runtime flags (rarely needed). +# KEYCLOAK_START_CMD=start +# KEYCLOAK_FEATURES=scripts +# KEYCLOAK_IMPORT_PATH=/opt/keycloak/data/import/uce-realm.json +# KC_HOSTNAME_STRICT_BACKCHANNEL=true +# KC_HOSTNAME_STRICT=false +# KC_HTTP_RELATIVE_PATH=/ +# KC_HTTP_ENABLED=true +# KC_HEALTH_ENABLED=true +# KC_METRICS_ENABLED=true + +# ------------------------- +# Optional: Create/Add/Edit Keycloak user/groups from CSV +# ------------------------- +# +# One-shot helper that creates/updates Keycloak users/groups from a CSV file. +# Enable it via: +# - `docker compose --profile keycloak-csv up -d` +# +# Host path to your CSV file (read-only mounted into the container). +# KEYCLOAK_SYNC_CSV_PATH_HOST=./principals.csv +# +# Optional toggles: +# KEYCLOAK_SYNC_DRY_RUN=true +# Limit how many CSV rows to process (excluding the header). Useful for DRY_RUN sanity checks. +# KEYCLOAK_SYNC_MAX_ROWS=20 +# KEYCLOAK_SYNC_CREATE_GROUPS=true +# KEYCLOAK_SYNC_CREATE_USERS=true +# KEYCLOAK_SYNC_UPDATE_USERS=true +# +# ------------------------- +# Optional: `uce-keycloak-config` settings +# ------------------------- +# +# Docker image used for the `uce-keycloak-config` helper container (runs the baked-in keycloak config script). +# Build it from `deploy/keycloak-config.Dockerfile` and push it to your registry. +# Usually not needed unless you run/publish the helper as a separate image. +# KEYCLOAK_CONFIG_IMAGE=docker.texttechnologylab.org/uce-core-feedback-keycloak-config:latest +# +# Keycloak base URL from the `uce-keycloak-config` helper's point of view (docker network URL by default). +# Leave empty to use the compose default `http://uce-keycloak-auth:8080`. +# KC_BASE_URL= +# +# Keycloak management URL used for health/readiness checks (docker network URL by default). +# Leave empty to use/derive the compose default `http://uce-keycloak-auth:9000`. +# KC_MGMT_URL= +# +# +# ------------------------- +# Optional: Fuseki / SPARQL +# ------------------------- +# +# Only needed if you enable the commented `uce-fuseki-sparql` service in `docker-compose.yaml` +# or use the tooling in `sparql/*`. +TDB2_DATA=./.dev/storage/tdb/biofid-search +TDB2_ENDPOINT=biofid-search +# Extra JVM flags passed to the Fuseki JVM (only used by `sparql/entrypoint.sh` / optional service). +JAVA_OPTIONS=-Xmx8g + +# PostgreSQL persistent data directory for compose-based local runs. +POSTGRES_DATA_HOST_PATH=./.dev/storage/postgres +# +# +# Used by `sparql/download.sh` for downloading artifacts. +URL= +ARTIFACT_URL= +ARTIFACT_NAME= +CHKSUM_TYPE= +CHKSUMPROG= +CHKSUM_EXT= +# +# Used in `sparql/Dockerfile` as build args; only relevant if you build that image. +FUSEKI_DIR=/fuseki +FUSEKI_JAR= +# KCADM= +# REALM= +# +# ------------------------- +# Other CommonConfig overrides +# ------------------------- +# +UCE_VERSION=1.0.4-debug +LOG_DB=false +SESSION_JOB_INTERVAL=3600 +SYSTEM_JOB_INTERVAL=10 +# +# Local dev paths +TEMPLATES_LOCATION=/app/uce.portal/resources/templates/ +# EXTERNAL_PUBLIC_USE=false +# EXTERNAL_PUBLIC_LOCATION=../uce.web/src/main/resources/public +DATABASE_SCRIPTS_LOCATION=/app/database/ +# +# External integrations +UNIVERSITY_BOTANIK_BASE_URL=https://sammlungen.ub.uni-frankfurt.de/botanik/periodical/titleinfo/{ID} +UNIVERSITY_COLLECTION_BASE_URL=https://sammlungen.ub.uni-frankfurt.de +GBIF_OCCURRENCES_SEARCH_URL=https://api.gbif.org/v1/occurrence/search?limit=10&media_type=stillImage&taxon_key={TAXON_ID} +RAG_WEBSERVER_BASE_URL=http://localhost:5678/ +SPARQL_HOST=http://localhost:3030/ +SPARQL_ENDPOINT=biofid-search/sparql +SPARQL_MAX_ENRICHMENT=100 +SPARQL_CONCURRENT_REQUESTS_MAX=64 diff --git a/.gitignore b/.gitignore index 1f73fc17..a0dd5527 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,18 @@ build/ __pycache__ data/* +.dev/ +.dev/storage/aux/gnfinder-taxon/* +.dev/storage/corpora/biofid-production/input/* +.dev/storage/corpora/biofid-mini/input/* +.dev/storage/tdb/biofid-search/Data-0002/* +.dev/storage/buildkit/* +.dev/storage/corpora/biofid-production-preprocessed/input/* +.dev/tmp/preprocess-test-sample/raw/input/* +.dev/tmp/preprocess-test-sample/pre/input/* +.dev/tmp/preprocess-test-targeted/raw/input/* +.dev/tmp/preprocess-single-10773164/pre/input/* +.dev/storage/corpora/biofid-production-preprocessed-eschar-full-20260323/out/input/* +.dev/storage/corpora/biofid-preprocessed-eschar-4/input/* +.dev/tmp/preprocess-check-one3/pre/input/* +dev/venv/* diff --git a/auth/CORE-feedback-scripts/core_uce_users.sh b/auth/CORE-feedback-scripts/core_uce_users.sh index 65b47f2a..205211e1 100644 --- a/auth/CORE-feedback-scripts/core_uce_users.sh +++ b/auth/CORE-feedback-scripts/core_uce_users.sh @@ -3,13 +3,13 @@ set -euo pipefail # --- CONFIGURATION --- -KEYCLOAK_URL="${KEYCLOAK_URL:-http://localhost:8080/auth}" # adjust if needed +KEYCLOAK_URL="${KEYCLOAK_AUTH_SERVER_URL:-${UCE_AUTH_PUBLIC_URL:-${KEYCLOAK_URL:-http://localhost:8080}}}" ADMIN_REALM="${ADMIN_REALM:-master}" -ADMIN_USER="${ADMIN_USER:-admin}" -ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin}" -REALM="uce" +ADMIN_USER="${KC_ADMIN_USERNAME:-${ADMIN_USER:-admin}}" +ADMIN_PASSWORD="${KC_ADMIN_PW:-${ADMIN_PASSWORD:-admin}}" +REALM="${KEYCLOAK_REALM:-${KC_REALM:-uce}}" GROUP_NAME="CORE-Proband" -CLIENT_ID="uce-web" # only for building login URL +CLIENT_ID="${KEYCLOAK_CLIENT:-${KC_CLIENT_ID:-uce-web}}" # only for building login URL INPUT_FILE="${1:-}" OUTPUT_FILE="${2:-provisioned-users.csv}" @@ -56,8 +56,9 @@ echo "Using group '$GROUP_NAME' (id=$GROUP_ID)" echo "hash,username,userId,tempPassword,loginUrl" > "$OUTPUT_FILE" -LOGIN_BASE="${KEYCLOAK_URL%/auth}/realms/${REALM}/protocol/openid-connect/auth" -REDIRECT_URI="${REDIRECT_URI:-https://your-app.example/uce/callback}" +KEYCLOAK_BASE="$(echo "${KEYCLOAK_URL}" | sed -E 's#/*$##; s#/auth$##')" +LOGIN_BASE="${KEYCLOAK_BASE}/realms/${REALM}/protocol/openid-connect/auth" +REDIRECT_URI="${UCE_AUTH_REDIRECT_URL:-${REDIRECT_URI:-https://your-app.example/uce/callback}}" # --- MAIN LOOP --- diff --git a/auth/CORE-feedback-scripts/run.sh b/auth/CORE-feedback-scripts/run.sh index 8130ca83..874b9fb6 100644 --- a/auth/CORE-feedback-scripts/run.sh +++ b/auth/CORE-feedback-scripts/run.sh @@ -1,7 +1,3 @@ export KCADM="docker exec -i uce-keycloak-auth /opt/keycloak/bin/kcadm.sh" -export KEYCLOAK_URL="http://localhost:8080/auth" -export ADMIN_REALM="master" -export ADMIN_USER="$KC_ADMIN_USERNAME" -export ADMIN_PASSWORD="$KC_ADMIN_PW" -./core_uce_users.sh hashes.txt provisioned-users.csv \ No newline at end of file +./core_uce_users.sh hashes.txt provisioned-users.csv diff --git a/database/13_corpusTopicDist.sql b/database/13_corpusTopicDist.sql index 2baf940f..d5140ead 100644 --- a/database/13_corpusTopicDist.sql +++ b/database/13_corpusTopicDist.sql @@ -1,5 +1,7 @@ +DROP FUNCTION IF EXISTS get_normalized_topic_scores(BIGINT, text, integer); + CREATE OR REPLACE FUNCTION get_normalized_topic_scores( - corpusid BIGINT, + p_corpusid BIGINT, p_user_name text DEFAULT NULL, p_min_level integer DEFAULT 1 ) @@ -16,8 +18,8 @@ FROM ( FROM documenttopicsraw WHERE document_id IN ( SELECT id - FROM permitted_documents(get_normalized_topic_scores.p_user_name, get_normalized_topic_scores.p_min_level) - WHERE corpusid = get_normalized_topic_scores.corpusid + FROM permitted_documents(get_normalized_topic_scores.p_user_name, get_normalized_topic_scores.p_min_level) pd + WHERE pd.corpusid = get_normalized_topic_scores.p_corpusid ) GROUP BY topiclabel ) subquery diff --git a/database/15_safe_tsquery_functions.sql b/database/15_safe_tsquery_functions.sql new file mode 100644 index 00000000..a60cf7a4 --- /dev/null +++ b/database/15_safe_tsquery_functions.sql @@ -0,0 +1,242 @@ +-- DISABLED: Safe tsquery construction functions for robust full-text search +-- Prevents tsquery syntax errors and handles large term expansions gracefully + +-- Function: safe_to_tsquery +-- Creates a tsquery from an array of terms with safety limits and error handling +-- DISABLED: Use baseline PostgreSQL tsquery functions instead +/* +CREATE OR REPLACE FUNCTION safe_to_tsquery( + config regconfig DEFAULT 'simple', + terms text[] DEFAULT NULL, + max_terms integer DEFAULT 100, + max_term_length integer DEFAULT 100, + max_total_length integer DEFAULT 100000 +) RETURNS tsquery AS $$ +DECLARE + safe_terms text[]; + term text; + query_text text; + term_count integer; +BEGIN + -- Return NULL if no terms + IF terms IS NULL OR array_length(terms, 1) = 0 THEN + RETURN NULL; + END IF; + + -- Limit number of terms + term_count := LEAST(array_length(terms, 1), max_terms); + safe_terms := terms[1:term_count]; + + -- Process each term: trim, limit length, escape + FOR i IN 1..array_length(safe_terms, 1) LOOP + term := safe_terms[i]; + + -- Trim whitespace + term := trim(term); + + -- Skip empty terms + IF term = '' THEN + safe_terms[i] := NULL; + CONTINUE; + END IF; + + -- Limit term length + IF length(term) > max_term_length THEN + term := substring(term from 1 for max_term_length); + END IF; + + -- Escape special tsquery characters: !, &, |, *, :, (, ), <, > + -- PostgreSQL tsquery expects single quotes around terms with special chars + term := replace(term, '''', ''''''); -- Escape single quotes + term := replace(term, '\', '\\'); -- Escape backslashes + + -- Check if term needs quoting (contains special characters or spaces) + IF term ~ '[!&|*:()<>''\s]' THEN + term := '''' || term || ''''; + END IF; + + safe_terms[i] := term; + END LOOP; + + -- Remove NULL terms + safe_terms := array_remove(safe_terms, NULL); + + -- Check if we have any terms left + IF array_length(safe_terms, 1) = 0 THEN + RETURN NULL; + END IF; + + -- Build query text with OR operator + query_text := array_to_string(safe_terms, ' | '); + + -- Check total length + IF length(query_text) > max_total_length THEN + RAISE WARNING 'tsquery too long (% bytes), truncating to % bytes', + length(query_text), max_total_length; + query_text := substring(query_text from 1 for max_total_length); + END IF; + + -- Attempt to create tsquery + BEGIN + RETURN to_tsquery(config, query_text); + EXCEPTION + WHEN OTHERS THEN + -- Log error and fallback to simple search with first few terms + RAISE WARNING 'Failed to create tsquery: % (query: %)', SQLERRM, query_text; + + -- Try with just the first 10 terms + IF array_length(safe_terms, 1) > 10 THEN + query_text := array_to_string(safe_terms[1:10], ' | '); + BEGIN + RETURN to_tsquery(config, query_text); + EXCEPTION + WHEN OTHERS THEN + -- Ultimate fallback: empty query + RETURN to_tsquery(config, ''); + END; + ELSE + -- Ultimate fallback: empty query + RETURN to_tsquery(config, ''); + END IF; + END; +END; +$$ LANGUAGE plpgsql IMMUTABLE STRICT; + +-- Function: safe_websearch_to_tsquery +-- Wrapper for websearch_to_tsquery with safety limits +CREATE OR REPLACE FUNCTION safe_websearch_to_tsquery( + config regconfig DEFAULT 'simple', + query_text text DEFAULT NULL, + max_length integer DEFAULT 100000 +) RETURNS tsquery AS $$ +BEGIN + -- Return NULL if no query + IF query_text IS NULL OR trim(query_text) = '' THEN + RETURN NULL; + END IF; + + -- Limit query length + IF length(query_text) > max_length THEN + RAISE WARNING 'websearch query too long (% bytes), truncating to % bytes', + length(query_text), max_length; + query_text := substring(query_text from 1 for max_length); + END IF; + + -- Attempt to create tsquery + BEGIN + RETURN websearch_to_tsquery(config, query_text); + EXCEPTION + WHEN OTHERS THEN + -- Log error and fallback + RAISE WARNING 'Failed to create websearch tsquery: % (query: %)', SQLERRM, query_text; + + -- Try with simplified query (remove problematic characters) + query_text := regexp_replace(query_text, '[!&|*:()<>''"]', ' ', 'g'); + query_text := regexp_replace(query_text, '\s+', ' ', 'g'); + query_text := trim(query_text); + + IF query_text = '' THEN + RETURN to_tsquery(config, ''); + END IF; + + BEGIN + RETURN websearch_to_tsquery(config, query_text); + EXCEPTION + WHEN OTHERS THEN + -- Ultimate fallback: empty query + RETURN to_tsquery(config, ''); + END; + END; +END; +$$ LANGUAGE plpgsql IMMUTABLE STRICT; + +-- Function: build_safe_search_query +-- Builds a search query from expanded terms with proper handling for pro mode +CREATE OR REPLACE FUNCTION build_safe_search_query( + config regconfig DEFAULT 'simple', + expanded_terms text[] DEFAULT NULL, + original_query text DEFAULT NULL, + pro_mode boolean DEFAULT false, + max_terms integer DEFAULT 100 +) RETURNS tsquery AS $$ +DECLARE + effective_terms text[]; + query_text text; +BEGIN + -- Use expanded terms if available, otherwise use original query + IF expanded_terms IS NOT NULL AND array_length(expanded_terms, 1) > 0 THEN + -- For pro mode with expanded terms, we need to handle differently + IF pro_mode THEN + -- In pro mode with expansions, we need to preserve the original query structure + -- but replace terms with their expansions + IF original_query IS NOT NULL AND original_query != '' THEN + -- This is complex - for now, use safe_to_tsquery with OR logic + -- TODO: Implement proper AST-based expansion for pro mode + RETURN safe_to_tsquery(config, expanded_terms, max_terms); + ELSE + -- No original query structure, use OR logic + RETURN safe_to_tsquery(config, expanded_terms, max_terms); + END IF; + ELSE + -- Non-pro mode: use websearch_to_tsquery for robustness + query_text := array_to_string(expanded_terms, ' OR '); + RETURN safe_websearch_to_tsquery(config, query_text); + END IF; + ELSE + -- No expanded terms, use original query + IF pro_mode THEN + -- Pro mode: use to_tsquery (supports operators) + BEGIN + RETURN to_tsquery(config, original_query); + EXCEPTION + WHEN OTHERS THEN + -- Fallback to websearch for malformed pro mode queries + RAISE WARNING 'Pro mode query failed, falling back to websearch: %', SQLERRM; + RETURN safe_websearch_to_tsquery(config, original_query); + END; + ELSE + -- Non-pro mode: use websearch_to_tsquery + RETURN safe_websearch_to_tsquery(config, original_query); + END IF; + END IF; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Function: chunk_array +-- Utility function to split array into chunks +CREATE OR REPLACE FUNCTION chunk_array( + arr text[], + chunk_size integer +) RETURNS text[][] AS $$ +DECLARE + chunks text[][]; + num_chunks integer; + i integer; +BEGIN + IF arr IS NULL OR array_length(arr, 1) IS NULL THEN + RETURN ARRAY[]::text[][]; + END IF; + + num_chunks := ceil(array_length(arr, 1)::float / chunk_size); + chunks := array_fill(NULL::text[], ARRAY[num_chunks]); + + FOR i IN 1..num_chunks LOOP + chunks[i] := arr[((i-1)*chunk_size + 1):LEAST(i*chunk_size, array_length(arr, 1))]; + END LOOP; + + RETURN chunks; +END; +$$ LANGUAGE plpgsql IMMUTABLE; + +-- Test the functions +COMMENT ON FUNCTION safe_to_tsquery(regconfig, text[], integer, integer, integer) IS + 'Creates a tsquery from terms with safety limits and error handling'; + +COMMENT ON FUNCTION safe_websearch_to_tsquery(regconfig, text, integer) IS + 'Wrapper for websearch_to_tsquery with safety limits'; + +COMMENT ON FUNCTION build_safe_search_query(regconfig, text[], text, boolean, integer) IS + 'Builds search query from expanded terms with pro mode support'; + +COMMENT ON FUNCTION chunk_array(text[], integer) IS + 'Splits text array into chunks of specified size'; \ No newline at end of file diff --git a/database/16_enhanced_search_function.sql b/database/16_enhanced_search_function.sql new file mode 100644 index 00000000..30aeadb1 --- /dev/null +++ b/database/16_enhanced_search_function.sql @@ -0,0 +1,403 @@ +-- DISABLED: Enhanced search function with robust tsquery handling and two-phase search +-- Replaces or enhances uce_search_layer_fulltext with better error handling + +-- Function: uce_search_layer_fulltext_enhanced +-- Enhanced version with safe tsquery construction and query decomposition +-- DISABLED: Use baseline uce_search_layer_fulltext instead +/* +CREATE OR REPLACE FUNCTION uce_search_layer_fulltext_enhanced( + corpus_id bigint, + input1 text[], + input2 text, + take_count integer, + offset_count integer, + count_all boolean DEFAULT false, + order_direction text DEFAULT 'DESC', + order_by_column text DEFAULT 'rank', + uce_metadata_filters jsonb DEFAULT NULL, + useTsVector boolean DEFAULT true, + source_table text DEFAULT 'page', + schema_name text DEFAULT 'public', + p_user_name text DEFAULT NULL, + p_min_level integer DEFAULT 1, + expanded_terms text[] DEFAULT NULL, + -- Configuration parameters for enhanced features + max_terms_per_query integer DEFAULT 50, + enable_two_phase boolean DEFAULT true, + two_phase_threshold integer DEFAULT 100 +) RETURNS TABLE( + total_count_out integer, + document_ids integer[], + document_ranks double precision[], + named_entities_found text[], + time_found text[], + taxons_found text[], + snippets_found text[] +) AS $$ +DECLARE + query TEXT; + total_count_temp integer; + document_ids_temp integer[]; + document_ranks_temp float[]; + named_entities_temp text[][]; + time_temp text[][]; + taxons_temp text[][]; + snippets_temp text[]; + additional_join_1 TEXT := ''; + additional_join_2 TEXT := ''; + order_by_clause TEXT := ''; + ts_function TEXT; + ts_condition TEXT; + snippet_query TEXT; + ranked_pages_cte TEXT; + + -- Enhanced variables + term_chunks text[][]; + chunk_results RECORD[]; + all_results RECORD[]; + i integer; + phase1_terms text[]; + phase1_results integer; + final_tsquery tsquery; +BEGIN + -- Ensure PostgreSQL uses indexes and has enough memory + SET work_mem = '256MB'; + + -- If input2 is NULL or empty, disable TsVector search + IF input2 IS NULL OR input2 = '' THEN + useTsVector := false; + END IF; + + -- Validate the order direction + IF order_direction NOT IN ('ASC', 'DESC') THEN + RAISE EXCEPTION 'Invalid order_direction: %', order_direction; + END IF; + + -- Construct ORDER BY clause dynamically + IF order_by_column = 'rank' THEN + order_by_clause := FORMAT('ORDER BY pr.rank %s', order_direction); + ELSIF order_by_column = 'documenttitle' THEN + order_by_clause := FORMAT('ORDER BY pr.documenttitle %s', order_direction); + ELSE + order_by_clause := 'ORDER BY pr.rank DESC'; -- Default ordering + END IF; + + -- ENHANCED: Build safe tsquery using our new functions + final_tsquery := build_safe_search_query( + 'simple'::regconfig, + expanded_terms, + input2, + useTsVector, -- useTsVector indicates pro mode + max_terms_per_query + ); + + -- ENHANCED: Two-phase search for large expansions + IF enable_two_phase + AND expanded_terms IS NOT NULL + AND array_length(expanded_terms, 1) > two_phase_threshold THEN + + -- Phase 1: Use first max_terms_per_query terms for quick results + phase1_terms := expanded_terms[1:max_terms_per_query]; + + -- Execute phase 1 search + RETURN QUERY + SELECT * FROM uce_search_layer_fulltext_enhanced( + corpus_id, input1, input2, take_count, offset_count, + count_all, order_direction, order_by_column, + uce_metadata_filters, useTsVector, source_table, + schema_name, p_user_name, p_min_level, + phase1_terms, -- Use limited terms for phase 1 + max_terms_per_query, false, two_phase_threshold -- Disable two-phase in recursive call + ); + + -- Check if we need phase 2 (not enough results) + GET DIAGNOSTICS phase1_results = ROW_COUNT; + + IF phase1_results < take_count AND count_all THEN + -- Phase 2: Execute additional queries for remaining terms + -- Split remaining terms into chunks + term_chunks := chunk_array(expanded_terms[(max_terms_per_query + 1):], max_terms_per_query); + + FOR i IN 1..array_length(term_chunks, 1) LOOP + -- Execute search for this chunk + BEGIN + RETURN QUERY + SELECT * FROM uce_search_layer_fulltext_enhanced( + corpus_id, input1, input2, + take_count - phase1_results, 0, -- Adjust take/offset + count_all, order_direction, order_by_column, + uce_metadata_filters, useTsVector, source_table, + schema_name, p_user_name, p_min_level, + term_chunks[i], + max_terms_per_query, false, two_phase_threshold + ); + + -- Update result count + GET DIAGNOSTICS phase1_results = phase1_results + ROW_COUNT; + + -- Break if we have enough results + EXIT WHEN phase1_results >= take_count; + EXCEPTION + WHEN OTHERS THEN + -- Log error but continue with other chunks + RAISE WARNING 'Error in phase 2 chunk %: %', i, SQLERRM; + CONTINUE; + END; + END LOOP; + END IF; + + -- Return results collected so far + RETURN; + END IF; + + -- Determine the appropriate search function for ts_function string + -- (Used in dynamic SQL construction) + IF final_tsquery IS NULL THEN + ts_function := 'NULL'; + ELSE + ts_function := quote_literal(final_tsquery::text); + END IF; + + -- Check source table + IF source_table != 'page' THEN + additional_join_1 := FORMAT('INNER JOIN %I.%I t ON um.document_id = t.document_id', schema_name, source_table); + additional_join_2 := FORMAT('INNER JOIN %I.%I t ON p.id = t.id', schema_name, source_table); + END IF; + + -- Define snippet selection logic + IF input2 IS NULL OR input2 = '' THEN + snippet_query := 'SELECT jsonb_agg(jsonb_build_object( + ''snippet'', LEFT(p.coveredtext, 400), + ''pageId'', p.id + ) ORDER BY p.id ASC) + FROM (SELECT p.id, p.coveredtext + FROM page p + WHERE p.document_id = lp.doc_id + ORDER BY p.id ASC + LIMIT 1) p'; + + -- If we don't search for any string, we don't need to fulltext search all documents + ranked_pages_cte := 'WITH limited_docs AS NOT MATERIALIZED ( + SELECT id, documenttitle FROM permitted_documents($13, $14) pd + WHERE pd.corpusid = $2 + LIMIT 999 + ) + SELECT + p.document_id AS doc_id, + 0 AS rank, + d.documenttitle + FROM limited_docs d + JOIN page p ON d.id = p.document_id + %s + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches))'; + ELSE + snippet_query := FORMAT('SELECT jsonb_agg(jsonb_build_object( + ''snippet'', ts_headline( + ''simple'', + p.coveredtext, + %s, + ''StartSel=, StopSel=, MaxWords=60, MinWords=35, MaxFragments=3, FragmentDelimiter=" [...] "'' + ), + ''pageId'', p.id + ) ORDER BY rank_score DESC) + FROM (SELECT p.id, p.coveredtext, ts_rank_cd(p.textsearch, %s) AS rank_score + FROM page p + WHERE p.document_id = lp.doc_id + AND p.textsearch @@ %s + ORDER BY rank_score DESC + LIMIT 5) p', + ts_function, ts_function, ts_function); + + ranked_pages_cte := FORMAT('SELECT + p.document_id AS doc_id, + ts_rank_cd(p.textsearch, %s) AS rank, + d.documenttitle + FROM page p + %s + JOIN permitted_documents($13, $14) d ON d.id = p.document_id + WHERE ( + (%s IS NOT NULL AND p.textsearch @@ %s) + OR (%s IS NULL) + ) + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches)) + AND d.corpusid = $2 + LIMIT 20000', + ts_function, additional_join_2, ts_function, ts_function, ts_function); + END IF; + + -- Construct the full query dynamically + query := FORMAT(' + WITH expanded_filters AS NOT MATERIALIZED ( + SELECT + (filter->>''key'')::text AS key, + (filter->>''value'')::text AS value, + (filter->>''min'')::decimal AS min, + (filter->>''max'')::decimal AS max, + (filter->>''valueType'')::int AS value_type + FROM jsonb_array_elements($1) AS filter + ), + filtered_ucemetadata AS ( + SELECT document_id, key, value, valueType + FROM ucemetadata + WHERE valueType != 2 + ), + filter_matches AS NOT MATERIALIZED ( + SELECT um.document_id + FROM expanded_filters ef + JOIN filtered_ucemetadata um ON + (ef.value_type IS NULL OR um.valueType = ef.value_type) + AND (ef.key IS NULL OR um.key = ef.key) + AND ( + ef.value IS NULL OR um.value = ef.value + OR ( + -- range search for NUMBER meta fields + ef.value_type = 1 + AND ( + (ef.min IS NULL OR um.value::decimal >= ef.min) + AND + (ef.max IS NULL OR um.value::decimal <= ef.max) + ) + ) + ) + JOIN permitted_documents($13, $14) d ON um.document_id = d.id AND d.corpusid = $2 + %s + GROUP BY um.document_id + HAVING COUNT(ef.key) = (SELECT COUNT(*) FROM expanded_filters) + ), + ranked_pages AS NOT MATERIALIZED ( %s ), + page_ranked AS NOT MATERIALIZED ( + SELECT + doc_id, + AVG(rank) AS rank, -- Get highest rank per document + documenttitle + FROM ranked_pages + GROUP BY doc_id, documenttitle + ), + limited_pages AS NOT MATERIALIZED ( + SELECT + pr.doc_id, + pr.rank, + pr.documenttitle + FROM page_ranked pr + %s + LIMIT $6 OFFSET $7 + ), + counted_documents AS NOT MATERIALIZED ( + SELECT COUNT(*) AS total_count FROM page_ranked + ), + ranked_documents AS NOT MATERIALIZED ( + SELECT + d.id, + lp.rank, + JSONB_AGG((%s)) AS snippets + FROM limited_pages lp + JOIN permitted_documents($13, $14) d ON d.id = lp.doc_id + GROUP BY d.id, lp.rank + ORDER BY lp.rank DESC + ), + extracted_entities AS NOT MATERIALIZED ( + SELECT ARRAY[ne.id::text, ne.coveredtext, COUNT(ne.id)::text, ne.typee, ne.document_id::text] + FROM ranked_documents rd + JOIN namedentity ne ON rd.id = ne.document_id + GROUP BY ne.id, ne.coveredtext, ne.typee, ne.document_id + ), + extracted_times AS NOT MATERIALIZED ( + SELECT ARRAY[t.id::text, t.coveredtext, COUNT(t.id)::text, t.valuee, t.document_id::text] + FROM ranked_documents rd + JOIN time t ON rd.id = t.document_id + GROUP BY t.id, t.coveredtext, t.valuee, t.document_id + ), + extracted_taxons AS NOT MATERIALIZED ( + SELECT ARRAY[ta.id::text, ta.coveredtext, COUNT(ta.id)::text, ta.primaryname, ta.document_id::text] + FROM ranked_documents rd + JOIN biofidtaxon ta ON rd.id = ta.document_id + GROUP BY ta.id, ta.coveredtext, ta.primaryname, ta.document_id + ) + SELECT + CASE WHEN $8 THEN (SELECT total_count FROM counted_documents) ELSE NULL END, + ARRAY(SELECT id FROM ranked_documents), + ARRAY(SELECT rank FROM ranked_documents), + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_entities) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_times) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_taxons) ELSE ARRAY[]::text[][] END, + ARRAY(SELECT snippets FROM ranked_documents) + ', + additional_join_1, + ranked_pages_cte, + order_by_clause, + snippet_query + ); + + -- Execute the query with proper parameter binding + RETURN QUERY EXECUTE query + USING uce_metadata_filters, corpus_id, input1, input2, + uce_metadata_filters, take_count, offset_count, count_all, + order_direction, order_by_column, uce_metadata_filters, + useTsVector, source_table, schema_name, p_user_name, p_min_level; + +EXCEPTION + WHEN OTHERS THEN + -- Enhanced error handling with fallback + RAISE WARNING 'Enhanced search failed: %, falling back to simple search', SQLERRM; + + -- Fallback to simple search without expansions + RETURN QUERY + SELECT * FROM uce_search_layer_fulltext( + corpus_id, input1, input2, take_count, offset_count, + count_all, order_direction, order_by_column, + uce_metadata_filters, useTsVector, source_table, + schema_name, p_user_name, p_min_level, + NULL::text[] -- No expanded terms + ); +END; +$$ LANGUAGE plpgsql; + +-- Function: uce_search_layer_fulltext_wrapper +-- Wrapper that uses enhanced function but maintains original interface +CREATE OR REPLACE FUNCTION uce_search_layer_fulltext_wrapper( + corpus_id bigint, + input1 text[], + input2 text, + take_count integer, + offset_count integer, + count_all boolean DEFAULT false, + order_direction text DEFAULT 'DESC', + order_by_column text DEFAULT 'rank', + uce_metadata_filters jsonb DEFAULT NULL, + useTsVector boolean DEFAULT true, + source_table text DEFAULT 'page', + schema_name text DEFAULT 'public', + p_user_name text DEFAULT NULL, + p_min_level integer DEFAULT 1, + expanded_terms text[] DEFAULT NULL +) RETURNS TABLE( + total_count_out integer, + document_ids integer[], + document_ranks double precision[], + named_entities_found text[], + time_found text[], + taxons_found text[], + snippets_found text[] +) AS $$ +BEGIN + -- Use enhanced function with default configuration + RETURN QUERY + SELECT * FROM uce_search_layer_fulltext_enhanced( + corpus_id, input1, input2, take_count, offset_count, + count_all, order_direction, order_by_column, + uce_metadata_filters, useTsVector, source_table, + schema_name, p_user_name, p_min_level, + expanded_terms, + 50, -- max_terms_per_query + true, -- enable_two_phase + 100 -- two_phase_threshold + ); +END; +$$ LANGUAGE plpgsql; + +-- Test the enhanced function +COMMENT ON FUNCTION uce_search_layer_fulltext_enhanced IS + 'Enhanced search function with safe tsquery construction and two-phase search'; + +COMMENT ON FUNCTION uce_search_layer_fulltext_wrapper IS + 'Wrapper that maintains original interface but uses enhanced implementation'; \ No newline at end of file diff --git a/database/16_enhanced_search_function_simple.sql b/database/16_enhanced_search_function_simple.sql new file mode 100644 index 00000000..49640b39 --- /dev/null +++ b/database/16_enhanced_search_function_simple.sql @@ -0,0 +1,309 @@ +-- DISABLED: Simplified enhanced search function with robust tsquery handling +-- Focuses on fixing the immediate issues without complex two-phase logic + +-- Function: uce_search_layer_fulltext_safe +-- Safe version that uses our safe tsquery functions +-- DISABLED: Use baseline uce_search_layer_fulltext instead +/* +CREATE OR REPLACE FUNCTION uce_search_layer_fulltext_safe( + corpus_id bigint, + input1 text[], + input2 text, + take_count integer, + offset_count integer, + count_all boolean DEFAULT false, + order_direction text DEFAULT 'DESC', + order_by_column text DEFAULT 'rank', + uce_metadata_filters jsonb DEFAULT NULL, + useTsVector boolean DEFAULT true, + source_table text DEFAULT 'page', + schema_name text DEFAULT 'public', + p_user_name text DEFAULT NULL, + p_min_level integer DEFAULT 1, + expanded_terms text[] DEFAULT NULL +) RETURNS TABLE( + total_count_out integer, + document_ids integer[], + document_ranks double precision[], + named_entities_found text[], + time_found text[], + taxons_found text[], + snippets_found text[] +) AS $$ +DECLARE + query TEXT; + total_count_temp integer; + document_ids_temp integer[]; + document_ranks_temp float[]; + named_entities_temp text[][]; + time_temp text[][]; + taxons_temp text[][]; + snippets_temp text[]; + additional_join_1 TEXT := ''; + additional_join_2 TEXT := ''; + order_by_clause TEXT := ''; + ts_function TEXT; + ts_condition TEXT; + snippet_query TEXT; + ranked_pages_cte TEXT; + + -- Safe tsquery variables + safe_tsquery tsquery; + tsquery_text text; +BEGIN + -- Ensure PostgreSQL uses indexes and has enough memory + SET work_mem = '256MB'; + + -- If input2 is NULL or empty, disable TsVector search + IF input2 IS NULL OR input2 = '' THEN + useTsVector := false; + END IF; + + -- Validate the order direction + IF order_direction NOT IN ('ASC', 'DESC') THEN + RAISE EXCEPTION 'Invalid order_direction: %', order_direction; + END IF; + + -- Construct ORDER BY clause dynamically + IF order_by_column = 'rank' THEN + order_by_clause := FORMAT('ORDER BY pr.rank %s', order_direction); + ELSIF order_by_column = 'documenttitle' THEN + order_by_clause := FORMAT('ORDER BY pr.documenttitle %s', order_direction); + ELSE + order_by_clause := 'ORDER BY pr.rank DESC'; -- Default ordering + END IF; + + -- SAFE TSQUERY CONSTRUCTION: Use our safe functions + IF expanded_terms IS NOT NULL AND array_length(expanded_terms, 1) > 0 THEN + -- Use expanded terms with safe construction + IF useTsVector THEN + -- Pro mode with expanded terms + safe_tsquery := safe_to_tsquery('simple', expanded_terms, 100); + ELSE + -- Non-pro mode with expanded terms + tsquery_text := array_to_string(expanded_terms, ' OR '); + safe_tsquery := safe_websearch_to_tsquery('simple', tsquery_text); + END IF; + ELSE + -- Use original query + IF useTsVector THEN + -- Pro mode + BEGIN + safe_tsquery := to_tsquery('simple', input2); + EXCEPTION + WHEN OTHERS THEN + -- Fallback to websearch for malformed pro mode queries + RAISE WARNING 'Pro mode query failed, falling back to websearch: %', SQLERRM; + safe_tsquery := safe_websearch_to_tsquery('simple', input2); + END; + ELSE + -- Non-pro mode + safe_tsquery := safe_websearch_to_tsquery('simple', input2); + END IF; + END IF; + + -- Convert tsquery to text for dynamic SQL + IF safe_tsquery IS NULL THEN + ts_function := 'NULL'; + ts_condition := 'FALSE'; + ELSE + ts_function := quote_literal(safe_tsquery::text); + ts_condition := FORMAT('p.textsearch @@ %s', ts_function); + END IF; + + -- Check source table + IF source_table != 'page' THEN + additional_join_1 := FORMAT('INNER JOIN %I.%I t ON um.document_id = t.document_id', schema_name, source_table); + additional_join_2 := FORMAT('INNER JOIN %I.%I t ON p.id = t.id', schema_name, source_table); + END IF; + + -- Define snippet selection logic + IF input2 IS NULL OR input2 = '' OR safe_tsquery IS NULL THEN + snippet_query := 'SELECT jsonb_agg(jsonb_build_object( + ''snippet'', LEFT(p.coveredtext, 400), + ''pageId'', p.id + ) ORDER BY p.id ASC) + FROM (SELECT p.id, p.coveredtext + FROM page p + WHERE p.document_id = lp.doc_id + ORDER BY p.id ASC + LIMIT 1) p'; + + -- If we don't search for any string, we don't need to fulltext search all documents + ranked_pages_cte := FORMAT('WITH limited_docs AS NOT MATERIALIZED ( + SELECT id, documenttitle FROM permitted_documents($13, $14) pd + WHERE pd.corpusid = $2 + LIMIT 999 + ) + SELECT + p.document_id AS doc_id, + 0 AS rank, + d.documenttitle + FROM limited_docs d + JOIN page p ON d.id = p.document_id + %s + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches))', + additional_join_2); + ELSE + snippet_query := FORMAT('SELECT jsonb_agg(jsonb_build_object( + ''snippet'', ts_headline( + ''simple'', + p.coveredtext, + %s, + ''StartSel=, StopSel=, MaxWords=60, MinWords=35, MaxFragments=3, FragmentDelimiter=" [...] "'' + ), + ''pageId'', p.id + ) ORDER BY rank_score DESC) + FROM (SELECT p.id, p.coveredtext, ts_rank_cd(p.textsearch, %s) AS rank_score + FROM page p + WHERE p.document_id = lp.doc_id + AND %s + ORDER BY rank_score DESC + LIMIT 5) p', + ts_function, ts_function, ts_condition); + + ranked_pages_cte := FORMAT('SELECT + p.document_id AS doc_id, + ts_rank_cd(p.textsearch, %s) AS rank, + d.documenttitle + FROM page p + %s + JOIN permitted_documents($13, $14) d ON d.id = p.document_id + WHERE %s + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches)) + AND d.corpusid = $2 + LIMIT 20000', + ts_function, additional_join_2, ts_condition); + END IF; + + -- Construct the full query dynamically + query := FORMAT(' + WITH expanded_filters AS NOT MATERIALIZED ( + SELECT + (filter->>''key'')::text AS key, + (filter->>''value'')::text AS value, + (filter->>''min'')::decimal AS min, + (filter->>''max'')::decimal AS max, + (filter->>''valueType'')::int AS value_type + FROM jsonb_array_elements($1) AS filter + ), + filtered_ucemetadata AS ( + SELECT document_id, key, value, valueType + FROM ucemetadata + WHERE valueType != 2 + ), + filter_matches AS NOT MATERIALIZED ( + SELECT um.document_id + FROM expanded_filters ef + JOIN filtered_ucemetadata um ON + (ef.value_type IS NULL OR um.valueType = ef.value_type) + AND (ef.key IS NULL OR um.key = ef.key) + AND ( + ef.value IS NULL OR um.value = ef.value + OR ( + -- range search for NUMBER meta fields + ef.value_type = 1 + AND ( + (ef.min IS NULL OR um.value::decimal >= ef.min) + AND + (ef.max IS NULL OR um.value::decimal <= ef.max) + ) + ) + ) + JOIN permitted_documents($13, $14) d ON um.document_id = d.id AND d.corpusid = $2 + %s + GROUP BY um.document_id + HAVING COUNT(ef.key) = (SELECT COUNT(*) FROM expanded_filters) + ), + ranked_pages AS NOT MATERIALIZED ( %s ), + page_ranked AS NOT MATERIALIZED ( + SELECT + doc_id, + AVG(rank) AS rank, -- Get highest rank per document + documenttitle + FROM ranked_pages + GROUP BY doc_id, documenttitle + ), + limited_pages AS NOT MATERIALIZED ( + SELECT + pr.doc_id, + pr.rank, + pr.documenttitle + FROM page_ranked pr + %s + LIMIT $6 OFFSET $7 + ), + counted_documents AS NOT MATERIALIZED ( + SELECT COUNT(*) AS total_count FROM page_ranked + ), + ranked_documents AS NOT MATERIALIZED ( + SELECT + d.id, + lp.rank, + JSONB_AGG((%s)) AS snippets + FROM limited_pages lp + JOIN permitted_documents($13, $14) d ON d.id = lp.doc_id + GROUP BY d.id, lp.rank + ORDER BY lp.rank DESC + ), + extracted_entities AS NOT MATERIALIZED ( + SELECT ARRAY[ne.id::text, ne.coveredtext, COUNT(ne.id)::text, ne.typee, ne.document_id::text] + FROM ranked_documents rd + JOIN namedentity ne ON rd.id = ne.document_id + GROUP BY ne.id, ne.coveredtext, ne.typee, ne.document_id + ), + extracted_times AS NOT MATERIALIZED ( + SELECT ARRAY[t.id::text, t.coveredtext, COUNT(t.id)::text, t.valuee, t.document_id::text] + FROM ranked_documents rd + JOIN time t ON rd.id = t.document_id + GROUP BY t.id, t.coveredtext, t.valuee, t.document_id + ), + extracted_taxons AS NOT MATERIALIZED ( + SELECT ARRAY[ta.id::text, ta.coveredtext, COUNT(ta.id)::text, ta.primaryname, ta.document_id::text] + FROM ranked_documents rd + JOIN biofidtaxon ta ON rd.id = ta.document_id + GROUP BY ta.id, ta.coveredtext, ta.primaryname, ta.document_id + ) + SELECT + CASE WHEN $8 THEN (SELECT total_count FROM counted_documents) ELSE NULL END, + ARRAY(SELECT id FROM ranked_documents), + ARRAY(SELECT rank FROM ranked_documents), + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_entities) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_times) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_taxons) ELSE ARRAY[]::text[][] END, + ARRAY(SELECT snippets FROM ranked_documents) + ', + additional_join_1, + ranked_pages_cte, + order_by_clause, + snippet_query + ); + + -- Execute the query with proper parameter binding + -- Note: The original function uses $13 for p_user_name and $14 for p_min_level + RETURN QUERY EXECUTE query + USING uce_metadata_filters, corpus_id, input1, input2, + uce_metadata_filters, take_count, offset_count, count_all, + order_direction, order_by_column, uce_metadata_filters, + useTsVector, source_table, schema_name, p_user_name, p_min_level; + +EXCEPTION + WHEN OTHERS THEN + -- Enhanced error handling with fallback + RAISE WARNING 'Safe search failed: %, falling back to original function', SQLERRM; + + -- Fallback to original function without expanded terms + RETURN QUERY + SELECT * FROM uce_search_layer_fulltext( + corpus_id, input1, input2, take_count, offset_count, + count_all, order_direction, order_by_column, + uce_metadata_filters, useTsVector, source_table, + schema_name, p_user_name, p_min_level, + NULL::text[] -- No expanded terms + ); +END; +$$ LANGUAGE plpgsql; + +-- Test the safe function +COMMENT ON FUNCTION uce_search_layer_fulltext_safe IS + 'Safe search function with robust tsquery construction and error handling'; \ No newline at end of file diff --git a/database/4_createSearchLayerFulltextProcedure.sql b/database/4_createSearchLayerFulltextProcedure.sql index 1cae8261..e199747a 100644 --- a/database/4_createSearchLayerFulltextProcedure.sql +++ b/database/4_createSearchLayerFulltextProcedure.sql @@ -13,6 +13,7 @@ CREATE OR REPLACE FUNCTION uce_search_layer_fulltext( IN schema_name text DEFAULT 'public', IN p_user_name text DEFAULT NULL, IN p_min_level integer DEFAULT 1, + IN expanded_terms text[] DEFAULT NULL, OUT total_count_out integer, OUT document_ids integer[], OUT named_entities_found text[][], @@ -34,19 +35,32 @@ DECLARE additional_join_1 TEXT := ''; additional_join_2 TEXT := ''; order_by_clause TEXT := ''; - ts_function TEXT; ts_condition TEXT; - snippet_query TEXT; - ranked_pages_cte TEXT; BEGIN -- Ensure PostgreSQL uses indexes and has enough memory --SET enable_seqscan = OFF; SET work_mem = '128MB'; + + -- Set statement timeout to prevent hanging queries (30 seconds) + SET statement_timeout = '30s'; + SET lock_timeout = '10s'; -- If input2 is NULL or empty, disable TsVector search IF input2 IS NULL OR input2 = '' THEN useTsVector := false; END IF; + + -- Validate input2 for Pro Mode (to_tsquery) to prevent syntax errors. + -- Keep strict semantics for pro mode: invalid tsquery should fail. + IF useTsVector AND input2 IS NOT NULL AND input2 != '' THEN + PERFORM to_tsquery('simple', input2); + END IF; + + -- Pro mode must preserve strict boolean semantics from input2. + -- Expanded term broadening is non-pro behavior only. + IF useTsVector THEN + expanded_terms := NULL; + END IF; -- Validate the order direction IF order_direction NOT IN ('ASC', 'DESC') THEN @@ -62,11 +76,11 @@ BEGIN order_by_clause := 'ORDER BY pr.rank DESC'; -- Default ordering END IF; - -- Determine the appropriate search function + -- Final tsquery keeps original query semantics from input2. IF useTsVector THEN - ts_function := 'to_tsquery(''simple'', $4)'; + ts_condition := 'to_tsquery(''simple'', $4)'; ELSE - ts_function := 'websearch_to_tsquery(''simple'', $4)'; + ts_condition := 'websearch_to_tsquery(''simple'', $4)'; END IF; -- Check source table @@ -75,179 +89,289 @@ BEGIN additional_join_2 := FORMAT('INNER JOIN %I.%I t ON p.id = t.id', schema_name, source_table); END IF; - -- Define snippet selection logic - IF input2 IS NULL OR input2 = '' THEN - snippet_query := 'SELECT jsonb_agg(jsonb_build_object( - ''snippet'', LEFT(p.coveredtext, 400), - ''pageId'', p.id - ) ORDER BY p.id ASC) - FROM (SELECT p.id, p.coveredtext - FROM page p - WHERE p.document_id = lp.doc_id - ORDER BY p.id ASC - LIMIT 1) p'; - - -- If we dont search for any string, we dont need to fulltext search all documents - ranked_pages_cte := 'WITH limited_docs AS NOT MATERIALIZED ( - SELECT id, documenttitle FROM permitted_documents($13, $14) WHERE corpusid = $2 LIMIT 999 - ) - SELECT - p.document_id AS doc_id, - 0 AS rank, - -- %s - d.documenttitle - FROM limited_docs d - JOIN page p ON d.id = p.document_id - %s - -- %s - AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches))'; - ELSE - snippet_query := 'SELECT jsonb_agg(jsonb_build_object( - ''snippet'', ts_headline( - ''simple'', - p.coveredtext, - ' || ts_function || ', - ''StartSel=, StopSel=, MaxWords=60, MinWords=35, MaxFragments=3, FragmentDelimiter=" [...] "'' - ), - ''pageId'', p.id - ) ORDER BY rank_score DESC) - FROM (SELECT p.id, p.coveredtext, ts_rank_cd(p.textsearch, ' || ts_function || ') AS rank_score - FROM page p - WHERE p.document_id = lp.doc_id - AND p.textsearch @@ ' || ts_function || ' - ORDER BY rank_score DESC - LIMIT 5) p'; - - ranked_pages_cte := 'SELECT - p.document_id AS doc_id, - ts_rank_cd(p.textsearch, %s) AS rank, - d.documenttitle - FROM page p - %s - JOIN permitted_documents($13, $14) d ON d.id = p.document_id - WHERE ( - ($4 IS NOT NULL AND $4 <> '''' AND p.textsearch @@ %s) - OR ($4 IS NULL OR $4 = '''') - ) - AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches)) - AND d.corpusid = $2 - LIMIT 20000'; -- Put a Limit here, it will increase perfomance and we dont need to show millions of hits. But I know, it's a hack... - END IF; - - -- Construct the full query dynamically - query := FORMAT(' - WITH expanded_filters AS NOT MATERIALIZED ( - SELECT - (filter->>''key'')::text AS key, - (filter->>''value'')::text AS value, - (filter->>''min'')::decimal AS min, - (filter->>''max'')::decimal AS max, - (filter->>''valueType'')::int AS value_type - FROM jsonb_array_elements($1) AS filter - ), - filtered_ucemetadata AS ( - SELECT document_id, key, value, valueType - FROM ucemetadata - WHERE valueType != 2 - ), - filter_matches AS NOT MATERIALIZED ( - SELECT um.document_id - FROM expanded_filters ef - JOIN filtered_ucemetadata um ON - (ef.value_type IS NULL OR um.valueType = ef.value_type) - AND (ef.key IS NULL OR um.key = ef.key) - AND ( - ef.value IS NULL OR um.value = ef.value - OR ( - -- range search for NUMBER meta fields - ef.value_type = 1 - AND ( - (ef.min IS NULL OR um.value::decimal >= ef.min) - AND - (ef.max IS NULL OR um.value::decimal <= ef.max) + IF input2 IS NULL OR input2 = '' THEN + query := FORMAT(' + WITH expanded_filters AS NOT MATERIALIZED ( + SELECT + (filter->>''key'')::text AS key, + (filter->>''value'')::text AS value, + (filter->>''min'')::decimal AS min, + (filter->>''max'')::decimal AS max, + (filter->>''valueType'')::int AS value_type + FROM jsonb_array_elements($1) AS filter + ), + filtered_ucemetadata AS ( + SELECT document_id, key, value, valueType + FROM ucemetadata + WHERE valueType != 2 + ), + filter_matches AS NOT MATERIALIZED ( + SELECT um.document_id + FROM expanded_filters ef + JOIN filtered_ucemetadata um ON + (ef.value_type IS NULL OR um.valueType = ef.value_type) + AND (ef.key IS NULL OR um.key = ef.key) + AND ( + ef.value IS NULL OR um.value = ef.value + OR ( + -- range search for NUMBER meta fields + ef.value_type = 1 + AND ( + (ef.min IS NULL OR um.value::decimal >= ef.min) + AND + (ef.max IS NULL OR um.value::decimal <= ef.max) + ) ) - ) - ) - JOIN permitted_documents($13, $14) d ON um.document_id = d.id AND d.corpusid = $2 - %s - GROUP BY um.document_id - HAVING COUNT(ef.key) = (SELECT COUNT(*) FROM expanded_filters) - ), - ranked_pages AS NOT MATERIALIZED ( ' || ranked_pages_cte || '), - page_ranked AS NOT MATERIALIZED ( - SELECT - doc_id, - AVG(rank) AS rank, -- Get highest rank per document - documenttitle - FROM ranked_pages - GROUP BY doc_id, documenttitle - ), - limited_pages AS NOT MATERIALIZED ( + ) + JOIN permitted_documents($9, $10) d ON um.document_id = d.id AND d.corpusid = $2 + %s + GROUP BY um.document_id + HAVING COUNT(ef.key) = (SELECT COUNT(*) FROM expanded_filters) + ), + limited_docs AS NOT MATERIALIZED ( + SELECT id, documenttitle FROM permitted_documents($9, $10) pd WHERE pd.corpusid = $2 LIMIT 999 + ), + page_ranked AS NOT MATERIALIZED ( + SELECT + p.document_id AS doc_id, + 0 AS rank, + d.documenttitle + FROM limited_docs d + JOIN page p ON d.id = p.document_id + %s + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches)) + GROUP BY p.document_id, d.documenttitle + ), + limited_pages AS NOT MATERIALIZED ( + SELECT + pr.doc_id, + pr.rank, + pr.documenttitle + FROM page_ranked pr + %s + LIMIT $6 OFFSET $7 + ), + counted_documents AS NOT MATERIALIZED ( + SELECT COUNT(*) AS total_count FROM page_ranked + ), + ranked_documents AS NOT MATERIALIZED ( + SELECT + d.id, + lp.rank, + JSONB_AGG(( + SELECT jsonb_agg(jsonb_build_object( + ''snippet'', LEFT(p.coveredtext, 400), + ''pageId'', p.id + ) ORDER BY p.id ASC) + FROM (SELECT p.id, p.coveredtext + FROM page p + WHERE p.document_id = lp.doc_id + ORDER BY p.id ASC + LIMIT 1) p + )) AS snippets + FROM limited_pages lp + JOIN permitted_documents($9, $10) d ON d.id = lp.doc_id + GROUP BY d.id, lp.rank + ORDER BY lp.rank DESC + ), + extracted_entities AS NOT MATERIALIZED ( + SELECT ARRAY[ne.id::text, ne.coveredtext, COUNT(ne.id)::text, ne.typee, ne.document_id::text] + FROM ranked_documents rd + JOIN namedentity ne ON rd.id = ne.document_id + GROUP BY ne.id, ne.coveredtext, ne.typee, ne.document_id + ), + extracted_times AS NOT MATERIALIZED ( + SELECT ARRAY[t.id::text, t.coveredtext, COUNT(t.id)::text, t.valuee, t.document_id::text] + FROM ranked_documents rd + JOIN time t ON rd.id = t.document_id + GROUP BY t.id, t.coveredtext, t.valuee, t.document_id + ), + extracted_taxons AS NOT MATERIALIZED ( + SELECT ARRAY[ta.id::text, ta.coveredtext, COUNT(ta.id)::text, ta.primaryname, ta.document_id::text] + FROM ranked_documents rd + JOIN biofidtaxon ta ON rd.id = ta.document_id + GROUP BY ta.id, ta.coveredtext, ta.primaryname, ta.document_id + ) SELECT - pr.doc_id, - pr.rank, - pr.documenttitle - FROM page_ranked pr - %s - LIMIT $6 OFFSET $7 - ), - counted_documents AS NOT MATERIALIZED ( - SELECT COUNT(*) AS total_count FROM page_ranked - ), - ranked_documents AS NOT MATERIALIZED ( + CASE WHEN $8 THEN (SELECT total_count FROM counted_documents) ELSE NULL END, + ARRAY(SELECT id FROM ranked_documents), + ARRAY(SELECT rank FROM ranked_documents), + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_entities) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_times) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_taxons) ELSE ARRAY[]::text[][] END, + ARRAY(SELECT snippets FROM ranked_documents) + ', additional_join_1, additional_join_2, order_by_clause); + ELSE + query := FORMAT(' + WITH expanded_filters AS NOT MATERIALIZED ( + SELECT + (filter->>''key'')::text AS key, + (filter->>''value'')::text AS value, + (filter->>''min'')::decimal AS min, + (filter->>''max'')::decimal AS max, + (filter->>''valueType'')::int AS value_type + FROM jsonb_array_elements($1) AS filter + ), + filtered_ucemetadata AS ( + SELECT document_id, key, value, valueType + FROM ucemetadata + WHERE valueType != 2 + ), + filter_matches AS NOT MATERIALIZED ( + SELECT um.document_id + FROM expanded_filters ef + JOIN filtered_ucemetadata um ON + (ef.value_type IS NULL OR um.valueType = ef.value_type) + AND (ef.key IS NULL OR um.key = ef.key) + AND ( + ef.value IS NULL OR um.value = ef.value + OR ( + -- range search for NUMBER meta fields + ef.value_type = 1 + AND ( + (ef.min IS NULL OR um.value::decimal >= ef.min) + AND + (ef.max IS NULL OR um.value::decimal <= ef.max) + ) + ) + ) + JOIN permitted_documents($9, $10) d ON um.document_id = d.id AND d.corpusid = $2 + %s + GROUP BY um.document_id + HAVING COUNT(ef.key) = (SELECT COUNT(*) FROM expanded_filters) + ), + expanded_term_chunks AS NOT MATERIALIZED ( + SELECT + ((ROW_NUMBER() OVER ()) - 1) / 200 AS chunk_id, + btrim(term) AS term + FROM unnest($11) AS term + WHERE term IS NOT NULL AND btrim(term) <> '''' + ), + expanded_term_queries AS NOT MATERIALIZED ( + SELECT + chunk_id, + websearch_to_tsquery(''simple'', string_agg(term, '' OR '')) AS chunk_query + FROM expanded_term_chunks + GROUP BY chunk_id + ), + expanded_prefilter_docs AS NOT MATERIALIZED ( + SELECT DISTINCT p.document_id + FROM page p + JOIN expanded_term_queries etq ON p.textsearch @@ etq.chunk_query + ), + candidate_pages AS MATERIALIZED ( + SELECT + p.document_id AS doc_id, + p.id AS page_id, + CASE + WHEN p.textsearch @@ ' || ts_condition || ' THEN ts_rank_cd(p.textsearch, ' || ts_condition || ') + ELSE 0 + END AS rank_score, + d.documenttitle + FROM page p + %s + JOIN permitted_documents($9, $10) d ON d.id = p.document_id + WHERE ( + p.textsearch @@ ' || ts_condition || ' + OR ( + $11 IS NOT NULL + AND cardinality($11) > 0 + AND p.document_id IN (SELECT document_id FROM expanded_prefilter_docs) + ) + ) + AND ($5 IS NULL OR p.document_id IN (SELECT document_id FROM filter_matches)) + AND d.corpusid = $2 + ), + document_ranks AS NOT MATERIALIZED ( + SELECT + doc_id, + AVG(rank_score) AS rank, + documenttitle + FROM candidate_pages + GROUP BY doc_id, documenttitle + ), + limited_docs AS NOT MATERIALIZED ( + SELECT + pr.doc_id, + pr.rank, + pr.documenttitle + FROM document_ranks pr + %s + LIMIT $6 OFFSET $7 + ), + counted_documents AS NOT MATERIALIZED ( + SELECT COUNT(*) AS total_count FROM document_ranks + ), + ranked_documents AS NOT MATERIALIZED ( + SELECT + d.id, + ld.rank, + JSONB_AGG(( + SELECT jsonb_agg(jsonb_build_object( + ''snippet'', ts_headline( + ''simple'', + p.coveredtext, + ' || ts_condition || ', + ''StartSel=, StopSel=, MaxWords=60, MinWords=35, MaxFragments=3, FragmentDelimiter=" [...] "'' + ), + ''pageId'', p.id + ) ORDER BY p.rank_score DESC) + FROM ( + SELECT cp.page_id AS id, p.coveredtext, cp.rank_score + FROM candidate_pages cp + JOIN page p ON p.id = cp.page_id + WHERE cp.doc_id = ld.doc_id + ORDER BY cp.rank_score DESC + LIMIT 5 + ) p + )) AS snippets + FROM limited_docs ld + JOIN permitted_documents($9, $10) d ON d.id = ld.doc_id + GROUP BY d.id, ld.rank + ORDER BY ld.rank DESC + ), + extracted_entities AS NOT MATERIALIZED ( + SELECT ARRAY[ne.id::text, ne.coveredtext, COUNT(ne.id)::text, ne.typee, ne.document_id::text] + FROM ranked_documents rd + JOIN namedentity ne ON rd.id = ne.document_id + GROUP BY ne.id, ne.coveredtext, ne.typee, ne.document_id + ), + extracted_times AS NOT MATERIALIZED ( + SELECT ARRAY[t.id::text, t.coveredtext, COUNT(t.id)::text, t.valuee, t.document_id::text] + FROM ranked_documents rd + JOIN time t ON rd.id = t.document_id + GROUP BY t.id, t.coveredtext, t.valuee, t.document_id + ), + extracted_taxons AS NOT MATERIALIZED ( + SELECT ARRAY[ta.id::text, ta.coveredtext, COUNT(ta.id)::text, ta.primaryname, ta.document_id::text] + FROM ranked_documents rd + JOIN biofidtaxon ta ON rd.id = ta.document_id + GROUP BY ta.id, ta.coveredtext, ta.primaryname, ta.document_id + ) SELECT - d.id, - lp.rank, - JSONB_AGG((' || snippet_query || ')) AS snippets - FROM limited_pages lp - JOIN permitted_documents($13, $14) d ON d.id = lp.doc_id - GROUP BY d.id, lp.rank - ORDER BY lp.rank DESC - ), - extracted_entities AS NOT MATERIALIZED ( - SELECT ARRAY[ne.id::text, ne.coveredtext, COUNT(ne.id)::text, ne.typee, ne.document_id::text] - FROM ranked_documents rd - JOIN namedentity ne ON rd.id = ne.document_id - GROUP BY ne.id, ne.coveredtext, ne.typee, ne.document_id - ), - extracted_times AS NOT MATERIALIZED ( - SELECT ARRAY[t.id::text, t.coveredtext, COUNT(t.id)::text, t.valuee, t.document_id::text] - FROM ranked_documents rd - JOIN time t ON rd.id = t.document_id - GROUP BY t.id, t.coveredtext, t.valuee, t.document_id - ), - extracted_taxons AS NOT MATERIALIZED ( - SELECT ARRAY[ta.id::text, ta.coveredtext, COUNT(ta.id)::text, ta.primaryname, ta.document_id::text] - FROM ranked_documents rd - JOIN biofidtaxon ta ON rd.id = ta.document_id - GROUP BY ta.id, ta.coveredtext, ta.primaryname, ta.document_id - ) - SELECT - CASE WHEN $8 THEN (SELECT total_count FROM counted_documents) ELSE NULL END, - ARRAY(SELECT id FROM ranked_documents), - ARRAY(SELECT rank FROM ranked_documents), - CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_entities) ELSE ARRAY[]::text[][] END, - CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_times) ELSE ARRAY[]::text[][] END, - CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_taxons) ELSE ARRAY[]::text[][] END, - ARRAY(SELECT snippets FROM ranked_documents) - ', additional_join_1, ts_function, additional_join_2, ts_function, order_by_clause); + CASE WHEN $8 THEN (SELECT total_count FROM counted_documents) ELSE NULL END, + ARRAY(SELECT id FROM ranked_documents), + ARRAY(SELECT rank FROM ranked_documents), + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_entities) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_times) ELSE ARRAY[]::text[][] END, + CASE WHEN $8 THEN ARRAY(SELECT * FROM extracted_taxons) ELSE ARRAY[]::text[][] END, + ARRAY(SELECT snippets FROM ranked_documents) + ', additional_join_1, additional_join_2, order_by_clause); + END IF; EXECUTE query USING uce_metadata_filters, -- $1 : uce_metadata_filters corpus_id, -- $2 : corpus_id - NULL, -- $3 : placeholder (not used in query) + input1, -- $3 : input1 / search tokens array input2, -- $4 : input2 / search string uce_metadata_filters, -- $5 : uce_metadata_filters (for filter checks) take_count, -- $6 : limit offset_count, -- $7 : offset count_all, -- $8 : return counts? - NULL, -- $9 : placeholder (not used in query) - NULL, -- $10 : placeholder (not used in query) - NULL, -- $11 : placeholder (not used in query) - NULL, -- $12 : placeholder (not used in query) - p_user_name, -- $13 : principal for permitted_documents - p_min_level -- $14 : min permission level + p_user_name, -- $9 : principal for permitted_documents + p_min_level, -- $10 : min permission level + expanded_terms -- $11 : expanded terms array INTO total_count_temp, document_ids_temp, document_ranks_temp, named_entities_temp, time_temp, taxons_temp, snippets_temp; total_count_out := total_count_temp; diff --git a/database/6_createGeonameProcedures.sql b/database/6_createGeonameProcedures.sql index e18f5b35..88d4d96c 100644 --- a/database/6_createGeonameProcedures.sql +++ b/database/6_createGeonameProcedures.sql @@ -180,7 +180,7 @@ BEGIN ) -- Filter by specific corpus ID - AND corpusid = corpus + AND geoname_context_timeline_cache.corpusid = corpus -- Group nearby points based on a fixed spatial grid to create clusters GROUP BY ST_SnapToGrid(location_geom, grid_size, grid_size); diff --git a/database/7_logicalLinks.sql b/database/7_logicalLinks.sql index b9a32401..84754de0 100644 --- a/database/7_logicalLinks.sql +++ b/database/7_logicalLinks.sql @@ -40,7 +40,7 @@ BEGIN -- First, try to get the internal document ID SELECT id INTO doc_internal_id FROM document - WHERE corpusid = rec.corpusid AND documentid = rec.fromm; + WHERE document.corpusid = rec.corpusid AND document.documentid = rec.fromm; IF doc_internal_id IS NOT NULL THEN BEGIN @@ -99,7 +99,7 @@ BEGIN -- First, try to get the internal document ID SELECT id INTO doc_internal_id FROM document - WHERE corpusid = rec.corpusid AND documentid = rec.too; + WHERE document.corpusid = rec.corpusid AND document.documentid = rec.too; IF doc_internal_id IS NOT NULL THEN BEGIN diff --git a/database/Dockerfile b/database/Dockerfile index 3a393356..22fb8a27 100644 --- a/database/Dockerfile +++ b/database/Dockerfile @@ -1,9 +1,10 @@ FROM postgres:16 # Install postgis extension and pgvector extension -RUN apt-get update && apt-get install -y gnupg2 curl ca-certificates lsb-release \ - && echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ - && curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - \ +RUN apt-get update && apt-get install -y --no-install-recommends gnupg2 curl ca-certificates lsb-release \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \ + | gpg --dearmor -o /usr/share/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list \ && apt-get update \ - && apt-get install -y postgis postgresql-16-postgis-3 postgresql-16-pgvector \ + && apt-get install -y --no-install-recommends postgresql-16-postgis-3 postgresql-16-postgis-3-scripts postgresql-16-pgvector \ && rm -rf /var/lib/apt/lists/* diff --git a/deploy/csv/keycloak_users.csv b/deploy/csv/keycloak_users.csv new file mode 100644 index 00000000..771d158e --- /dev/null +++ b/deploy/csv/keycloak_users.csv @@ -0,0 +1,2 @@ +type,name,email,firstName,lastName,password,temporary,groups +USER,example-user,,,,K5fUD2MloA1V,true,core-admin diff --git a/deploy/keycloak-config.Dockerfile b/deploy/keycloak-config.Dockerfile new file mode 100644 index 00000000..a66a92f5 --- /dev/null +++ b/deploy/keycloak-config.Dockerfile @@ -0,0 +1,10 @@ +FROM alpine:3.20 + +RUN apk add --no-cache curl jq + +COPY deploy/keycloak-config.sh /scripts/keycloak-config.sh +RUN chmod +x /scripts/keycloak-config.sh + +ENTRYPOINT ["/bin/sh", "-lc"] +CMD ["/scripts/keycloak-config.sh"] + diff --git a/deploy/keycloak-config.sh b/deploy/keycloak-config.sh new file mode 100644 index 00000000..283638f6 --- /dev/null +++ b/deploy/keycloak-config.sh @@ -0,0 +1,195 @@ +#!/bin/sh +set -eu + +strip_trailing_slashes() { + # Strip trailing slashes but keep a single "/" if the whole string is "/". + v="${1:-}" + v="$(printf %s "$v" | sed 's/[[:space:]]*$//')" + while [ "${#v}" -gt 1 ] && [ "${v%/}" != "$v" ]; do + v="${v%/}" + done + printf %s "$v" +} + +if [ -z "${KC_BASE_URL:-}" ]; then + echo "KC_BASE_URL is required (e.g. http://uce-keycloak-auth:8080)" + exit 1 +fi +if [ -z "${KC_ADMIN_USERNAME:-}" ] || [ -z "${KC_ADMIN_PW:-}" ]; then + echo "KC_ADMIN_USERNAME and KC_ADMIN_PW are required" + exit 1 +fi +if [ -z "${KC_REALM:-}" ]; then + echo "KC_REALM is required (e.g. uce)" + exit 1 +fi +if [ -z "${KC_CLIENT_ID:-}" ]; then + echo "KC_CLIENT_ID is required (e.g. uce-web)" + exit 1 +fi +if [ -z "${UCE_PUBLIC_URL:-}" ]; then + echo "UCE_PUBLIC_URL is required (e.g. https://uce.example.org)" + exit 1 +fi + +# Normalize URL bases early so we never generate double slashes. +KC_BASE_URL="$(strip_trailing_slashes "${KC_BASE_URL}")" +UCE_PUBLIC_URL="$(strip_trailing_slashes "${UCE_PUBLIC_URL}")" + +# Optional: set/align the OIDC client secret (confidential client). +# UCE uses KEYCLOAK_CREDENTIALS_SECRET at runtime; keeping this in sync prevents `unauthorized_client`. +KC_CLIENT_SECRET="${KC_CLIENT_SECRET:-${KEYCLOAK_CREDENTIALS_SECRET:-}}" + +echo "Waiting for Keycloak at ${KC_BASE_URL}..." +KC_MGMT_URL="${KC_MGMT_URL:-}" +if [ -z "${KC_MGMT_URL}" ]; then + # Keycloak exposes health endpoints on the management interface (default port 9000). + # Derive the management URL from KC_BASE_URL (which points to the main HTTP port, usually 8080). + # Only do a safe port substitution when KC_BASE_URL ends with :. Otherwise require KC_MGMT_URL explicitly. + if printf %s "${KC_BASE_URL}" | grep -Eq ':[0-9]+$'; then + KC_MGMT_URL="$(printf %s "${KC_BASE_URL}" | sed -E 's#:[0-9]+$#:9000#')" + else + echo "KC_MGMT_URL is required when KC_BASE_URL has no explicit port (expected ...:8080)" >&2 + exit 1 + fi +fi +KC_MGMT_URL="$(strip_trailing_slashes "${KC_MGMT_URL}")" +READY=0 +for i in $(seq 1 120); do + if curl -fsS "${KC_MGMT_URL}/health/ready" >/dev/null 2>&1; then + READY=1 + break + fi + sleep 1 +done +if [ "$READY" -ne 1 ]; then + echo "Keycloak did not become ready in time at ${KC_MGMT_URL}/health/ready" >&2 + exit 1 +fi + +echo "Requesting admin token..." +TOKEN="$( + curl -fsS \ + -X POST "${KC_BASE_URL}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=password" \ + --data-urlencode "client_id=admin-cli" \ + --data-urlencode "username=${KC_ADMIN_USERNAME}" \ + --data-urlencode "password=${KC_ADMIN_PW}" \ + | jq -r '.access_token' +)" + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo "Failed to obtain access token" + exit 1 +fi + +if [ -n "${KC_SSL_REQUIRED:-}" ]; then + echo "Updating Keycloak realm SSL requirement for ${KC_REALM} (sslRequired=${KC_SSL_REQUIRED})..." + # Keycloak realm field: sslRequired = none|external|all + REALM_JSON="$( + curl -fsS \ + -H "Authorization: Bearer ${TOKEN}" \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}" + )" + + UPDATED_REALM="$( + echo "$REALM_JSON" | jq \ + --arg ssl "${KC_SSL_REQUIRED}" \ + '.sslRequired = $ssl' + )" + + curl -fsS \ + -X PUT "${KC_BASE_URL}/admin/realms/${KC_REALM}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$UPDATED_REALM" \ + >/dev/null + + echo "Keycloak realm SSL requirement updated." +fi + +if [ -n "${KC_SSO_SESSION_IDLE_TIMEOUT:-}" ]; then + echo "Updating Keycloak realm session settings for ${KC_REALM}..." + REALM_JSON="$( + curl -fsS \ + -H "Authorization: Bearer ${TOKEN}" \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}" + )" + + UPDATED_REALM="$( + echo "$REALM_JSON" | jq \ + --argjson idle "${KC_SSO_SESSION_IDLE_TIMEOUT}" \ + '.ssoSessionIdleTimeout = $idle' + )" + + curl -fsS \ + -X PUT "${KC_BASE_URL}/admin/realms/${KC_REALM}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$UPDATED_REALM" \ + >/dev/null + + echo "Keycloak realm session settings updated (ssoSessionIdleTimeout=${KC_SSO_SESSION_IDLE_TIMEOUT})." +fi + +echo "Resolving client id for realm=${KC_REALM} clientId=${KC_CLIENT_ID}..." +CLIENT_UUID="$( + curl -fsS \ + -H "Authorization: Bearer ${TOKEN}" \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients?clientId=${KC_CLIENT_ID}" \ + | jq -r --arg cid "${KC_CLIENT_ID}" 'map(select(.clientId == $cid)) | .[0].id' +)" + +if [ -z "$CLIENT_UUID" ] || [ "$CLIENT_UUID" = "null" ]; then + echo "Could not resolve client UUID for ${KC_CLIENT_ID} in realm ${KC_REALM}" + exit 1 +fi + +echo "Updating Keycloak client settings for ${KC_CLIENT_ID}..." +CLIENT_JSON="$( + curl -fsS \ + -H "Authorization: Bearer ${TOKEN}" \ + "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients/${CLIENT_UUID}" +)" + +UPDATED="$( + echo "$CLIENT_JSON" | jq \ + --arg uceUrl "${UCE_PUBLIC_URL}" \ + --arg clientSecret "${KC_CLIENT_SECRET}" \ + ' + .rootUrl = $uceUrl + | .baseUrl = $uceUrl + | .redirectUris = ([($uceUrl + "/auth/*")] + (.redirectUris // []) | unique) + | .webOrigins = ([$uceUrl] + (.webOrigins // []) | unique) + | .attributes = (.attributes // {}) + | .attributes["post.logout.redirect.uris"] = ( + # Keycloak expects a string here. Multiple entries are typically separated by "##". + # Keep whatever is already there, but ensure `$uceUrl/auth/logout` is present. + ( .attributes["post.logout.redirect.uris"] // "" ) as $existing + | ($uceUrl + "/auth/logout") as $want + | if ($existing | tostring) == "" or ($existing | tostring) == "+" + then $want + elif ($existing | tostring | contains($want)) + then ($existing | tostring) + else (($existing | tostring) + "##" + $want) + end + ) + | (if ($clientSecret | length) > 0 + then + .publicClient = false + | .clientAuthenticatorType = "client-secret" + | .secret = $clientSecret + else . + end) + ' +)" + +curl -fsS \ + -X PUT "${KC_BASE_URL}/admin/realms/${KC_REALM}/clients/${CLIENT_UUID}" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$UPDATED" \ + >/dev/null + +echo "Keycloak client updated." diff --git a/deploy/keycloak-sync-from-csv.Dockerfile b/deploy/keycloak-sync-from-csv.Dockerfile new file mode 100644 index 00000000..209c0e4d --- /dev/null +++ b/deploy/keycloak-sync-from-csv.Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-alpine + +WORKDIR /app + +COPY deploy/keycloak-sync-from-csv.py /app/keycloak-sync-from-csv.py + +ENTRYPOINT ["python", "/app/keycloak-sync-from-csv.py"] + diff --git a/deploy/keycloak-sync-from-csv.py b/deploy/keycloak-sync-from-csv.py new file mode 100644 index 00000000..0050d8ee --- /dev/null +++ b/deploy/keycloak-sync-from-csv.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +import csv +import json +import os +import sys +import urllib.parse +import urllib.request + + +def _env(name: str, default: str | None = None) -> str | None: + value = os.environ.get(name) + if value is None or value.strip() == "": + return default + return value.strip() + + +def _env_bool(name: str, default: bool) -> bool: + value = _env(name) + if value is None: + return default + return value.lower() in {"1", "true", "yes", "y", "on"} + +def _env_int(name: str, default: int | None = None) -> int | None: + value = _env(name) + if value is None: + return default + try: + return int(value, 10) + except Exception: + raise ValueError(f"{name} must be an integer, got: {value!r}") + + +class KeycloakClient: + def __init__(self, base_url: str, realm: str, token: str): + self.base_url = base_url.rstrip("/") + self.realm = realm + self.token = token + + def _req(self, method: str, path: str, body: dict | list | None = None, query: dict | None = None): + url = f"{self.base_url}{path}" + if query: + url += "?" + urllib.parse.urlencode(query) + data = None + headers = { + "Authorization": f"Bearer {self.token}", + "Accept": "application/json", + } + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + req = urllib.request.Request(url=url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read() + if not raw: + return resp.status, None, dict(resp.headers) + try: + return resp.status, json.loads(raw.decode("utf-8")), dict(resp.headers) + except Exception: + return resp.status, raw.decode("utf-8", errors="replace"), dict(resp.headers) + except urllib.error.HTTPError as e: + raw = e.read() + try: + payload = json.loads(raw.decode("utf-8")) + except Exception: + payload = raw.decode("utf-8", errors="replace") + return e.code, payload, dict(e.headers) + + def list_groups(self) -> list[dict]: + status, payload, _ = self._req("GET", f"/admin/realms/{self.realm}/groups") + if status != 200: + raise RuntimeError(f"Failed to list groups: HTTP {status} {payload}") + return payload or [] + + def create_group(self, name: str) -> str: + status, payload, headers = self._req( + "POST", + f"/admin/realms/{self.realm}/groups", + body={"name": name}, + ) + if status not in (201, 204): + raise RuntimeError(f"Failed to create group {name}: HTTP {status} {payload}") + + location = headers.get("Location") or headers.get("location") + if location: + return location.rstrip("/").split("/")[-1] + + groups = self.list_groups() + for g in groups: + if g.get("name") == name: + return g["id"] + raise RuntimeError(f"Created group {name} but couldn't resolve its id") + + def get_user_by_username(self, username: str) -> dict | None: + status, payload, _ = self._req( + "GET", + f"/admin/realms/{self.realm}/users", + query={"username": username, "exact": "true"}, + ) + if status != 200: + raise RuntimeError(f"Failed to search user {username}: HTTP {status} {payload}") + if not payload: + return None + return payload[0] + + def create_user(self, username: str, email: str | None, first: str | None, last: str | None) -> str: + body: dict = {"username": username, "enabled": True} + if email: + body["email"] = email + if first: + body["firstName"] = first + if last: + body["lastName"] = last + + status, payload, headers = self._req( + "POST", + f"/admin/realms/{self.realm}/users", + body=body, + ) + if status not in (201, 204): + raise RuntimeError(f"Failed to create user {username}: HTTP {status} {payload}") + + location = headers.get("Location") or headers.get("location") + if location: + return location.rstrip("/").split("/")[-1] + + user = self.get_user_by_username(username) + if user and user.get("id"): + return user["id"] + raise RuntimeError(f"Created user {username} but couldn't resolve its id") + + def update_user(self, user_id: str, email: str | None, first: str | None, last: str | None): + status, payload, _ = self._req("GET", f"/admin/realms/{self.realm}/users/{user_id}") + if status != 200: + raise RuntimeError(f"Failed to fetch user {user_id}: HTTP {status} {payload}") + user = payload or {} + if email is not None: + user["email"] = email + if first is not None: + user["firstName"] = first + if last is not None: + user["lastName"] = last + user["enabled"] = True + + status, payload, _ = self._req("PUT", f"/admin/realms/{self.realm}/users/{user_id}", body=user) + if status not in (204,): + raise RuntimeError(f"Failed to update user {user_id}: HTTP {status} {payload}") + + def set_user_password(self, user_id: str, password: str, temporary: bool): + status, payload, _ = self._req( + "PUT", + f"/admin/realms/{self.realm}/users/{user_id}/reset-password", + body={"type": "password", "value": password, "temporary": bool(temporary)}, + ) + if status not in (204,): + raise RuntimeError(f"Failed to set password for user {user_id}: HTTP {status} {payload}") + + def add_user_to_group(self, user_id: str, group_id: str): + status, payload, _ = self._req( + "PUT", + f"/admin/realms/{self.realm}/users/{user_id}/groups/{group_id}", + body=None, + ) + if status not in (204,): + raise RuntimeError(f"Failed to add user {user_id} to group {group_id}: HTTP {status} {payload}") + + +def get_admin_token(base_url: str, username: str, password: str) -> str: + token_url = f"{base_url.rstrip('/')}/realms/master/protocol/openid-connect/token" + data = urllib.parse.urlencode( + { + "grant_type": "password", + "client_id": "admin-cli", + "username": username, + "password": password, + } + ).encode("utf-8") + req = urllib.request.Request( + url=token_url, + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.loads(resp.read().decode("utf-8")) + token = payload.get("access_token") + if not token: + raise RuntimeError("Failed to obtain admin token") + return token + + +def parse_groups(groups_raw: str | None) -> list[str]: + if not groups_raw: + return [] + parts = [p.strip() for p in groups_raw.split(";")] + return [p for p in parts if p] + + +def main() -> int: + csv_path = _env("CSV_PATH") or (sys.argv[1] if len(sys.argv) > 1 else None) + if not csv_path: + print("CSV_PATH (or first CLI arg) is required", file=sys.stderr) + return 2 + + base_url = _env("KC_BASE_URL") + realm = _env("KC_REALM") + admin_user = _env("KC_ADMIN_USERNAME") + admin_pw = _env("KC_ADMIN_PW") + if not base_url or not realm or not admin_user or not admin_pw: + print("KC_BASE_URL, KC_REALM, KC_ADMIN_USERNAME, KC_ADMIN_PW are required", file=sys.stderr) + return 2 + + dry_run = _env_bool("DRY_RUN", False) + create_groups = _env_bool("CREATE_GROUPS", True) + create_users = _env_bool("CREATE_USERS", True) + update_users = _env_bool("UPDATE_USERS", True) + # Limit how many CSV data rows to process (excluding the header). + # Prefer MAX_ROWS (container/internal), but support KEYCLOAK_SYNC_MAX_ROWS (compose/.env-friendly). + max_rows = _env_int("MAX_ROWS") + if max_rows is None: + max_rows = _env_int("KEYCLOAK_SYNC_MAX_ROWS") + if max_rows is not None and max_rows < 0: + raise ValueError("MAX_ROWS / KEYCLOAK_SYNC_MAX_ROWS must be >= 0") + + token = get_admin_token(base_url, admin_user, admin_pw) + kc = KeycloakClient(base_url, realm, token) + + group_cache: dict[str, str] = {} + if create_groups: + for g in kc.list_groups(): + name = g.get("name") + gid = g.get("id") + if name and gid: + group_cache[name] = gid + + def ensure_group(name: str) -> str: + if name in group_cache: + return group_cache[name] + if not create_groups: + raise RuntimeError(f"Group {name} does not exist (CREATE_GROUPS=false)") + if dry_run: + fake_id = f"DRYRUN:{name}" + group_cache[name] = fake_id + print(f"[DRY_RUN] create group: {name}") + return fake_id + gid = kc.create_group(name) + group_cache[name] = gid + print(f"created group: {name}") + return gid + + with open(csv_path, "r", encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + if not reader.fieldnames: + print("CSV must have a header row", file=sys.stderr) + return 2 + + required = {"type", "name"} + missing = required - set([h.strip() for h in reader.fieldnames if h]) + if missing: + print(f"CSV missing required columns: {', '.join(sorted(missing))}", file=sys.stderr) + return 2 + + rows_processed = 0 + for row in reader: + rows_processed += 1 + if max_rows is not None and rows_processed > max_rows: + print(f"max rows reached ({max_rows}), stopping.") + break + + type_raw = (row.get("type") or "").strip().upper() + name = (row.get("name") or "").strip() + if not type_raw or not name: + continue + + if type_raw == "GROUP": + ensure_group(name) + continue + + if type_raw != "USER": + raise RuntimeError(f"Unknown type {type_raw} for name={name}") + + if not create_users and not update_users: + continue + + email = (row.get("email") or "").strip() or None + first = (row.get("firstName") or "").strip() or None + last = (row.get("lastName") or "").strip() or None + password = (row.get("password") or "").strip() or None + temporary = ((row.get("temporary") or "").strip().lower() in {"1", "true", "yes", "y", "on"}) + groups = parse_groups((row.get("groups") or "").strip() or None) + + user = kc.get_user_by_username(name) + user_id: str | None = None + + if user is None: + if not create_users: + raise RuntimeError(f"User {name} does not exist (CREATE_USERS=false)") + if dry_run: + user_id = f"DRYRUN:{name}" + print(f"[DRY_RUN] create user: {name} email={email} firstName={first} lastName={last}") + else: + user_id = kc.create_user(name, email, first, last) + print(f"created user: {name}") + else: + user_id = user.get("id") + if user_id is None: + raise RuntimeError(f"User search returned no id for username={name}") + if update_users: + if dry_run: + print(f"[DRY_RUN] update user: {name} email={email} firstName={first} lastName={last}") + else: + kc.update_user(user_id, email, first, last) + print(f"updated user: {name}") + + if password: + if dry_run: + print(f"[DRY_RUN] set password: {name} temporary={temporary}") + else: + assert user_id is not None + kc.set_user_password(user_id, password, temporary) + print(f"set password: {name} temporary={temporary}") + + if groups: + for gname in groups: + gid = ensure_group(gname) + if dry_run: + print(f"[DRY_RUN] add user {name} to group {gname}") + else: + if str(gid).startswith("DRYRUN:"): + continue + assert user_id is not None + kc.add_user_to_group(user_id, gid) + print(f"added user {name} to group {gname}") + + print("done") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/deploy/nginx/templates/uce.conf.template b/deploy/nginx/templates/uce.conf.template new file mode 100644 index 00000000..0ba4dd92 --- /dev/null +++ b/deploy/nginx/templates/uce.conf.template @@ -0,0 +1,42 @@ +map $http_x_forwarded_proto $uce_x_forwarded_proto { + default $http_x_forwarded_proto; + "" $scheme; +} + +map $http_x_forwarded_port $uce_x_forwarded_port { + default $http_x_forwarded_port; + "" $server_port; +} + +server { + listen 80; + server_name ${UCE_VIRTUAL_HOST}; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $uce_x_forwarded_port; + proxy_set_header X-Forwarded-Proto $uce_x_forwarded_proto; + proxy_pass http://uce-web:${UCE_INTERNAL_PORT}; + } +} + +server { + listen 80; + server_name ${KEYCLOAK_VIRTUAL_HOST}; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $uce_x_forwarded_port; + proxy_set_header X-Forwarded-Proto $uce_x_forwarded_proto; + proxy_pass http://uce-keycloak-auth:${KEYCLOAK_INTERNAL_PORT}; + } +} + diff --git a/dev/biofid_fuseki_fix.py b/dev/biofid_fuseki_fix.py new file mode 100644 index 00000000..49551d91 --- /dev/null +++ b/dev/biofid_fuseki_fix.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Build a GBIF-derived enrichment report for taxon names. + +This script is intentionally generic: for each queried scientific name it resolves exact +homonym usages from GBIF, follows accepted usages where needed, and collects synonyms, +subordinate taxa, and vernacular names. The result is a JSON report that we can compare to +Fuseki/TDB contents before deciding how to patch RDF data. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.parse +import urllib.request +from collections import defaultdict +from functools import lru_cache + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--name", action="append", default=[], help="Scientific name to inspect.") + parser.add_argument( + "--names-file", + help="Optional file with one scientific name per line.", + ) + parser.add_argument("--output", required=True, help="Path to write the JSON report.") + parser.add_argument("--timeout", type=float, default=20.0, help="HTTP timeout in seconds.") + return parser.parse_args() + + +def normalize_canonical(name: str) -> str: + return re.sub(r"\s+", " ", name.strip()).casefold() + + +@lru_cache(maxsize=4096) +def fetch_json(url: str, timeout: float) -> dict: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "codex-biofid-fix/1.0", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + + +def species_match(name: str, timeout: float) -> dict: + encoded = urllib.parse.quote(name) + return fetch_json(f"https://api.gbif.org/v1/species/match?verbose=true&name={encoded}", timeout) + + +def species_search(name: str, timeout: float) -> dict: + encoded = urllib.parse.quote(name) + return fetch_json(f"https://api.gbif.org/v1/species/search?q={encoded}&limit=100", timeout) + + +def species_detail(key: int, timeout: float) -> dict: + return fetch_json(f"https://api.gbif.org/v1/species/{key}", timeout) + + +def species_synonyms(key: int, timeout: float) -> list[dict]: + return fetch_json(f"https://api.gbif.org/v1/species/{key}/synonyms?limit=500", timeout).get("results", []) + + +def species_children(key: int, timeout: float) -> list[dict]: + return fetch_json(f"https://api.gbif.org/v1/species/{key}/children?limit=500", timeout).get("results", []) + + +def species_vernaculars(key: int, timeout: float) -> list[dict]: + return fetch_json(f"https://api.gbif.org/v1/species/{key}/vernacularNames?limit=500", timeout).get("results", []) + + +def summarize_usage(item: dict) -> dict: + return { + "key": item.get("key"), + "scientificName": item.get("scientificName"), + "canonicalName": item.get("canonicalName"), + "authorship": item.get("authorship"), + "rank": item.get("rank"), + "taxonomicStatus": item.get("taxonomicStatus"), + "acceptedKey": item.get("acceptedKey"), + "accepted": item.get("accepted"), + "numDescendants": item.get("numDescendants"), + } + + +def build_report(name: str, timeout: float) -> dict: + canonical_query = normalize_canonical(name) + match = species_match(name, timeout) + search = species_search(name, timeout) + + exact_usages: dict[int, dict] = {} + fuzzy_or_non_exact: list[dict] = [] + + for item in search.get("results", []): + canonical = normalize_canonical(item.get("canonicalName") or "") + if canonical == canonical_query: + key = item.get("key") + if key is not None and key not in exact_usages: + exact_usages[key] = summarize_usage(item) + else: + fuzzy_or_non_exact.append(summarize_usage(item)) + + if match.get("usageKey") and normalize_canonical(match.get("canonicalName") or "") == canonical_query: + key = int(match["usageKey"]) + if key not in exact_usages: + exact_usages[key] = summarize_usage(species_detail(key, timeout)) + + accepted_usages: dict[int, dict] = {} + source_to_accepted: dict[int, int] = {} + for key in sorted(exact_usages): + detail = species_detail(key, timeout) + accepted_key = detail.get("acceptedKey") or detail.get("key") + if accepted_key is None: + continue + accepted_key = int(accepted_key) + source_to_accepted[key] = accepted_key + if accepted_key not in accepted_usages: + accepted_usages[accepted_key] = summarize_usage(species_detail(accepted_key, timeout)) + + expansion_names: set[str] = set() + names_by_kind: dict[str, set[str]] = defaultdict(set) + accepted_synonyms: dict[int, list[dict]] = {} + accepted_children: dict[int, list[dict]] = {} + accepted_vernaculars: dict[int, list[dict]] = {} + + for accepted_key in sorted(accepted_usages): + detail = species_detail(accepted_key, timeout) + sci = detail.get("scientificName") + if sci: + expansion_names.add(sci) + names_by_kind["acceptedScientificNames"].add(sci) + + synonyms = [summarize_usage(item) for item in species_synonyms(accepted_key, timeout)] + children = [ + summarize_usage(item) + for item in species_children(accepted_key, timeout) + if (item.get("rank") or "").upper() in {"SUBSPECIES", "VARIETY", "FORM", "FORMA"} + ] + vernaculars = species_vernaculars(accepted_key, timeout) + + accepted_synonyms[accepted_key] = synonyms + accepted_children[accepted_key] = children + accepted_vernaculars[accepted_key] = vernaculars + + for item in synonyms: + if item.get("scientificName"): + expansion_names.add(item["scientificName"]) + names_by_kind["synonyms"].add(item["scientificName"]) + for item in children: + if item.get("scientificName"): + expansion_names.add(item["scientificName"]) + names_by_kind["subordinateTaxa"].add(item["scientificName"]) + for item in vernaculars: + vernacular = item.get("vernacularName") + if vernacular: + expansion_names.add(vernacular) + names_by_kind["vernacularNames"].add(vernacular) + + return { + "query": name, + "gbifMatch": { + "usageKey": match.get("usageKey"), + "scientificName": match.get("scientificName"), + "canonicalName": match.get("canonicalName"), + "rank": match.get("rank"), + "status": match.get("status"), + "matchType": match.get("matchType"), + "confidence": match.get("confidence"), + "acceptedUsageKey": match.get("acceptedUsageKey"), + "note": match.get("note"), + }, + "exactCanonicalUsages": [exact_usages[key] for key in sorted(exact_usages)], + "sourceToAcceptedUsage": source_to_accepted, + "acceptedUsages": [accepted_usages[key] for key in sorted(accepted_usages)], + "acceptedSynonyms": accepted_synonyms, + "acceptedChildren": accepted_children, + "acceptedVernaculars": accepted_vernaculars, + "expansionNames": sorted(expansion_names), + "expansionNamesByKind": {kind: sorted(values) for kind, values in names_by_kind.items()}, + "excludedNonExactSearchHits": fuzzy_or_non_exact[:20], + } + + +def load_names(args: argparse.Namespace) -> list[str]: + names = list(args.name) + if args.names_file: + with open(args.names_file, encoding="utf-8") as fh: + names.extend(line.strip() for line in fh if line.strip()) + names = [name for name in names if name] + if not names: + raise ValueError("at least one --name or --names-file entry is required") + return names + + +def main() -> int: + args = parse_args() + try: + names = load_names(args) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + + report = { + "queries": [build_report(name, args.timeout) for name in names], + } + with open(args.output, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, ensure_ascii=True) + fh.write("\n") + print(json.dumps({"output": args.output, "queries": len(names)})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/biofid_gnfinder_fix.py b/dev/biofid_gnfinder_fix.py new file mode 100644 index 00000000..6a55fe8b --- /dev/null +++ b/dev/biofid_gnfinder_fix.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Build a conservative manifest for abbreviated gnfinder taxa that likely need repair. + +This script does not rewrite XMI yet. It scans imported corpus files directly, looks for +`gnfinder:Taxon` abbreviations such as `C. muricata`, tries to infer the genus from nearby +verified gnfinder annotations in the same file, and queries GBIF for a suggested expansion. + +The output is a JSONL manifest that we can review before deciding how to patch the XMI or +the import pipeline. +""" + +from __future__ import annotations + +import argparse +import bz2 +import gzip +import html +import json +import re +import sys +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +VERIFY_TAG_RE = re.compile(r"]*)/>") +TAXON_TAG_RE = re.compile(r"]*)/>") +ATTR_RE = re.compile(r'([A-Za-z0-9_:-]+)="(.*?)"') +ABBREV_RE = re.compile(r"^(?P[A-Z])\.\s+(?P[A-Za-z][A-Za-z.\-]*(?:\s+[A-Za-z][A-Za-z.\-]*)*)$") + + +@dataclass +class Annotation: + begin: int + end: int + value: str + attrs: dict[str, str] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument( + "--corpus-root", + required=True, + type=Path, + help="Path to a corpus root containing corpusConfig.json and input/.", + ) + parser.add_argument( + "--output", + required=True, + type=Path, + help="Path to write the JSONL manifest.", + ) + parser.add_argument( + "--limit-files", + type=int, + default=0, + help="Optional cap on the number of XMI files to scan.", + ) + parser.add_argument( + "--gbif-timeout", + type=float, + default=20.0, + help="HTTP timeout in seconds for GBIF requests.", + ) + return parser.parse_args() + + +def open_text(path: Path) -> str: + if path.suffix == ".bz2": + with bz2.open(path, "rt", encoding="utf-8", errors="replace") as fh: + return fh.read() + if path.suffix == ".gz": + with gzip.open(path, "rt", encoding="utf-8", errors="replace") as fh: + return fh.read() + return path.read_text(encoding="utf-8", errors="replace") + + +def parse_attrs(attr_blob: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for key, raw_value in ATTR_RE.findall(attr_blob): + attrs[key] = html.unescape(raw_value) + return attrs + + +def parse_annotations(pattern: re.Pattern[str], xml_text: str) -> list[Annotation]: + annotations: list[Annotation] = [] + for match in pattern.finditer(xml_text): + attrs = parse_attrs(match.group(1)) + try: + begin = int(attrs.get("begin", "-1")) + end = int(attrs.get("end", "-1")) + except ValueError: + continue + annotations.append( + Annotation( + begin=begin, + end=end, + value=attrs.get("value", ""), + attrs=attrs, + ) + ) + annotations.sort(key=lambda item: (item.begin, item.end)) + return annotations + + +def normalize_genus_candidate(value: str) -> str | None: + for token in re.split(r"\s+", value.strip()): + cleaned = re.sub(r"[^A-Za-z-]", "", token) + if cleaned and cleaned[0].isupper(): + return cleaned + return None + + +def find_nearest_genus(abbrev: Annotation, verified: list[Annotation], initial: str) -> tuple[str | None, str | None]: + genus_hits: list[tuple[int, str, str]] = [] + for item in verified: + genus = ( + normalize_genus_candidate(item.attrs.get("matchedCanonicalSimple", "")) + or normalize_genus_candidate(item.attrs.get("currentName", "")) + or normalize_genus_candidate(item.value) + ) + if genus and genus.startswith(initial): + distance = min(abs(item.begin - abbrev.begin), abs(item.end - abbrev.end)) + source = item.attrs.get("matchedName") or item.attrs.get("currentName") or item.value + genus_hits.append((distance, genus, source)) + if not genus_hits: + return None, None + genus_hits.sort(key=lambda entry: entry[0]) + _, genus, source = genus_hits[0] + return genus, source + + +def gbif_json(url: str, timeout: float) -> dict: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/json", + "User-Agent": "codex-biofid-fix/1.0", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + + +def query_gbif(name: str, timeout: float) -> dict: + encoded = urllib.parse.quote(name) + match_url = f"https://api.gbif.org/v1/species/match?verbose=true&name={encoded}" + search_url = f"https://api.gbif.org/v1/species/search?q={encoded}&limit=10" + try: + match_data = gbif_json(match_url, timeout) + except Exception as exc: # noqa: BLE001 + match_data = {"error": str(exc)} + try: + search_data = gbif_json(search_url, timeout) + except Exception as exc: # noqa: BLE001 + search_data = {"error": str(exc)} + + top_hits = [] + for result in search_data.get("results", [])[:5]: + top_hits.append( + { + "key": result.get("key"), + "scientificName": result.get("scientificName"), + "canonicalName": result.get("canonicalName"), + "rank": result.get("rank"), + "taxonomicStatus": result.get("taxonomicStatus"), + "acceptedKey": result.get("acceptedKey"), + "accepted": result.get("accepted"), + } + ) + + return { + "match": { + "usageKey": match_data.get("usageKey"), + "scientificName": match_data.get("scientificName"), + "canonicalName": match_data.get("canonicalName"), + "rank": match_data.get("rank"), + "status": match_data.get("status"), + "matchType": match_data.get("matchType"), + "confidence": match_data.get("confidence"), + "note": match_data.get("note"), + "acceptedUsageKey": match_data.get("acceptedUsageKey"), + }, + "topSearchHits": top_hits, + } + + +def iter_xmi_files(input_dir: Path) -> Iterable[Path]: + for path in sorted(input_dir.iterdir()): + if path.is_file() and ( + path.name.endswith(".xmi") + or path.name.endswith(".xmi.gz") + or path.name.endswith(".xmi.bz2") + ): + yield path + + +def main() -> int: + args = parse_args() + input_dir = args.corpus_root / "input" + if not input_dir.is_dir(): + print(f"input directory not found: {input_dir}", file=sys.stderr) + return 1 + + args.output.parent.mkdir(parents=True, exist_ok=True) + processed = 0 + matches = 0 + + with args.output.open("w", encoding="utf-8") as out: + for xmi_path in iter_xmi_files(input_dir): + if args.limit_files and processed >= args.limit_files: + break + processed += 1 + xml_text = open_text(xmi_path) + verified = parse_annotations(VERIFY_TAG_RE, xml_text) + unresolved = parse_annotations(TAXON_TAG_RE, xml_text) + + for item in unresolved: + match = ABBREV_RE.match(item.value.strip()) + if not match: + continue + initial = match.group("initial") + remainder = match.group("rest") + inferred_genus, genus_source = find_nearest_genus(item, verified, initial) + expanded = f"{inferred_genus} {remainder}" if inferred_genus else None + gbif = query_gbif(expanded, args.gbif_timeout) if expanded else None + record = { + "documentId": xmi_path.stem.replace(".xmi", ""), + "file": str(xmi_path), + "begin": item.begin, + "end": item.end, + "abbreviation": item.value, + "inferredGenus": inferred_genus, + "genusSource": genus_source, + "expandedCandidate": expanded, + "gbif": gbif, + } + out.write(json.dumps(record, ensure_ascii=True) + "\n") + matches += 1 + + print( + json.dumps( + { + "filesProcessed": processed, + "abbreviationCandidates": matches, + "output": str(args.output), + }, + ensure_ascii=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev/build-java-images-fast.sh b/dev/build-java-images-fast.sh new file mode 100755 index 00000000..25194c48 --- /dev/null +++ b/dev/build-java-images-fast.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fast local build for UCE Java images with persistent BuildKit cache. +# This keeps Maven dependencies in a reusable local cache to avoid re-downloading. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CACHE_ROOT="${ROOT_DIR}/.dev/storage/buildkit" +mkdir -p "${CACHE_ROOT}" + +export DOCKER_BUILDKIT=1 + +build_image() { + local dockerfile="$1" + local tag="$2" + local cache_dir="$3" + mkdir -p "${cache_dir}" + + docker buildx build \ + --load \ + --file "${dockerfile}" \ + --tag "${tag}" \ + --cache-from "type=local,src=${cache_dir}" \ + --cache-to "type=local,dest=${cache_dir},mode=max" \ + "${ROOT_DIR}" +} + +TARGET="${1:-all}" + +if [[ "${TARGET}" == "all" || "${TARGET}" == "web" ]]; then + build_image \ + "${ROOT_DIR}/uce.portal/uce.web/Dockerfile" \ + "uce-web:local-fast" \ + "${CACHE_ROOT}/uce-web" +fi + +if [[ "${TARGET}" == "all" || "${TARGET}" == "importer" ]]; then + build_image \ + "${ROOT_DIR}/uce.portal/uce.corpus-importer/Dockerfile" \ + "uce-importer:local-fast" \ + "${CACHE_ROOT}/uce-importer" +fi + +echo "Built images:" +if [[ "${TARGET}" == "all" || "${TARGET}" == "web" ]]; then + echo " uce-web:local-fast" +fi +if [[ "${TARGET}" == "all" || "${TARGET}" == "importer" ]]; then + echo " uce-importer:local-fast" +fi +echo "BuildKit cache persisted under ${CACHE_ROOT}" diff --git a/dev/build-push-importer.sh b/dev/build-push-importer.sh new file mode 100755 index 00000000..8ff034e4 --- /dev/null +++ b/dev/build-push-importer.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + dev/build-push-importer.sh [image_repo] + +Examples: + dev/build-push-importer.sh 0.1.2 + dev/build-push-importer.sh 0.1.2 docker.texttechnologylab.org/uce-core-feedback-importer + +What it does: + - Builds the importer image from ./uce.portal/uce.corpus-importer/Dockerfile (clean: --no-cache, --pull) + - Tags it as : + - Pushes it to the registry + +Notes: + - Requires a working Docker daemon and that you are logged in to the target registry. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" || $# -lt 1 || $# -gt 2 ]]; then + usage + exit 1 +fi + +VERSION="$1" +IMAGE_REPO="${2:-docker.texttechnologylab.org/uce-core-feedback-importer}" +IMAGE_TAG="${IMAGE_REPO}:${VERSION}" + +if [[ ! "$VERSION" =~ ^[0-9]+(\.[0-9]+){1,3}([.-][A-Za-z0-9]+)?$ ]]; then + echo "Invalid version: $VERSION" >&2 + exit 2 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if [[ ! -f "./uce.portal/uce.corpus-importer/Dockerfile" ]]; then + echo "Missing Dockerfile: ./uce.portal/uce.corpus-importer/Dockerfile" >&2 + exit 3 +fi + +echo "Building importer image: ${IMAGE_TAG}" +docker build --pull --no-cache \ + -f ./uce.portal/uce.corpus-importer/Dockerfile \ + -t "${IMAGE_TAG}" \ + . + +echo "Pushing importer image: ${IMAGE_TAG}" +docker push "${IMAGE_TAG}" + +echo "Done: ${IMAGE_TAG}" diff --git a/docker-compose.yaml b/docker-compose.yaml index ed49ebbc..302b5d1a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,106 +7,300 @@ services: - # uce-fuseki-sparql: - # container_name: uce-fuseki-sparql - # image: docker.texttechnologylab.org/uce/uce-fuseki-sparql:0.0.1 - # ports: - # - "8030:5430" - # volumes: - # - ${TDB2_DATA}:/fuseki/databases/${TDB2_ENDPOINT} - # environment: - # - JAVA_OPTIONS=${JAVA_OPTIONS} - # command: --update --tdb2 --port 5430 --loc /fuseki/databases/${TDB2_ENDPOINT} /${TDB2_ENDPOINT} - # networks: - # - app_net - - # uce-rag-service: - # container_name: uce-rag-service - # build: - # context: . - # dockerfile: ./rag/Dockerfile - # ports: - # - "8080:5678" - # depends_on: - # - uce-postgresql-db - # networks: - # - app_net + nginx: + container_name: uce-nginx + image: nginx:1.27-alpine + pull_policy: always + profiles: + - proxy + ports: + - "${NGINX_PROXY_HTTP_PORT:-80}:80" + environment: + # Used by /etc/nginx/templates/*.template via envsubst (official nginx image feature) + UCE_VIRTUAL_HOST: ${UCE_PUBLIC_HOST:-} + UCE_VIRTUAL_PORT: ${UCE_PUBLISHED_PORT:-} + KEYCLOAK_VIRTUAL_HOST: ${KEYCLOAK_PUBLIC_HOST:-} + KEYCLOAK_VIRTUAL_PORT: ${KEYCLOAK_PUBLISHED_PORT:-} + volumes: + - ./deploy/nginx/templates:/etc/nginx/templates:ro + depends_on: + uce-web: + condition: service_started + uce-keycloak-auth: + condition: service_started + networks: + - app_net uce-web: container_name: uce-web + image: uce-web:local-fast build: context: . dockerfile: ./uce.portal/uce.web/Dockerfile + profiles: + - local + - remote + - remotekc + - remotedb + - ssh + - proxy + env_file: + - .env + environment: + VIRTUAL_HOST: ${UCE_PUBLIC_HOST:-} + VIRTUAL_PORT: ${UCE_PUBLISHED_PORT:-} + # Compose-derived legacy runtime vars (deduplicated from .env host/port primitives). + UCE_AUTH_PUBLIC_URL: ${KEYCLOAK_PUBLIC_SCHEME:-http}://${KEYCLOAK_PUBLIC_HOST:-}:${KEYCLOAK_PUBLISHED_PORT:-} + UCE_AUTH_REDIRECT_URL: ${UCE_SCHEME:-http}://${KEYCLOAK_PUBLIC_HOST:-}:${UCE_PUBLISHED_PORT:-}/auth + KEYCLOAK_AUTH_SERVER_URL: ${KEYCLOAK_INTERNAL_SCHEME:-http}://uce-keycloak-auth:${KEYCLOAK_INTERNAL_PORT:-8080} + POSTGRESQL_HIBERNATE_CONNECTION_URL: jdbc:postgresql://${DB_HOST:-}:${DB_RUNTIME_PORT:-5432}/${DB_NAME:-} + POSTGRESQL_HIBERNATE_CONNECTION_USERNAME: ${DB_USER:-} + POSTGRESQL_HIBERNATE_CONNECTION_PASSWORD: ${DB_PASSWORD:-} ports: - - "8008:4567" + - "${UCE_PUBLISHED_PORT:-}:${UCE_INTERNAL_PORT:-4567}" depends_on: uce-postgresql-db: condition: service_healthy + required: false networks: - app_net volumes: - - "${UCE_CONFIG_PATH}:/app/config/uceConfig.json" - - "./database:/app/database" + - "${UCE_CONFIG_PATH:-./uce.portal/uce.common/src/main/resources/defaultUceConfig.json}:/app/config/uceConfig.json:ro" + - "${UCE_DATABASE_SCRIPTS_HOST_PATH:-./database}:${DATABASE_SCRIPTS_LOCATION:-/app/database}" + # External templates (host-mounted) so UI can be edited without rebuilding the image + - "${UCE_TEMPLATES_HOST_PATH:-./uce.portal/resources/templates}:${TEMPLATES_LOCATION:-/app/uce.portal/resources/templates}" command: [ "java", "--add-opens=java.base/java.util=ALL-UNNAMED", - "--add-opens=java.util/java.base=ALL-UNNAMED", "-jar", "./target/webportal-jar-with-dependencies.jar", "-cf", "/app/config/uceConfig.json" ] + uce-ssh-pg-db-tunnel: + container_name: uce-ssh-pg-db-tunnel + image: alpine:3.21 + pull_policy: always + env_file: + - .env + environment: + UCE_SSH_HOST: ${SSH_HOST:-} + UCE_DB_HOST: ${SSH_REMOTE_DB_HOST:-} + UCE_DB_PORT: ${SSH_REMOTE_DB_PORT:-} + UCE_LOCAL_FORWARD_PORT: ${SSH_LOCAL_PORT:-} + UCE_SSH_PASSWORD: ${SSH_PASSWORD:-} + profiles: + - ssh + restart: unless-stopped + entrypoint: ["/bin/sh", "-ec"] + command: + - | + apk add --no-cache openssh-client sshpass + mkdir -p /root/.ssh + chmod 700 /root/.ssh + if [ -z "$${UCE_SSH_HOST:-}" ]; then + echo "UCE_SSH_HOST is not set. Configure .env before enabling profile ssh." + sleep infinity + fi + while true; do + if [ -n "$${UCE_SSH_PASSWORD:-}" ]; then + SSHPASS="$${UCE_SSH_PASSWORD}" sshpass -e ssh \ + -N \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o TCPKeepAlive=yes \ + -o StrictHostKeyChecking=accept-new \ + -L 0.0.0.0:$${UCE_LOCAL_FORWARD_PORT:-}:$${UCE_DB_HOST:-}:$${UCE_DB_PORT:-} \ + "$${UCE_SSH_HOST}" + else + ssh -i /root/.ssh/id_key \ + -N \ + -o ExitOnForwardFailure=yes \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=3 \ + -o TCPKeepAlive=yes \ + -o StrictHostKeyChecking=accept-new \ + -L 0.0.0.0:$${UCE_LOCAL_FORWARD_PORT:-}:$${UCE_DB_HOST:-}:$${UCE_DB_PORT:-} \ + "$${UCE_SSH_HOST}" + fi + echo "SSH tunnel disconnected, retrying in 5s..." + sleep 5 + done + volumes: + - ${UCE_SSH_KEY_PATH:-/dev/null}:/root/.ssh/id_key:ro + expose: + - "${UCE_LOCAL_FORWARD_PORT:-}" + networks: + - app_net uce-importer: container_name: uce-importer + profiles: + - import + image: uce-importer:local-fast build: context: . dockerfile: ./uce.portal/uce.corpus-importer/Dockerfile - depends_on: - uce-postgresql-db: - condition: service_healthy + env_file: + - .env + environment: + POSTGRESQL_HIBERNATE_CONNECTION_URL: jdbc:postgresql://${DB_HOST:-}:${DB_RUNTIME_PORT:-5432}/${DB_NAME:-} + POSTGRESQL_HIBERNATE_CONNECTION_USERNAME: ${DB_USER:-} + POSTGRESQL_HIBERNATE_CONNECTION_PASSWORD: ${DB_PASSWORD:-} networks: - app_net volumes: - - "./database:/app/database" - # MOUNT HERE ALL UIMA CORPORA INTO THE '/app/input/corpora/' PATH - EXAMPLE BELOW FOR ZOBODAT CORPUS: - # - "./../test_data/corpora/zobodat:/app/input/corpora/zobodat" - - "./../uce_testcorpora:/app/input/corpora/uce_testcorpora" + # Optional: DB scripts for triggers/procedures. The importer will attempt to run them; if missing it continues. + - "${IMPORTER_DATABASE_HOST_PATH:-./database}:/app/database:ro" + # Mount the corpus input directory (XMI/archives + corpusConfig.json) + - "${IMPORTER_CORPORA_HOST_PATH:-./data/importer}:/app/input:ro" command: [ "java", "-jar", "./target/importer.jar", - "-srcDir", "/app/input/corpora/", - "-num", "1", - "-t", "${IMPORTER_THREADS}" + "-src", "${IMPORTER_IMPORT_SRC:-/app/input}", + "-num", "${IMPORTER_NUMBER:-1}", + "-t", "${IMPORTER_THREADS:-4}" ] uce-postgresql-db: container_name: uce-postgresql-db - image: docker.texttechnologylab.org/uce/uce-postgresql:0.0.1 + image: ${POSTGRES_IMAGE:-docker.texttechnologylab.org/uce/uce-postgresql:latest} + pull_policy: always + profiles: + - local + - remotekc + - db + env_file: + - .env environment: - POSTGRES_DB: uce - POSTGRES_USER: postgres - POSTGRES_PASSWORD: 1234 + POSTGRES_DB: ${DB_NAME:-} + POSTGRES_USER: ${DB_USER:-} + POSTGRES_PASSWORD: ${DB_PASSWORD:-} ports: - - "8002:5432" + - "${DB_PORT:-5432}:${DB_INTERNAL_PORT:-5432}" volumes: + - ${POSTGRES_DATA_HOST_PATH:-./.dev/storage/postgres}:/var/lib/postgresql/data #- ./../backups/2025-07-03_BIOfid_Nova-Data/db-data:/var/lib/postgresql/data - - ${POSTGRESQL_CONFIG}:/etc/postgresql.conf + - ${POSTGRESQL_CONFIG:-./database/postgresql.conf}:/etc/postgresql.conf command: -c config_file=/etc/postgresql.conf networks: - app_net healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-}"] interval: 5s timeout: 5s start_period: 10s retries: 20 + uce-keycloak-auth: + container_name: uce-keycloak-auth + image: ${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:26.2.5} + pull_policy: always + profiles: + - local + - remotedb + - ssh + - proxy + env_file: + - .env + ports: + - "${KEYCLOAK_PUBLISHED_PORT:-}:${KEYCLOAK_INTERNAL_PORT:-8080}" + volumes: + - ${KC_REALM_IMPORT_PATH:-./auth/uce-realm.json}:/opt/keycloak/data/import/uce-realm.json + # Persist Keycloak data (incl. embedded DB) outside the container + # - ${KEYCLOAK_DATA_HOST_PATH:-./data/keycloak}:/opt/keycloak/data + command: + - ${KEYCLOAK_START_CMD:-start} + - --features=${KEYCLOAK_FEATURES:-scripts} + - --import-realm + - --import-path=${KEYCLOAK_IMPORT_PATH:-/opt/keycloak/data/import/uce-realm.json} + environment: + VIRTUAL_HOST: ${KEYCLOAK_PUBLIC_HOST:-} + VIRTUAL_PORT: ${KEYCLOAK_PUBLISHED_PORT:-} + KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USERNAME:-} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PW:-} + KC_HOSTNAME_STRICT_BACKCHANNEL: ${KC_HOSTNAME_STRICT_BACKCHANNEL:-true} + KC_HOSTNAME_STRICT: ${KC_HOSTNAME_STRICT:-false} + KC_HTTP_RELATIVE_PATH: ${KC_HTTP_RELATIVE_PATH:-/} + KC_HTTP_ENABLED: ${KC_HTTP_ENABLED:-true} + KC_HEALTH_ENABLED: ${KC_HEALTH_ENABLED:-true} + KC_METRICS_ENABLED: ${KC_METRICS_ENABLED:-true} + extra_hosts: + - "host.docker.internal:host-gateway" + healthcheck: + # Keycloak 26 exposes health endpoints on the management interface (port 9000). + test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:9000/health/live'] + interval: 5s + timeout: 5s + retries: 20 + networks: + - app_net + + uce-keycloak-config: + image: ${KEYCLOAK_CONFIG_IMAGE:-docker.texttechnologylab.org/uce-core-feedback-keycloak-config:latest} + pull_policy: always + container_name: uce-keycloak-config + profiles: + - local + - remotedb + - ssh + - proxy + env_file: + - .env + depends_on: + uce-keycloak-auth: + condition: service_healthy + environment: + KC_BASE_URL: ${KEYCLOAK_INTERNAL_SCHEME:-http}://uce-keycloak-auth:${KEYCLOAK_INTERNAL_PORT:-8080} + KC_MGMT_URL: ${KEYCLOAK_INTERNAL_SCHEME:-http}://uce-keycloak-auth:${KEYCLOAK_MGMT_PORT:-9003} + KC_REALM: ${KC_REALM:-uce} + KC_CLIENT_ID: ${KC_CLIENT_ID:-uce-web} + KC_ADMIN_USERNAME: ${KC_ADMIN_USERNAME:-} + KC_ADMIN_PW: ${KC_ADMIN_PW:-} + UCE_PUBLIC_URL: ${UCE_SCHEME:-http}://${UCE_PUBLIC_HOST:-localhost}:${UCE_PUBLISHED_PORT:-} + KEYCLOAK_CREDENTIALS_SECRET: ${KEYCLOAK_CREDENTIALS_SECRET:-} + KC_SSL_REQUIRED: ${KC_SSL_REQUIRED:-none} + KC_SSO_SESSION_IDLE_TIMEOUT: ${KC_SSO_SESSION_IDLE_TIMEOUT:-} + networks: + - app_net + + uce-keycloak-sync-from-csv: + container_name: uce-keycloak-sync-from-csv + profiles: + - keycloak-csv + image: ${KEYCLOAK_SYNC_IMAGE:-docker.texttechnologylab.org/uce-core-feedback-keycloak-sync-from-csv:latest} + pull_policy: always + # build: + # context: . + # dockerfile: ./deploy/keycloak-sync-from-csv.Dockerfile + env_file: + - .env + environment: + KC_BASE_URL: ${KEYCLOAK_INTERNAL_SCHEME:-http}://${KEYCLOAK_PUBLIC_HOST:-}:${KEYCLOAK_PUBLISHED_PORT:-} + KC_REALM: ${KC_REALM:-} + KC_ADMIN_USERNAME: ${KC_ADMIN_USERNAME:-} + KC_ADMIN_PW: ${KC_ADMIN_PW:-} + CSV_PATH: ${KEYCLOAK_SYNC_CSV_PATH_CONTAINER:-/data/principals.csv} + MAX_ROWS: ${KEYCLOAK_SYNC_MAX_ROWS:-} + DRY_RUN: ${KEYCLOAK_SYNC_DRY_RUN:-false} + CREATE_GROUPS: ${KEYCLOAK_SYNC_CREATE_GROUPS:-true} + CREATE_USERS: ${KEYCLOAK_SYNC_CREATE_USERS:-true} + UPDATE_USERS: ${KEYCLOAK_SYNC_UPDATE_USERS:-true} + volumes: + - ${KEYCLOAK_SYNC_CSV_PATH_HOST:-./principals.csv}:${KEYCLOAK_SYNC_CSV_PATH_CONTAINER:-/data/principals.csv}:ro + networks: + - app_net + uce-minio-storage: image: minio/minio container_name: minio + profiles: + - minio + env_file: + - .env ports: - "9000:9000" - "9001:9001" @@ -114,39 +308,39 @@ services: - MINIO_ROOT_USER=admin - MINIO_ROOT_PASSWORD=12345678 volumes: - - ${MINIO_STORAGE_DATA}:/data + - ${MINIO_STORAGE_DATA:-./data/minio}:/data command: server /data --console-address ":9001" networks: - app_net - - uce-keycloak-auth: - container_name: uce-keycloak-auth - image: quay.io/keycloak/keycloak:26.2.5 + + uce-fuseki-sparql: + container_name: uce-fuseki-sparql + image: docker.texttechnologylab.org/uce/uce-fuseki-sparql:0.0.1 + profiles: + - fuseki ports: - - "8080:8080" + - "8030:5430" volumes: - - ${KC_REALM_IMPORT_PATH}:/opt/keycloak/data/import/uce-realm.json - command: - - start - - --features=scripts - - --import-realm - - --import-path=/opt/keycloak/data/import/uce-realm.json + - ${TDB2_DATA}:/fuseki/databases/${TDB2_ENDPOINT} environment: - KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_ADMIN_USERNAME} - KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PW} - KC_HOSTNAME_STRICT_BACKCHANNEL: true - KC_HOSTNAME_STRICT: false - KC_HTTP_RELATIVE_PATH: / - KC_HTTP_ENABLED: true - KC_HEALTH_ENABLED: true - KC_METRICS_ENABLED: true - extra_hosts: - - "host.docker.internal:host-gateway" - healthcheck: - test: ['CMD-SHELL', '[ -f /tmp/HealthCheck.java ] || echo "public class HealthCheck { public static void main(String[] args) throws java.lang.Throwable { System.exit(java.net.HttpURLConnection.HTTP_OK == ((java.net.HttpURLConnection)new java.net.URL(args[0]).openConnection()).getResponseCode() ? 0 : 1); } }" > /tmp/HealthCheck.java && java /tmp/HealthCheck.java http://localhost:8080/auth/health/live'] - interval: 5s - timeout: 5s - retries: 20 + - JAVA_OPTIONS=${JAVA_OPTIONS} + command: --update --tdb2 --port 5430 --loc /fuseki/databases/${TDB2_ENDPOINT} /${TDB2_ENDPOINT} + networks: + - app_net + + uce-rag-service: + container_name: uce-rag-service + profiles: + - rag + build: + context: . + dockerfile: ./rag/Dockerfile + ports: + - "8080:5678" + depends_on: + uce-postgresql-db: + condition: service_started + required: false networks: - app_net diff --git a/protocol.md b/protocol.md new file mode 100644 index 00000000..35b2a8de --- /dev/null +++ b/protocol.md @@ -0,0 +1,13 @@ + +### 2026-03-25 21:10:43 +0100 - Taxon enrichment matching tightened +- Root cause identified: taxon candidate lookup allowed prefix matching (), causing to resolve to . +- Change: now ranks/filters by + - exact match, or + - whole-word boundary regex match + before optional contains/fuzzy fallbacks. +- Validation: compile success; DB inspection confirms has no exact/word-boundary taxon in current corpus. + +### 2026-03-25 - Taxon enrichment matching tightened +- Root cause identified: taxon candidate lookup allowed prefix matching (`LOWER(primaryname) LIKE :prefix`), causing `Raupe` to resolve to `Raupen-Kernkeule`. +- Change: `getIdentifiableTaxonsByValue` now ranks/filters by exact match or whole-word boundary regex match before optional contains/fuzzy fallbacks. +- Validation: compile success; DB inspection confirms `Raupe` has no exact/word-boundary taxon in current corpus. diff --git a/uce.portal/resources/templates/corpus/components/documents.ftl b/uce.portal/resources/templates/corpus/components/documents.ftl index 0f5afd33..8ca18244 100644 --- a/uce.portal/resources/templates/corpus/components/documents.ftl +++ b/uce.portal/resources/templates/corpus/components/documents.ftl @@ -2,6 +2,7 @@
<#assign searchId = ""> + <#assign showFeatureValuesInCard = (uceConfig.settings.ui.corpusInspector.showAnnotations)!true> <#include '*/search/components/documentCardContent.ftl' >
diff --git a/uce.portal/resources/templates/corpus/corpusInspector.ftl b/uce.portal/resources/templates/corpus/corpusInspector.ftl index 484c6790..06f1aab0 100644 --- a/uce.portal/resources/templates/corpus/corpusInspector.ftl +++ b/uce.portal/resources/templates/corpus/corpusInspector.ftl @@ -5,52 +5,70 @@
-
- - - -
-
${corpus.getName()}
-
-

${languageResource.get("corpusInspector")}

+ <#if (uceConfig.settings.ui.corpusInspector.showHeader)!true> +
+
+ + + +
+
+
${corpus.getName()}
+
+

${languageResource.get("corpusInspector")}

+
+
+ <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + + + + +
- - - -
+
-
Meta
-
-
- <#include "*/corpus/components/corpusMetadata.ftl" /> + <#if (uceConfig.settings.ui.corpusInspector.showMeta)!true> +
Meta
+
+
+ <#include "*/corpus/components/corpusMetadata.ftl" /> +
-
+ -
Annotations
-
- <#include "*/corpus/components/corpusAnnotations.ftl"/> -
+ <#if (uceConfig.settings.ui.corpusInspector.showAnnotations)!true> +
Annotations
+
+ <#include "*/corpus/components/corpusAnnotations.ftl"/> +
+
-
-
-
-
${languageResource.get("corpusDocuments")}
- - ${languageResource.get("callForSearch")} - + <#if (uceConfig.settings.ui.corpusInspector.showDocuments)!true> +
+
+
+
${languageResource.get("corpusDocuments")}
+ <#if (uceConfig.settings.ui.corpusInspector.showSearchHint)!true> + + ${languageResource.get("callForSearch")} + + +
+
+
+
Loading...
-
-
Loading...
-
-
+
- diff --git a/uce.portal/resources/templates/corpus/corpusUniverse.ftl b/uce.portal/resources/templates/corpus/corpusUniverse.ftl index 0665698a..72c48155 100644 --- a/uce.portal/resources/templates/corpus/corpusUniverse.ftl +++ b/uce.portal/resources/templates/corpus/corpusUniverse.ftl @@ -21,6 +21,8 @@
+<#include "*/sessionExpiredModal.ftl"> +<#include "*/auth/userShortProfile.ftl">
@@ -122,4 +124,4 @@ - \ No newline at end of file + diff --git a/uce.portal/resources/templates/corpus/globe.ftl b/uce.portal/resources/templates/corpus/globe.ftl index ac74a3a3..b8d14f7e 100644 --- a/uce.portal/resources/templates/corpus/globe.ftl +++ b/uce.portal/resources/templates/corpus/globe.ftl @@ -14,6 +14,8 @@ +<#include "*/sessionExpiredModal.ftl"> +<#include "*/auth/userShortProfile.ftl">
@@ -257,4 +259,4 @@ - \ No newline at end of file + diff --git a/uce.portal/resources/templates/css/corpus-inspector.css b/uce.portal/resources/templates/css/corpus-inspector.css index e3d5367b..dd3e4f69 100644 --- a/uce.portal/resources/templates/css/corpus-inspector.css +++ b/uce.portal/resources/templates/css/corpus-inspector.css @@ -21,6 +21,18 @@ background-color: rgba(245, 245, 247, 1); } +.corpus-inspector .cheader .cheader-side{ + width: 40px; + min-width: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.corpus-inspector .cheader .cheader-center{ + flex: 1; +} + .corpus-inspector .cheader a{ border-radius: 50%; display: flex; diff --git a/uce.portal/resources/templates/css/document-reader.css b/uce.portal/resources/templates/css/document-reader.css index 693acdd7..1b41e189 100644 --- a/uce.portal/resources/templates/css/document-reader.css +++ b/uce.portal/resources/templates/css/document-reader.css @@ -31,9 +31,18 @@ }*/ body { + --reader-sidebar-width: 500px; + --reader-sidebar-viz-width: min(74vw, 1240px); + --reader-sidebar-drawer-width: min(92vw, 620px); + --reader-sidebar-drawer-viz-width: min(96vw, 840px); + --reader-middle-min: 640px; + --reader-middle-ideal: 52vw; + --reader-middle-max: 1120px; + overflow-x: hidden; } .pages-loader-popup { + /* display: none !important; */ position: fixed; left: 12px; bottom: 12px; @@ -50,14 +59,145 @@ body { padding-bottom: 3rem; } +.reader-shell-container { + padding-top: 0; + padding-bottom: 0; +} + +.reader-view-slot { + min-width: 0; +} + +.reader-view-layout { + display: flex; + align-items: stretch; + width: 100%; + min-height: 100vh; + position: relative; +} + +/* Reader annotation popovers/tooltips must render above both left/right drawer handles. */ +body .popover, +body .tooltip { + z-index: 2306 !important; +} + +.reader-middle-pane { + flex: 1 1 auto; + min-width: 0; + display: flex; + justify-content: center; +} + +.reader-news-column { + width: clamp(var(--reader-middle-min), var(--reader-middle-ideal), var(--reader-middle-max)); + max-width: calc(100vw - 48px); +} + +/* Feedback reader view: no artificial top/bottom spacing around the central pane. */ +body.feedback-layout .site-container, +body.feedback-layout .site-container.with-view-nav { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + .reader-container { padding: 0 !important; background-color: ghostwhite; border: lightgray 1px solid; box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; + min-height: 100vh; +} + +.reader-main { + min-width: 0; + flex: 1 1 auto; +} + +/* Feedback-only fallback: keep feedback middle pane centered. */ +body.feedback-layout .container-fluid > .flexed.m-0.p-0 { + display: block !important; + position: relative; +} + +body.feedback-layout .reader-main.w-100 { + width: clamp(760px, 56vw, 1180px) !important; + max-width: calc(100vw - 48px) !important; + margin-left: auto !important; + margin-right: auto !important; + flex: none !important; +} + +body.feedback-layout .reader-main.w-100 > .reader-container.container { + width: 100% !important; + max-width: none !important; + margin-left: 0 !important; + margin-right: 0 !important; } -.reader-container .header { +body.feedback-layout.sidebar-drawer-mode .reader-main.w-100 { + width: clamp(760px, 62vw, 1280px) !important; +} + +/* Feedback reader hard-centering fallback (class-driven, browser-proof). */ +body.feedback-layout .container-fluid > .flexed.feedback-row-centered { + display: block !important; + position: relative; +} + +body.feedback-layout .reader-main.reader-feedback-centered { + width: clamp(760px, 56vw, 1180px) !important; + max-width: calc(100vw - 48px) !important; + margin-left: auto !important; + margin-right: auto !important; + flex: none !important; +} + +/* DOM-driven fallback: if feedback content exists, center regardless of body mode classes. */ +.container-fluid > .flexed.m-0.p-0:has(.feedback-main) { + display: block !important; + position: relative; +} + +.reader-main:has(.feedback-main) { + width: clamp(760px, 56vw, 1180px) !important; + max-width: calc(100vw - 48px) !important; + margin-left: auto !important; + margin-right: auto !important; + flex: none !important; +} + +.reader-main:has(.feedback-main) > .reader-container.container { + width: 100% !important; + max-width: none !important; + margin-left: 0 !important; + margin-right: 0 !important; +} + +.reader-main:has(.feedback-main) .reader-container > .reader-middle-header { + display: none !important; +} + +body.sidebar-drawer-mode .reader-main:has(.feedback-main) { + width: clamp(760px, 62vw, 1280px) !important; +} + +body.feedback-layout .reader-main.reader-feedback-centered > .reader-container.container { + width: 100% !important; + max-width: none !important; + margin-left: 0 !important; + margin-right: 0 !important; +} + +body.feedback-layout .reader-main.reader-feedback-centered .reader-container > .reader-middle-header { + display: none !important; +} + +body.feedback-layout.sidebar-drawer-mode .reader-main.reader-feedback-centered { + width: clamp(760px, 62vw, 1280px) !important; +} + +.reader-middle-header { position: sticky; box-shadow: rgba(0, 0, 0, 0.1) 0px 10px 50px; padding: 2.5rem; @@ -67,6 +207,35 @@ body { background-color: ghostwhite; } +.reader-middle-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.reader-middle-header-title-wrap { + margin-left: 0.5rem; + margin-right: 0.5rem; + text-align: center; + flex: 1 1 auto; +} + +.reader-middle-header-title-wrap h5 { + margin-bottom: 0.35rem; +} + +.reader-middle-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reader-middle-header-language { + min-width: 2ch; + text-align: right; +} + /* Topic navigation buttons */ .reader-container .topic-navigation-buttons { position: fixed; @@ -395,18 +564,58 @@ body { } .side-bar { - width: 500px; + width: var(--reader-sidebar-width); + flex: 0 0 auto; transition: 0.5s; background-color: ghostwhite; border-left: lightgray 1px solid; border-top: lightgray 1px solid; border-right: lightgray 1px solid; - position: sticky; + position: fixed; right: 0; - top: -1px; + top: 0; overflow-y: auto; box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; height: 100vh; + z-index: 1100; +} + +.side-bar.sidebar-collapsed { + width: 20px !important; + min-width: 20px !important; + max-width: 20px !important; + overflow: hidden !important; +} + +.side-bar.sidebar-collapsed .tab-header, +.side-bar.sidebar-collapsed .side-bar-content, +.side-bar.sidebar-collapsed .title-image, +.side-bar.sidebar-collapsed .tab-content { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; +} + +.sidebar-drawer-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + position: fixed; + right: var(--sidebar-drawer-open-width, var(--reader-sidebar-width)); + top: 50%; + transform: translateY(-50%); + z-index: 1205; + border: 1px solid lightgray; + background: ghostwhite; + color: var(--secondary); + border-right: 0; + border-radius: 12px 0 0 12px; + width: 40px; + height: 72px; +} + +.sidebar-drawer-backdrop { + display: none; } .side-bar .title-image { @@ -626,6 +835,11 @@ body { display: flex; border-bottom: 1px solid #ccc; margin-bottom: 10px; + position: sticky; + top: 0; + z-index: 1110; + background: ghostwhite; + pointer-events: auto; } .tab-header .tab-btn { @@ -636,6 +850,9 @@ body { background: #f7f7f7; border-right: 1px solid #ccc; font-weight: bold; + position: relative; + z-index: 1111; + pointer-events: auto; } .tab-header .tab-btn:last-child { @@ -651,14 +868,16 @@ body { display: none; height: 100%; position: relative; + z-index: 1; } .tab-content .tab-pane.active { display: block; } .side-bar.visualization-expanded { - width: 150vw !important; - transition: width 0.3s ease; + width: var(--reader-sidebar-viz-width) !important; + flex-basis: var(--reader-sidebar-viz-width) !important; + transition: width 0.3s ease, flex-basis 0.3s ease; } .tab-pane .visualization-wrapper { @@ -708,27 +927,36 @@ body { } /* Bottom Navigation */ -.tab-pane .viz-bottom-nav { - position: fixed; - right: 0%; - bottom: 30px; - transform: translateX(-50%); +.side-bar .tab-pane .viz-bottom-nav { + position: absolute; + left: 16px; + right: 16px; + bottom: 16px; + transform: none; width: auto; min-width: 320px; - max-width: 200vw; + max-width: none; display: flex; - justify-content: space-around; + justify-content: center; + gap: 6px; border-radius: 24px; box-shadow: 0 4px 24px rgba(0,0,0,0.12); background: #fff; border: 1px solid #e0e0e0; padding: 0.5rem 1.5rem; - z-index: 100; + z-index: 10; white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; } .tab-pane .viz-nav-btn { - width: 100%; + width: auto; + min-width: max-content; + flex: 0 0 auto; + max-width: 180px; padding: 8px; border: none; background: none; @@ -737,6 +965,9 @@ body { font-weight: 600; color: #555; transition: color 0.2s, background 0.2s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .viz-nav-btn.active { color: #007bff; @@ -775,6 +1006,64 @@ body { outline: none; } +body.sidebar-drawer-mode:not(.sidebar-drawer-open) .sidebar-drawer-toggle { + right: 0; +} + +body.sidebar-drawer-mode.sidebar-drawer-open .sidebar-drawer-toggle { + right: var(--sidebar-drawer-open-width, var(--reader-sidebar-drawer-width)); +} + +body.sidebar-drawer-mode .sidebar-drawer-backdrop { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + z-index: 1200; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.2s ease; +} + +body.sidebar-drawer-mode .side-bar { + position: fixed; + top: 0; + right: 0; + width: var(--reader-sidebar-drawer-width); + flex-basis: var(--reader-sidebar-drawer-width); + max-width: 620px; + height: 100vh; + z-index: 1201; + transform: translateX(102%); + transition: transform 0.25s ease, width 0.25s ease, flex-basis 0.25s ease; +} + +body.sidebar-drawer-mode:not(.feedback-layout) .reader-main:not(:has(.feedback-main)) { + width: 100% !important; + max-width: 100% !important; + flex: 1 1 100% !important; +} + +body.sidebar-drawer-mode .side-bar .expander { + display: none; +} + +body.sidebar-drawer-mode.sidebar-drawer-open .side-bar { + transform: translateX(0); +} + +body.sidebar-drawer-mode.sidebar-drawer-open .sidebar-drawer-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +body.sidebar-drawer-mode .side-bar.visualization-expanded { + width: var(--reader-sidebar-drawer-viz-width) !important; + flex-basis: var(--reader-sidebar-drawer-viz-width) !important; +} + #vp-3, #vp-4, #vp-5, #vp-2, #vp-1 { display: flex; align-items: center; @@ -950,6 +1239,7 @@ body { border-radius: 4px; cursor: pointer; padding: 0; +} .paragraph .paragraph-header { border-radius: 16px; @@ -963,5 +1253,44 @@ body { background-color: white; color: var(--prime); border: 1px solid var(--prime); +} + +.default-reader-view .reader-middle-pane { + padding-right: clamp(16px, 2vw, 28px); +} + +.feedback-reader-view .reader-middle-pane { + padding-left: clamp(12px, 2vw, 24px); + padding-right: clamp(12px, 2vw, 24px); +} + +@media (max-width: 1400px) { + body { + --reader-sidebar-width: 460px; + --reader-sidebar-viz-width: min(72vw, 1080px); + --reader-middle-ideal: 56vw; + } +} -} \ No newline at end of file +@media (max-width: 1200px) { + body { + --reader-middle-min: 560px; + --reader-middle-ideal: 62vw; + --reader-sidebar-width: 430px; + } +} + +@media (max-width: 980px) { + body { + --reader-middle-min: min(100vw - 24px, 480px); + --reader-middle-ideal: 100%; + } + + .reader-news-column { + max-width: calc(100vw - 24px); + } + + .reader-middle-header { + padding: 1.4rem 1rem; + } +} diff --git a/uce.portal/resources/templates/css/feedback.css b/uce.portal/resources/templates/css/feedback.css index 7cfe5c80..05dc3b4d 100644 --- a/uce.portal/resources/templates/css/feedback.css +++ b/uce.portal/resources/templates/css/feedback.css @@ -1,4 +1,6 @@ /* Feedback presentation styling */ + + :root { --fb-bg: #f5f6f7; --fb-card: #ffffff; @@ -6,18 +8,81 @@ --fb-text: #1f2937; --fb-muted: #6b7280; --fb-border: #d7d9dc; - --fb-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); + /* --fb-shadow: 0 1px 3px rgba(17, 24, 39, 0.06); */ +} + +body { + background: var(--fb-card); +} + +body, +body.no-cursor, +.site-container, +.site-container.with-view-nav, +.reader-container, +.reader-container.container { + background-color: var(--fb-bg) !important; +} + +body.feedback-mode .site-container, +body.feedback-layout .site-container { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +body.feedback-mode .container-fluid, +body.feedback-layout .container-fluid { + padding-top: 0 !important; + margin-top: 0 !important; +} + +/* In feedback document-reader mode, avoid flex row competition with the fixed sidebar. */ +body.feedback-mode .container-fluid > .flexed, +body.feedback-layout .container-fluid > .flexed { + display: block !important; + position: relative; +} + +/* In feedback mode, use only the feedback header as the top header. */ +body.feedback-mode .reader-container > .header, +body.feedback-layout .reader-container > .header { + display: none !important; +} + + +/* consistent spacing between feedback blocks */ +.feedback-overview, +.feedback-urls, +.feedback-content { + margin: 0 0 24px 0; +} + +/* don't add extra space on the last block */ +.feedback-content { margin-bottom: 0; } + +/* section title spacing (avoid huge jump) */ +.feedback-content h3 { + margin: 0 0 12px 0; } .feedback-mode { background: var(--fb-bg); min-height: 100vh; - padding: 32px 24px 48px; + padding: 0; color: var(--fb-text); font-family: "Inter", system-ui, -apple-system, sans-serif; } +/* Document-reader feedback layout must not get top dead space from body padding. */ +body.feedback-layout.feedback-mode { + padding-top: 0 !important; + padding-bottom: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + .feedback-main { + background: var(--fb-bg); max-width: 1200px; margin: 0 auto; display: flex; @@ -25,10 +90,65 @@ gap: 24px; } +/* Keep feedback middle pane centrally aligned like a news/article column. */ +body.feedback-mode .reader-main, +body.feedback-layout .reader-main { + width: clamp(760px, 56vw, 1180px) !important; + max-width: calc(100vw - 48px) !important; + margin-left: auto !important; + margin-right: auto !important; + flex: none !important; +} + +body.feedback-mode .reader-main > .reader-container.container, +body.feedback-layout .reader-main > .reader-container.container { + width: 100% !important; + max-width: none !important; + margin-left: 0 !important; + margin-right: 0 !important; +} + +body.feedback-mode .reader-container .feedback-main, +body.feedback-mode .feedback-main, +body.feedback-layout .reader-container .feedback-main, +body.feedback-layout .feedback-main { + width: 100%; + max-width: none; + margin: 0; +} + +/* Drawer mode may use a wider center pane, but still keep it centered. */ +body.feedback-mode.sidebar-drawer-mode .reader-main, +body.feedback-layout.sidebar-drawer-mode .reader-main { + width: clamp(760px, 62vw, 1280px); +} + .feedback-header { + background: var(--fb-card); display: flex; flex-direction: column; gap: 12px; + padding: 2rem 2rem 1rem 2rem; + border: 1px solid var(--fb-border); + border-radius: 5; + box-shadow: var(--fb-shadow); + position: sticky; + top: 0; + z-index: 20; +} + +/* Keep feedback header sticky in the reader viewport while scrolling document content. */ +body.feedback-layout .feedback-main .feedback-header { + position: sticky !important; + top: 0 !important; + z-index: 30 !important; +} + +.feedback-header .header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .feedback-header h1 { @@ -69,7 +189,7 @@ background: var(--fb-card); border: 1px solid var(--fb-border); border-radius: 0; - padding: 16px; + padding: 24px; box-shadow: var(--fb-shadow); } @@ -128,7 +248,7 @@ background: var(--fb-card); border: 1px solid var(--fb-border); border-radius: 4px; - padding: 16px; + padding: 24px; box-shadow: var(--fb-shadow); } @@ -144,6 +264,10 @@ font-size: 14px; } +.feedback-urls table { + margin-top: 12px; +} + .feedback-urls th, .feedback-urls td { padding: 10px 8px; @@ -182,7 +306,7 @@ background: var(--fb-card); border: 1px solid var(--fb-border); border-radius: 0; - padding: 16px; + padding: 24px; box-shadow: var(--fb-shadow); } @@ -220,8 +344,11 @@ .feedback-chart-img { display: block; - height: 420px; + height: auto; width: auto; + min-width: 100%; + max-width: none; + max-height: 550px; border-radius: 0; border: 1px solid var(--fb-border); box-shadow: var(--fb-shadow); @@ -246,12 +373,47 @@ border-bottom: 1px solid var(--fb-border); } +.url-cell { max-width: 0; } /* allow shrinking inside table layout */ +.url-ellipsis{ + display: inline-block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; +} + +table.top-urls { width: 100%; table-layout: fixed; } +table.top-urls .url-cell { max-width: 0; } + +table.top-urls .url-ellipsis{ + display: inline-block; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feedback-chart-img{ + border: 1px solid rgba(17,24,39,0.08); + box-shadow: 0 2px 8px rgba(17,24,39,0.06); + } + + + @media (max-width: 768px) { .feedback-mode { padding: 20px 16px 32px; } .metric-card { - padding: 14px; + padding: 16px; + } + + .feedback-urls, + .text-card, + .chart-card, + .table-card { + padding: 16px; } } diff --git a/uce.portal/resources/templates/css/lexicon.css b/uce.portal/resources/templates/css/lexicon.css index a025a286..21d1c685 100644 --- a/uce.portal/resources/templates/css/lexicon.css +++ b/uce.portal/resources/templates/css/lexicon.css @@ -49,6 +49,173 @@ border:lightgray 1px solid; } +.lexicon-view .lexicon-content-include{ + min-height: 220px; +} + +.lexicon-view .lexicon-content-include.is-loading .lexicon-entry-list-region{ + opacity: 0.35; + transition: opacity 0.18s ease; +} + +.lexicon-view .lexicon-loader-container{ + position: absolute; + width: calc(100% - 24px); + top: 0; + left: 12px; + height: 100%; + background-color: rgba(255, 255, 255, 0.88); + z-index: 10; +} + +.lexicon-view .lexicon-loader-content{ + position: sticky; + top: 120px; + display: flex; + flex-direction: column; + align-items: center; +} + +.lexicon-view .lexicon-loader-content h1 { + color: rgba(0, 0, 0, 0.5); + text-align: center; + font-size: 17px; + position: relative; +} + +.lexicon-view .lexicon-loader-content h1:after { + position: absolute; + content: ""; + -webkit-animation: lexiconDots 2s cubic-bezier(0, .39, 1, .68) infinite; + animation: lexiconDots 2s cubic-bezier(0, .39, 1, .68) infinite; +} + +.lexicon-view .loader { + position: relative; + margin: 5% auto 15px; +} + +.lexicon-view .book { + border: 2px solid var(--secondary); + width: 60px; + height: 45px; + position: relative; + perspective: 150px; +} + +.lexicon-view .page { + display: block; + width: 30px; + height: 45px; + border: 2px solid white; + border-left: 1px solid white; + margin: 0; + position: absolute; + right: -4px; + top: -4px; + overflow: hidden; + background: var(--prime); + transform-style: preserve-3d; + -webkit-transform-origin: left center; + transform-origin: left center; +} + +.lexicon-view .book .page:nth-child(1) { + -webkit-animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.6s infinite; + animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.6s infinite; +} + +.lexicon-view .book .page:nth-child(2) { + -webkit-animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.45s infinite; + animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.45s infinite; +} + +.lexicon-view .book .page:nth-child(3) { + -webkit-animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.2s infinite; + animation: lexiconPageTurn 1.2s cubic-bezier(0, .39, 1, .68) 1.2s infinite; +} + +.lexicon-view .lexicon-update-status{ + position: fixed; + right: 24px; + bottom: 24px; + z-index: 1200; + background: rgba(31, 113, 57, 0.88); + color: white; + padding: 6px 10px; + border-radius: 2px; + font-size: 13px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.20); + pointer-events: none; +} + +@-webkit-keyframes lexiconPageTurn { + 0% { + -webkit-transform: rotateY(0deg); + transform: rotateY(0deg); + } + 20% { + background: lightgray; + } + 40% { + background: var(--secondary); + -webkit-transform: rotateY(-180deg); + transform: rotateY(-180deg); + } + 100% { + background: var(--secondary); + -webkit-transform: rotateY(-180deg); + transform: rotateY(-180deg); + } +} + +@keyframes lexiconPageTurn { + 0% { + transform: rotateY(0deg); + } + 20% { + background: lightgray; + } + 40% { + background: var(--secondary); + transform: rotateY(-180deg); + } + 100% { + background: var(--secondary); + transform: rotateY(-180deg); + } +} + +@-webkit-keyframes lexiconDots { + 0% { + content: ""; + } + 33% { + content: "."; + } + 66% { + content: ".."; + } + 100% { + content: "..."; + } +} + +@keyframes lexiconDots { + 0% { + content: ""; + } + 33% { + content: "."; + } + 66% { + content: ".."; + } + 100% { + content: "..."; + } +} + .lexicon-view .lexicon-entry{ border-bottom: lightgrey 1px solid; border-left:8px solid transparent; @@ -162,4 +329,4 @@ color:var(--prime); font-weight: bold; font-size: small; -} \ No newline at end of file +} diff --git a/uce.portal/resources/templates/css/search-redesign.css b/uce.portal/resources/templates/css/search-redesign.css index b560899d..7f12934c 100644 --- a/uce.portal/resources/templates/css/search-redesign.css +++ b/uce.portal/resources/templates/css/search-redesign.css @@ -362,9 +362,114 @@ .enriched-search-tokens-list .enriched-token .children-container{ max-height: 200px; overflow-y: auto; + overflow-x: hidden; padding: 3px; } +.enriched-search-tokens-list .enriched-token .expanded-content{ + overflow: hidden; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-children-chips{ + display: flex; + gap: 6px; + overflow-x: auto; + white-space: nowrap; + padding: 4px 0 6px; + margin-bottom: 2px; + position: sticky; + top: 0; + z-index: 2; + background: linear-gradient(to bottom, rgba(244, 248, 255, 0.98), rgba(244, 248, 255, 0.9)); + border-bottom: 1px solid rgba(130, 145, 166, 0.28); + backdrop-filter: blur(2px); +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-children-chip{ + border: 1px solid #b9c4d3; + background-color: #fff; + color: #2f3f57; + border-radius: 999px; + font-size: 11px; + padding: 2px 10px; + cursor: pointer; + text-transform: none; + font-weight: 600; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-children-chip.selected{ + background-color: var(--prime); + border-color: var(--prime); + color: #fff; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-children-json-list{ + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: flex-start; + padding-top: 2px; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip{ + display: inline-flex; + align-items: center; + border: 1px solid #d7dde8; + background-color: #f4f6fa; + color: #32425a; + border-radius: 12px; + font-size: 12px; + line-height: 1.25; + padding: 2px 8px; + max-width: 100%; + overflow-wrap: anywhere; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-value-text{ + overflow-wrap: anywhere; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-meta-badge{ + margin-left: 8px; + position: relative; + width: 13px; + height: 13px; + min-width: 13px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 8px; + font-weight: 700; + line-height: 13px; + text-align: center; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-meta-badge::before{ + content: ""; + position: absolute; + left: -5px; + top: 1px; + width: 1px; + height: 11px; + background-color: #c7cfdb; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-meta-badge.badge-accepted{ + background-color: #6fbf73; + color: #fff; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-meta-badge.badge-doubtful{ + background-color: #e1c75d; + color: #3f3320; +} + +.enriched-search-tokens-list .enriched-token .children-container .grouped-child-value-chip .chip-meta-badge.badge-neutral{ + background-color: #ebeff5; + color: #4e5d74; +} + .sort-container { z-index: 2; border: 1px lightgray solid; @@ -397,6 +502,14 @@ color: black !important; } +.sort-container .switch-search-layer-result-btn.is-static { + cursor: default; +} + +.sort-container .switch-search-layer-result-btn.is-static:hover { + border-bottom-color: var(--secondary) !important; +} + .pagination { width: 100%; align-items: center; @@ -698,4 +811,4 @@ left: 0; width: 100%; height: 100%; -} \ No newline at end of file +} diff --git a/uce.portal/resources/templates/css/site.css b/uce.portal/resources/templates/css/site.css index c4edeeb0..1ad4e5f7 100644 --- a/uce.portal/resources/templates/css/site.css +++ b/uce.portal/resources/templates/css/site.css @@ -1,7 +1,7 @@ :root { /* Variables prime and secondary MUST ALWAYS be in line 3 and 4! */ - --prime: #00618f; - --secondary: rgba(35, 35, 35, 1); + --prime: #5c7a17; + --secondary: darkgreen; --gold: rgba(255, 165, 0, 1); --dark: rgba(38, 38, 38, 1); --default: rgb(245, 245, 247, 1); @@ -112,6 +112,12 @@ nav .selected-nav-btn.text::before { content: ''; } +.pagination .next-page-btn.disabled, +.pagination .next-page-btn[aria-disabled="true"] { + opacity: 0.45; + pointer-events: none; +} + .light-border { border: 1px lightgray solid !important; } @@ -450,6 +456,12 @@ nav .selected-nav-btn.text::before { text-decoration-color: var(--secondary); } +.ui-action-disabled, +[aria-disabled="true"] { + opacity: 0.55; + cursor: not-allowed !important; +} + .hidden { visibility: hidden; pointer-events: none; @@ -995,6 +1007,11 @@ nav .selected-nav-btn.text::before { background-color: lightgray; } +.open-wiki-page .fa-wikipedia-w { + font-size: 14px !important; + line-height: 1 !important; +} + .focused-document-card { margin-bottom: 4rem; position: relative; diff --git a/uce.portal/resources/templates/css/view-nav.css b/uce.portal/resources/templates/css/view-nav.css index 5ec26060..d8f19e51 100644 --- a/uce.portal/resources/templates/css/view-nav.css +++ b/uce.portal/resources/templates/css/view-nav.css @@ -1,6 +1,11 @@ +.view-mode-nav-shell { + position: relative; + z-index: 2000; +} + .view-mode-nav { position: fixed; - left: 16px; + left: 0; top: 80px; z-index: 2000; @@ -13,6 +18,12 @@ border: 1px solid #d7d9dc; border-radius: 0; box-shadow: 0 12px 28px rgba(0,0,0,0.12); + transform: translateX(-115%); + transition: transform 0.25s ease; +} + +.view-mode-nav.is-open { + transform: translateX(0); } .view-mode-link { @@ -37,57 +48,115 @@ background: transparent; } -.with-view-nav { - margin-left: 220px; +.view-mode-link.is-current { + pointer-events: none; + cursor: default; + text-decoration: none; +} + +body.has-view-nav .container-fluid { + margin-left: 0; } -.feedback-mode.with-view-nav { - display: flex; - align-items: flex-start; - gap: 24px; +body.feedback-mode.has-view-nav .container-fluid { + margin-left: 0; +} + +body.feedback-layout.has-view-nav .container-fluid { margin-left: 0; } -.feedback-mode.with-view-nav .view-mode-nav { +body.has-view-nav:has(.feedback-main) .container-fluid { + margin-left: 0; +} + +body.feedback-mode.has-view-nav:not(.view-nav-drawer-mode) .view-mode-nav { + position: sticky; + top: 80px; + left: auto; +} + +body.feedback-layout.has-view-nav:not(.view-nav-drawer-mode) .view-mode-nav { + position: sticky; + top: 80px; + left: auto; +} + +body.has-view-nav:has(.feedback-main):not(.view-nav-drawer-mode) .view-mode-nav { position: sticky; top: 80px; left: auto; } .view-mode-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + position: fixed; + left: var(--view-nav-toggle-left, 0px); + top: var(--view-nav-toggle-top, 80px); + height: var(--view-nav-toggle-height, 72px); + z-index: 2102; + border: 1px solid #d7d9dc; + background: ghostwhite; + color: var(--secondary); + border-left: 0; + border-radius: 0 12px 12px 0; + width: 40px; + transform: translateX(0); + transition: left 0.25s ease, top 0.25s ease, height 0.25s ease; +} + +.view-nav-backdrop { display: none; +} + +body.view-nav-drawer-mode .view-nav-backdrop { + display: block; position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); z-index: 2100; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.2s ease; } -@media (max-width: 1024px) { - .view-mode-toggle { - display: inline-flex; - } +body.view-nav-drawer-mode .view-mode-nav { + top: 80px; + left: 0; + width: 210px; + z-index: 2101; + border-right: 0; + border-radius: 0; + box-shadow: 0 12px 28px rgba(0,0,0,0.12); +} - .view-mode-nav { - top: 80px; - left: 16px; - width: 180px; - height: auto; - padding: 10px 14px; - background: #f5f6f7; - border: 1px solid #d7d9dc; - border-radius: 0; - box-shadow: 0 12px 28px rgba(0,0,0,0.12); - transform: translateX(-110%); - transition: transform 0.25s ease; - } +body.view-nav-drawer-mode .view-mode-nav-shell { + height: 0; +} - .view-mode-nav.is-open { - transform: translateX(0); - } +body.view-nav-drawer-mode .view-mode-nav.is-open { + transform: translateX(0); +} - .with-view-nav { - margin-left: 0; - } +body.view-nav-drawer-mode.view-nav-open .view-nav-backdrop { + opacity: 1; + visibility: visible; + pointer-events: auto; +} - .view-mode-link { - border-bottom: 1px solid #e5e7eb; +body.view-nav-drawer-mode.has-view-nav .container-fluid { + margin-left: 0; +} + +body.view-nav-drawer-mode .view-mode-link { + border-bottom: 1px solid #e5e7eb; +} + +@media (max-width: 768px) { + .view-mode-nav { + width: 180px; } } diff --git a/uce.portal/resources/templates/css/wiki.css b/uce.portal/resources/templates/css/wiki.css index 0a47ec90..c1c4ad90 100644 --- a/uce.portal/resources/templates/css/wiki.css +++ b/uce.portal/resources/templates/css/wiki.css @@ -101,6 +101,7 @@ min-width: 300px; height: 90vh; min-height: 400px; + overflow: hidden; transform: translate(-50%, -50%); transition: 0.2s; } @@ -109,28 +110,60 @@ width: 100vw !important; max-width: 99999px !important; height: 100vh !important; + top: 0 !important; + left: 0 !important; + transform: none !important; border-radius: 0 !important; + z-index: 2002 !important; transition: 0.2s; } .wiki-page-modal .content .w-header { + position: relative; + z-index: 2; padding: 16px; background-color: rgba(125, 125, 125, 0.15); flex-shrink: 0; border-bottom: lightgrey 1px solid; } +.wiki-page-modal .content.fullscreen .w-header { + position: sticky; + top: 0; + z-index: 2004; + pointer-events: auto; +} + +.wiki-page-modal .content.fullscreen .w-header .w-rounded-btn { + position: relative; + z-index: 2005; + pointer-events: auto; +} + +.wiki-page-modal .content.fullscreen .page-content { + min-height: 0; + position: relative; + z-index: 2003; +} + .wiki-page-modal .content .page-content { + z-index: 1; padding: 18px 24px; height: 100%; width: 100%; position: relative; flex-grow: 1; overflow-y: auto; + overflow-x: hidden; +} + +.wiki-page-modal .content .page-content .include { + max-width: 100%; + overflow-x: hidden; } .wiki-page-modal .loading-div { - position: fixed; + position: absolute; top: 0; left: 0; height: 100%; @@ -140,6 +173,16 @@ display: flex; align-items: center; justify-content: center; + opacity: 0; + pointer-events: none !important; + visibility: hidden; + transition: opacity 0.08s ease; +} + +.wiki-page-modal .loading-div.is-active { + opacity: 1; + pointer-events: none !important; + visibility: visible; } .w-rounded-btn { @@ -163,12 +206,39 @@ } .wiki-page { + margin-top: 0 !important; + padding-top: 0 !important; } .wiki-page * { font-family: "Times New Roman", serif; } +.wiki-page-modal .wiki-title { + display: inline-flex; + align-items: center; + line-height: 1; + font-size: 1.1rem; + font-weight: 600; +} + +.wiki-page-modal .open-wiki-page.wiki-link-current, +.wiki-page .open-wiki-page.wiki-link-current { + pointer-events: none; + cursor: default; + opacity: 0.7; + text-decoration: none !important; +} + +.wiki-page-modal .open-wiki-page.wiki-link-broken, +.wiki-page .open-wiki-page.wiki-link-broken { + pointer-events: none; + cursor: not-allowed; + opacity: 0.45; + filter: grayscale(1); + text-decoration: line-through !important; +} + .wiki-page .keywords-container { border: lightgrey 1px solid; padding-left: 3px; @@ -402,6 +472,24 @@ .wiki-page .json-display { max-height: 550px; + max-width: 100%; + overflow-x: auto; + overflow-y: auto; +} + +.wiki-page .json-display table, +.wiki-page .json-display pre, +.wiki-page .json-display code, +.wiki-page table { + max-width: 100%; +} + +.wiki-page .json-display pre, +.wiki-page .json-display code, +.wiki-page td, +.wiki-page th { + overflow-wrap: anywhere; + word-break: break-word; } .wiki-page .corpus-config-json .json-display{ @@ -409,6 +497,7 @@ border-top:0 !important; } .wiki-metadata-expanded-view { + display: none; position: fixed; background-color: rgba(0, 0, 0, 0.4); top: 0; @@ -417,6 +506,22 @@ height: 100vh; backdrop-filter: blur(2px); z-index: 1000; + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 0.12s ease; +} + +.wiki-metadata-expanded-view.is-active { + display: block; + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.wiki-metadata-expanded-view:not(.is-active), +.wiki-metadata-expanded-view:not(.is-active) * { + pointer-events: none !important; } .wiki-metadata-expanded-view .content-reader { @@ -676,4 +781,4 @@ .uce-document-permissions-container .uce-document-permissions-table tr { border-bottom: 1px solid lightgray; -} \ No newline at end of file +} diff --git a/uce.portal/resources/templates/feedback/middlePane.ftl b/uce.portal/resources/templates/feedback/middlePane.ftl index 6b89a4b2..8ae9c6f1 100644 --- a/uce.portal/resources/templates/feedback/middlePane.ftl +++ b/uce.portal/resources/templates/feedback/middlePane.ftl @@ -1,5 +1,16 @@ + diff --git a/uce.portal/resources/templates/feedback/middlePaneSpec.ftl b/uce.portal/resources/templates/feedback/middlePaneSpec.ftl new file mode 100644 index 00000000..0aa9e122 --- /dev/null +++ b/uce.portal/resources/templates/feedback/middlePaneSpec.ftl @@ -0,0 +1,112 @@ +<#-- Spec-driven feedback view. Expects middlePaneModel.metadata (map), middlePaneModel.images (list), + and middlePaneModel.effectivePermission (optional). --> + +<#import "*/reader/components/middlePaneHeader.ftl" as middleHeader> + + + +<#assign md = middlePaneModel.metadata!{} > +<#assign cleanedTitle = (middlePaneModel.documentTitle!md.document_title!md.documentTitle!"")?replace("\\s*\\(Erhebung:.*\\)\\s*$", "", "r")> +<#assign feedbackWikiId = ""> +<#if document??> + <#assign feedbackWikiId = document.getWikiId()!"" > + + + diff --git a/uce.portal/resources/templates/imprint.ftl b/uce.portal/resources/templates/imprint.ftl index e2d994cb..5c0251a1 100644 --- a/uce.portal/resources/templates/imprint.ftl +++ b/uce.portal/resources/templates/imprint.ftl @@ -12,6 +12,9 @@ ${languageResource.get("imprint")} +<#include "*/sessionExpiredModal.ftl"> +<#include "*/auth/userShortProfile.ftl"> +
@@ -28,4 +31,4 @@
-
\ No newline at end of file +
diff --git a/uce.portal/resources/templates/index.ftl b/uce.portal/resources/templates/index.ftl index fe81e53e..19124921 100644 --- a/uce.portal/resources/templates/index.ftl +++ b/uce.portal/resources/templates/index.ftl @@ -61,6 +61,7 @@ <#include "*/messageModal.ftl"> +<#include "*/sessionExpiredModal.ftl"> <#include "*/auth/userShortProfile.ftl"> @@ -107,81 +108,92 @@
-
-

System Status

-
- - - - - + <#if (uceConfig.settings.ui.mainPage.showSystemStatus)!true> +
+

System Status

+
+ + + + + +
-
+ -
-
- - - - + <#if (uceConfig.settings.ui.mainPage.showCorpusSelector)!true> +
+
+ + + + +
-
+ - - <#if uceConfig.authIsEnabled()> + <#if (uceConfig.settings.ui.mainPage.showAuthButton)!true && uceConfig.authIsEnabled()>
<#if uceUser?has_content>
@@ -477,14 +489,18 @@
- <#include "*/wiki/components/wikiPageModal.ftl"> + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + <#include "*/wiki/components/wikiPageModal.ftl"> + -
- <#include "*/ragbot/chatwindow.ftl"/> -
+ <#if (uceConfig.settings.ui.mainPage.showRagbotChat)!true> +
+ <#include "*/ragbot/chatwindow.ftl"/> +
+
diff --git a/uce.portal/resources/templates/js/documentReader.js b/uce.portal/resources/templates/js/documentReader.js index 41c3828b..93364b2d 100644 --- a/uce.portal/resources/templates/js/documentReader.js +++ b/uce.portal/resources/templates/js/documentReader.js @@ -45,6 +45,43 @@ function setupImageZoomOverlay() { }); } +function enforceFeedbackCenterLayout() { + // Runtime fallback: detect feedback by actual DOM, not only mode flags. + const hasFeedbackDom = !!document.querySelector('.feedback-main'); + if (!hasFeedbackDom && !document.body.classList.contains('feedback-layout')) return; + + document.body.classList.add('feedback-layout'); + document.body.classList.add('feedback-layout-detected'); + + const row = document.querySelector('.container-fluid > .flexed'); + const readerMain = document.querySelector('.reader-main'); + const readerContainer = document.querySelector('.reader-main > .reader-container.container'); + if (row) row.classList.add('feedback-row-centered'); + if (readerMain) readerMain.classList.add('reader-feedback-centered'); + if (readerMain) readerMain.classList.add('feedback-layout-main'); + if (readerContainer) readerContainer.classList.add('feedback-layout-container'); + applyFeedbackCenterInlineLayout(); +} + +function applyFeedbackCenterInlineLayout() { + const hasFeedbackLayout = document.body.classList.contains('feedback-layout') || !!document.querySelector('.feedback-main'); + if (!hasFeedbackLayout) return; + + const readerMain = document.querySelector('.reader-main'); + const readerContainer = document.querySelector('.reader-main > .reader-container.container'); + if (!readerMain || !readerContainer) return; + + // Keep layout deterministic in CSS; remove stale inline overrides. + readerMain.style.removeProperty('display'); + readerMain.style.removeProperty('justify-content'); + readerMain.style.removeProperty('padding-right'); + readerMain.style.removeProperty('box-sizing'); + readerContainer.style.removeProperty('width'); + readerContainer.style.removeProperty('max-width'); + readerContainer.style.removeProperty('margin-left'); + readerContainer.style.removeProperty('margin-right'); +} + function imageZoom(img_src) { window.uceDocumentViewerOverlayImg.src = img_src; window.uceDocumentViewerOverlay.style.display = 'flex'; @@ -52,28 +89,341 @@ function imageZoom(img_src) { setupImageZoomOverlay(); +function isSidebarDrawerMode() { + return document.body.classList.contains('sidebar-drawer-mode'); +} + +function syncMinimapVisibility() { + const minimap = document.querySelector('.scrollbar-minimap'); + if (!minimap) return; + // Disable minimap rail in the new reader shell to avoid overlap artifacts + // beside the right sidebar when collapsing/reopening. + minimap.style.display = 'none'; +} + +function updateSidebarDrawerTogglePosition() { + const sidebar = document.querySelector('.side-bar'); + if (!sidebar) return; + const openWidth = Math.round(sidebar.getBoundingClientRect().width || 0); + if (openWidth > 0) { + document.body.style.setProperty('--sidebar-drawer-open-width', openWidth + 'px'); + } else { + document.body.style.removeProperty('--sidebar-drawer-open-width'); + } +} + +function refreshSidebarDrawerTogglePositionSmooth() { + // Keep drawer toggle pinned to the live sidebar edge while width transitions run. + updateSidebarDrawerTogglePosition(); + requestAnimationFrame(() => updateSidebarDrawerTogglePosition()); + window.setTimeout(() => updateSidebarDrawerTogglePosition(), 90); + window.setTimeout(() => updateSidebarDrawerTogglePosition(), 220); + window.setTimeout(() => updateSidebarDrawerTogglePosition(), 380); +} + +function setSidebarDrawerOpen(open) { + if (open) { + $('.side-bar').removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); + } + document.body.classList.toggle('sidebar-drawer-open', !!open); + syncMinimapVisibility(); + refreshSidebarDrawerTogglePositionSmooth(); + applyFeedbackCenterInlineLayout(); +} + +const SIDEBAR_RESIZE_DEBOUNCE_MS = 120; +const SIDEBAR_LAYOUT_COOLDOWN_MS = 320; +const SIDEBAR_AUTO_CLOSE_DELAY_MS = 90; +const SIDEBAR_AUTO_OPEN_DELAY_MS = 650; +let sidebarResizeDebounceTimer = null; +let sidebarAutoLayoutLockedUntil = 0; +let sidebarPendingModeTimer = null; +let sidebarPendingMode = null; + +function computeShouldUseSidebarDrawerMode() { + const sidebar = document.querySelector('.side-bar'); + const main = document.querySelector('.reader-main'); + if (!sidebar || !main) return document.body.classList.contains('sidebar-drawer-mode'); + + const hasFeedbackLayout = document.body.classList.contains('feedback-layout') || !!document.querySelector('.feedback-main'); + const activeTabId = getActiveSidebarTabId(); + const COLLAPSE_MIN_MAIN_WIDTH = activeTabId === 'visualization-tab' ? 620 : 760; + const EXPAND_MIN_MAIN_WIDTH = activeTabId === 'visualization-tab' ? 760 : 900; // hysteresis + const currentModeIsDrawer = document.body.classList.contains('sidebar-drawer-mode'); + + const sidebarRect = sidebar.getBoundingClientRect(); + const sidebarWidth = sidebarRect.width || 0; + const availableMainWidth = Math.max(0, window.innerWidth - sidebarWidth); + const readerContainer = document.querySelector('.reader-container'); + const readerRect = readerContainer ? readerContainer.getBoundingClientRect() : null; + const overlapPx = readerRect ? (readerRect.right - sidebarRect.left) : 0; + const overlayCollision = overlapPx > 24 && window.innerWidth < 1450; + // Middle pane has priority: once available room drops below threshold, use drawer mode. + const notEnoughMainSpace = availableMainWidth > 0 && availableMainWidth < COLLAPSE_MIN_MAIN_WIDTH; + const keepDrawerUntilSafe = currentModeIsDrawer && (availableMainWidth > 0 && availableMainWidth < EXPAND_MIN_MAIN_WIDTH); + // In feedback layout, intentional fixed sidebar overlap should not force drawer mode on desktop. + const collidingWithMainPane = hasFeedbackLayout ? false : overlayCollision; + + return collidingWithMainPane || notEnoughMainSpace || keepDrawerUntilSafe; +} + +function updateSidebarLayoutMode(options = {}) { + const force = !!options.force; + const shouldUseDrawer = computeShouldUseSidebarDrawerMode(); + const currentModeIsDrawer = document.body.classList.contains('sidebar-drawer-mode'); + + const now = Date.now(); + if (!force && now < sidebarAutoLayoutLockedUntil && shouldUseDrawer !== currentModeIsDrawer) return; + + if (!force && shouldUseDrawer !== currentModeIsDrawer) { + if (sidebarPendingMode === shouldUseDrawer) return; + if (sidebarPendingModeTimer) window.clearTimeout(sidebarPendingModeTimer); + sidebarPendingMode = shouldUseDrawer; + + const delay = shouldUseDrawer ? SIDEBAR_AUTO_CLOSE_DELAY_MS : SIDEBAR_AUTO_OPEN_DELAY_MS; + sidebarPendingModeTimer = window.setTimeout(() => { + sidebarPendingModeTimer = null; + const pendingTarget = sidebarPendingMode; + sidebarPendingMode = null; + if (pendingTarget === computeShouldUseSidebarDrawerMode()) { + updateSidebarLayoutMode({ force: true }); + } + }, delay); + return; + } + + if (sidebarPendingModeTimer) { + window.clearTimeout(sidebarPendingModeTimer); + sidebarPendingModeTimer = null; + } + sidebarPendingMode = null; + + if (shouldUseDrawer !== currentModeIsDrawer) { + document.body.classList.toggle('sidebar-drawer-mode', shouldUseDrawer); + const $sidebar = $('.side-bar'); + const activeTabId = getActiveSidebarTabId(); + if (shouldUseDrawer) { + $sidebar.removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); + } else { + // Reset stale constrained widths when moving back to regular desktop sidebar. + $sidebar.removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); + if (activeTabId === 'visualization-tab') { + $sidebar.addClass('visualization-expanded'); + } else { + $sidebar.removeClass('visualization-expanded'); + } + $('.side-bar .side-bar-content').show(); + } + // Middle pane has priority: whenever auto-switching mode, collapse the drawer. + setSidebarDrawerOpen(false); + sidebarAutoLayoutLockedUntil = now + SIDEBAR_LAYOUT_COOLDOWN_MS; + applyFeedbackCenterInlineLayout(); + return; + } + + if (!shouldUseDrawer && document.body.classList.contains('sidebar-drawer-open')) { + setSidebarDrawerOpen(false); + } + syncMinimapVisibility(); + applyFeedbackCenterInlineLayout(); +} + +function scheduleSidebarLayoutRefresh() { + if (sidebarResizeDebounceTimer) window.clearTimeout(sidebarResizeDebounceTimer); + sidebarResizeDebounceTimer = window.setTimeout(() => { + updateSidebarLayoutMode({ force: true }); + syncMinimapVisibility(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + applyFeedbackCenterInlineLayout(); + }, SIDEBAR_RESIZE_DEBOUNCE_MS); +} + +function stabilizeDesktopSidebarMode() { + // Guardrail: on wide screens we should never remain in drawer mode if collision + // checks say there is enough room; prevents stale lock-in after transitions. + if (window.innerWidth < 1450) return; + if (!isSidebarDrawerMode()) return; + if (computeShouldUseSidebarDrawerMode()) return; + document.body.classList.remove('sidebar-drawer-mode', 'sidebar-drawer-open'); +} + +function getActiveSidebarTabId() { + const activeBtn = document.querySelector('.tab-btn.active'); + return activeBtn ? activeBtn.getAttribute('data-tab') : 'navigator-tab'; +} + +function normalizeSidebarForTab(targetId) { + const $sidebar = $('.side-bar'); + if ($sidebar.length === 0) return; + $sidebar.removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); + if (targetId === 'visualization-tab') { + $sidebar.addClass('visualization-expanded'); + } else { + $sidebar.removeClass('visualization-expanded'); + } +} + +function isVizContainerReady(containerId) { + const container = document.getElementById(containerId); + if (!container) return false; + const rect = container.getBoundingClientRect(); + return rect.width > 80 && rect.height > 80; +} + +function renderVizPanelByTarget(target, attempt = 0) { + const maxAttempts = 20; + const retryDelayMs = 120; + const targetToContainer = { + '#viz-panel-1': 'vp-1', + '#viz-panel-2': 'vp-2', + '#viz-panel-3': 'vp-3', + '#viz-panel-4': 'vp-4', + '#viz-panel-5': 'vp-5' + }; + const targetToRenderer = { + '#viz-panel-1': renderTemporalExplorer, + '#viz-panel-2': renderTopicEntityChordDiagram, + '#viz-panel-3': renderSentenceTopicNetwork, + '#viz-panel-4': renderTopicSimilarityMatrix, + '#viz-panel-5': renderSentenceTopicSankey + }; + + const containerId = targetToContainer[target] || 'vp-1'; + const renderer = targetToRenderer[target] || renderTemporalExplorer; + if (typeof renderer !== 'function') return; + + if (!isVizContainerReady(containerId)) { + if (attempt < maxAttempts) { + window.setTimeout(() => renderVizPanelByTarget(target, attempt + 1), retryDelayMs); + } + return; + } + + renderer(containerId); +} + +function resetVizPanelContainer(panelContainer) { + if (!panelContainer) return; + if (window.echarts && typeof window.echarts.getInstanceByDom === 'function') { + const existingInstance = window.echarts.getInstanceByDom(panelContainer); + if (existingInstance) { + existingInstance.dispose(); + } + } + panelContainer.removeAttribute('_echarts_instance_'); + panelContainer.classList.remove('rendered'); + panelContainer.innerHTML = ''; +} + +function rerenderVisualizationForCurrentPanel() { + const activeBtn = document.querySelector('.viz-nav-btn.active'); + const target = activeBtn ? activeBtn.getAttribute('data-target') : '#viz-panel-1'; + const targetIdMatch = String(target || '').match(/#viz-panel-(\d+)/); + const panelId = targetIdMatch && targetIdMatch[1] ? targetIdMatch[1] : '1'; + const panelContainer = document.getElementById('vp-' + panelId); + resetVizPanelContainer(panelContainer); + + window.setTimeout(() => renderVizPanelByTarget(target), 120); +} + +$('body').on('click', '.sidebar-drawer-toggle', function () { + if (isSidebarDrawerMode()) { + setSidebarDrawerOpen(!document.body.classList.contains('sidebar-drawer-open')); + return; + } + + // Non-drawer mode: use the same collapse/expand behavior as the classic sidebar expander. + const $expander = $('.side-bar .expander'); + if ($expander.length > 0) { + $expander.trigger('click'); + } else { + const $sidebar = $('.side-bar'); + const collapsed = ($sidebar.width() || 0) <= 40; + if (collapsed) { + const activeTabId = getActiveSidebarTabId(); + $sidebar.removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); + if (activeTabId === 'visualization-tab') { + $sidebar.addClass('visualization-expanded'); + } else { + $sidebar.removeClass('visualization-expanded'); + } + $sidebar.removeClass('sidebar-collapsed'); + $('.side-bar .side-bar-content').fadeIn(250); + } else { + $sidebar.css('width', '20px'); + $sidebar.addClass('sidebar-collapsed'); + $('.side-bar .side-bar-content').fadeOut(150); + } + } + syncMinimapVisibility(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); +}); + +$('body').on('click', '.sidebar-drawer-backdrop', function () { + setSidebarDrawerOpen(false); +}); + /** * Handles the expanding and de-expanding of the side bar */ $('body').on('click', '.side-bar .expander', function () { + if (isSidebarDrawerMode()) { + setSidebarDrawerOpen(!document.body.classList.contains('sidebar-drawer-open')); + return; + } let expanded = $(this).data('expanded'); if (expanded) { $('.side-bar').css('width', '20px'); + $('.side-bar').addClass('sidebar-collapsed'); $('.side-bar .side-bar-content').fadeOut(150); $(this).find('i').css({ 'transform': 'rotate(180deg)', 'transition': '0.35s' }); } else { + $('.side-bar').removeClass('sidebar-collapsed'); $(this).find('i').css({ 'transform': 'rotate(0deg)', 'transition': '0.35s' }); $('.side-bar .side-bar-content').fadeIn(500); - $('.side-bar').css('width', '500px'); + $('.side-bar').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); } $(this).data('expanded', !expanded); + syncMinimapVisibility(); }) /** @@ -155,11 +505,15 @@ function handleFocusedPageChanged() { /** * Handle the changing of the font size */ -$('body').on('change', '.font-size-range', function () { +$('body').on('input change', '.font-size-range', function () { const fontSize = $(this).val(); - $('.document-content *').each(function () { - $(this).css('font-size', fontSize + 'px'); - }); + const selectors = [ + 'p', 'span', 'label', 'li', 'a', 'td', 'th', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'small' + ].join(', '); + + // Keep structural elements/icons untouched; update readable text only. + $('.document-content').find(selectors).css('font-size', fontSize + 'px'); }) $('body').on('mouseenter', '.reader-container .annotation', function () { @@ -180,6 +534,9 @@ $('body').on('click', '.found-searchtokens-list .found-search-token', function ( }); $(document).ready(function () { + enforceFeedbackCenterLayout(); + updateSidebarLayoutMode({ force: true }); + updateFloatingUIPositions(); checkScroll(); // we want to continously lazy load new pages @@ -901,6 +1258,14 @@ function updateFloatingUIPositions() { const navButtons = document.querySelector('.topic-navigation-buttons'); if (!sidebar || !minimap) return; + syncMinimapVisibility(); + if (getComputedStyle(minimap).display === 'none') return; + + if (isSidebarDrawerMode() && !document.body.classList.contains('sidebar-drawer-open')) { + minimap.style.right = '10px'; + if (navButtons) navButtons.style.right = '50px'; + return; + } const sidebarRect = sidebar.getBoundingClientRect(); @@ -912,43 +1277,105 @@ function updateFloatingUIPositions() { } } -window.addEventListener('resize', updateFloatingUIPositions); -window.addEventListener('DOMContentLoaded', updateFloatingUIPositions); +window.addEventListener('resize', scheduleSidebarLayoutRefresh); +window.addEventListener('DOMContentLoaded', () => { + enforceFeedbackCenterLayout(); + updateSidebarLayoutMode({ force: true }); + syncMinimapVisibility(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + const sideBar = document.querySelector('.side-bar'); + if (sideBar) { + sideBar.addEventListener('transitionend', (event) => { + if (event && (event.propertyName === 'width' || event.propertyName === 'flex-basis' || event.propertyName === 'transform')) { + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + } + }); + } +}); -document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', async () => { - const targetId = btn.getAttribute('data-tab'); - const sideBar = document.querySelector('.side-bar'); +function activateSidebarTab(targetId, triggerButton) { + const sideBar = document.querySelector('.side-bar'); + if (!sideBar || !targetId) return; + const drawerMode = isSidebarDrawerMode(); - document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + if (triggerButton) triggerButton.classList.add('active'); - document.querySelectorAll('.tab-pane').forEach(pane => { - pane.classList.toggle('active', pane.id === targetId); - }); + document.querySelectorAll('.tab-pane').forEach(pane => { + pane.classList.toggle('active', pane.id === targetId); + }); - hideTopicNavButtons(); - clearTopicColoring(); + hideTopicNavButtons(); + clearTopicColoring(); - if (targetId !== 'navigator-tab') { - $('.scrollbar-minimap').hide(); - sideBar.classList.add('visualization-expanded'); + if (targetId !== 'navigator-tab') { + $('.scrollbar-minimap').hide(); + sideBar.classList.add('visualization-expanded'); + } else { + setTimeout(updateFloatingUIPositions, 500); + currentSelectedTopic = null; + sideBar.classList.remove('visualization-expanded'); + $('.scrollbar-minimap').show(); + } + + normalizeSidebarForTab(targetId); + if (drawerMode) { + setSidebarDrawerOpen(true); + } else { + if (targetId === 'visualization-tab') { + $('.side-bar').removeClass('sidebar-collapsed').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }).addClass('visualization-expanded'); } else { - setTimeout(updateFloatingUIPositions,500) ; - currentSelectedTopic = null; - sideBar.classList.remove('visualization-expanded'); - $('.scrollbar-minimap').show(); + $('.side-bar').removeClass('sidebar-collapsed visualization-expanded').css({ + 'width': '', + 'flex-basis': '', + 'max-width': '', + 'min-width': '' + }); } - if (targetId === 'visualization-tab') { - setTimeout(() => renderTemporalExplorer('vp-1'), 500); - $('.viz-nav-btn').removeClass('active'); - $('.viz-nav-btn').first().addClass('active'); + $('.side-bar .side-bar-content').show(); + } - $('.viz-panel').removeClass('active'); - $('.viz-panel').first().addClass('active'); - } - }); + updateSidebarLayoutMode({ force: true }); + // Re-evaluate after width transitions settle; avoids false drawer-mode lock-in + // when switching from visualization-expanded back to control on wide screens. + window.setTimeout(() => { + updateSidebarLayoutMode({ force: true }); + stabilizeDesktopSidebarMode(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + }, 360); + window.setTimeout(() => { + stabilizeDesktopSidebarMode(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + }, 900); + scheduleSidebarLayoutRefresh(); + syncMinimapVisibility(); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); + + if (targetId === 'visualization-tab') { + const firstPanelContainer = document.getElementById('vp-1'); + resetVizPanelContainer(firstPanelContainer); + window.setTimeout(() => renderVizPanelByTarget('#viz-panel-1'), 220); + $('.viz-nav-btn').removeClass('active'); + $('.viz-nav-btn').first().addClass('active'); + $('.viz-panel').removeClass('active'); + $('.viz-panel').first().addClass('active'); + } +} +$(document).off('click', '.tab-btn').on('click', '.tab-btn', function (event) { + event.preventDefault(); + event.stopPropagation(); + activateSidebarTab($(this).attr('data-tab'), this); }); $(document).on('click', '.viz-nav-btn', function () { @@ -965,24 +1392,18 @@ $(document).on('click', '.viz-nav-btn', function () { $('.viz-panel').removeClass('active'); $(target).addClass('active'); - if (target === '#viz-panel-1') { - setTimeout(() => renderTemporalExplorer('vp-1'), 500); - } - if (target === '#viz-panel-2') { - setTimeout(() => renderTopicEntityChordDiagram('vp-2'), 500); - } - if (target === '#viz-panel-3') { - setTimeout(() => renderSentenceTopicNetwork('vp-3'), 500); + // Force a fresh render for the selected visualization panel. + const targetIdMatch = String(target || '').match(/#viz-panel-(\d+)/); + if (targetIdMatch && targetIdMatch[1]) { + resetVizPanelContainer(document.getElementById('vp-' + targetIdMatch[1])); } + if (target === '#viz-panel-4') { $('.selector-container').hide(); - setTimeout(() => renderTopicSimilarityMatrix('vp-4'), 500); - - } - if (target === '#viz-panel-5') { - setTimeout(() => renderSentenceTopicSankey('vp-5'), 500); - } + window.setTimeout(() => renderVizPanelByTarget(target), 220); + refreshSidebarDrawerTogglePositionSmooth(); + updateFloatingUIPositions(); }); @@ -1680,6 +2101,7 @@ function renderTemporalExplorer(containerId) { container.classList.add('rendered'); }).catch(err => { console.error("Error loading or processing annotation data:", err); + container.classList.remove('rendered'); }); } diff --git a/uce.portal/resources/templates/js/language.js b/uce.portal/resources/templates/js/language.js index d0cca2e3..f469af16 100644 --- a/uce.portal/resources/templates/js/language.js +++ b/uce.portal/resources/templates/js/language.js @@ -7,10 +7,10 @@ $(document).ready(function () { } console.log("Sent Content Language: " + contentLanguage); - // Handle the caching of the language in the browser storage and reload the page should the server have sent wrong language + // Keep cookie in sync with server language. const storedLanguage = getLanguage(); if (storedLanguage !== undefined && storedLanguage != null && storedLanguage !== contentLanguage) { - location.reload(); + // We intentionally avoid forced auto-reloads here because that can create loops. } else { setLanguage(contentLanguage); } @@ -31,11 +31,12 @@ $(document).ready(function () { function switchLanguage(language){ setLanguage(language); - location.reload(); + // Server-side rendered FTL text needs exactly one reload. + window.location.reload(); } function setLanguage(language) { - document.cookie = "language=" + language; + document.cookie = "language=" + language + "; path=/; max-age=31536000"; } function getLanguage() { diff --git a/uce.portal/resources/templates/js/layeredSearch.js b/uce.portal/resources/templates/js/layeredSearch.js index b22155c7..ea5283eb 100644 --- a/uce.portal/resources/templates/js/layeredSearch.js +++ b/uce.portal/resources/templates/js/layeredSearch.js @@ -94,6 +94,84 @@ let LayeredSearchHandler = (function () { this.updateUIBatch(); } + LayeredSearchHandler.prototype.clearAllLayers = function () { + this.layers = {}; + $('.layered-search-builder-container .layers-container').html(''); + this.updateUIBatch(); + } + + LayeredSearchHandler.prototype.addSlotToLayer = function (depth, type, value) { + const $layer = $('.layered-search-builder-container .layer-container[data-depth="' + depth + '"] .layer'); + if ($layer.length === 0) return; + + const $emptySlot = $layer.find('.empty-slot').first(); + if ($emptySlot.length > 0) { + $emptySlot.get(0).style.maxWidth = '100px'; + } + + const $htmlTemplate = $('.layered-search-builder-container .slot-templates .template-' + type).clone(); + if ($htmlTemplate.length === 0) return; + + const id = generateUUID(); + $htmlTemplate.attr('data-id', id); + $layer.prepend($htmlTemplate); + if (!this.layers[depth]) this.layers[depth] = []; + this.layers[depth].push($htmlTemplate); + if (value !== undefined && value !== null) { + $htmlTemplate.find('.slot-value').val(String(value)); + } + + if (type === "LOCATION") { + const uceMap = graphVizHandler.createUceMap( + $layer.find('.slot[data-id="[ID]"] .location-map'.replace('[ID]', id)).get(0) + ); + uceMap.twoDim(); + const $slot = $('.slot[data-id="[ID]"]'.replace('[ID]', id)); + const $slotInput = $slot.find('.slot-value'); + const ctx = this; + uceMap.on('stateChanged', function (e) { + if (e.longLat && e.radius) { + $slotInput.val('R::lng=' + e.longLat.lng.toFixed(2) + ";lat=" + e.longLat.lat.toFixed(2) + ";r=" + e.radius.toFixed(2)); + ctx.markLayersAsDirty(depth, false); + } + }); + $slot.find('.location-map').hide(); + } + } + + LayeredSearchHandler.prototype.hydrateFromRouteState = function (state) { + if (!state || state.submit !== true || !Array.isArray(state.layers)) return false; + + this.clearAllLayers(); + this.searchId = state.searchId ? String(state.searchId) : generateUUID().toString().replaceAll("-", ""); + + const sortedLayers = state.layers + .filter((layer) => layer && Array.isArray(layer.slots)) + .slice() + .sort((a, b) => parseInt(a.depth || 0, 10) - parseInt(b.depth || 0, 10)); + + for (let i = 0; i < sortedLayers.length; i++) { + const layer = sortedLayers[i]; + const depth = i + 1; + this.addNewLayer(depth); + for (let s = 0; s < layer.slots.length; s++) { + const slot = layer.slots[s]; + if (!slot || !slot.type) continue; + this.addSlotToLayer(depth, String(slot.type), slot.value); + } + this.markLayersAsDirty(depth, false); + } + + this.submitStatus = true; + $('.search-settings-div .submit-layered-search-input').val('true'); + $('.layered-search-builder-container .submit-div a').each(function () { + const isOn = $(this).attr('data-submit') === 'true'; + $(this).toggleClass('activated', isOn); + }); + this.updateUIBatch(); + return true; + } + LayeredSearchHandler.prototype.addNewSlot = function ($btn) { const type = $btn.data('type'); const depth = parseInt($btn.closest('.layer-container').attr('data-depth')); @@ -268,4 +346,4 @@ $('body').on('click', '.layered-search-builder-container .layer-container .slot $(document).ready(function () { window.layeredSearchHandler = getNewLayeredSearchHandler(); window.layeredSearchHandler.init(); -}) \ No newline at end of file +}) diff --git a/uce.portal/resources/templates/js/search.js b/uce.portal/resources/templates/js/search.js index d57d335c..2883678b 100644 --- a/uce.portal/resources/templates/js/search.js +++ b/uce.portal/resources/templates/js/search.js @@ -1,56 +1,531 @@ let currentCorpusUniverseHandler = undefined; +let searchRestoreInProgress = false; +let searchViewBootstrapInProgress = false; +let searchVizToggleInProgressUntil = 0; + +function encodeLayeredSearchStateForRoute(state) { + if (!state) return ''; + try { + const json = JSON.stringify(state); + const utf8 = encodeURIComponent(json).replace(/%([0-9A-F]{2})/g, (_, p1) => + String.fromCharCode(parseInt(p1, 16)) + ); + return btoa(utf8).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + } catch (e) { + return ''; + } +} + +function decodeLayeredSearchStateFromRoute(raw) { + if (!raw) return null; + try { + const b64 = String(raw).replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '==='.slice((b64.length + 3) % 4); + const utf8 = atob(padded); + const json = decodeURIComponent(Array.prototype.map.call(utf8, (c) => + '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) + ).join('')); + const parsed = JSON.parse(json); + if (!parsed || !Array.isArray(parsed.layers)) return null; + return parsed; + } catch (e) { + return null; + } +} + +function getLayeredSearchStateForRoute() { + if (!window.layeredSearchHandler) return undefined; + const submitFromInput = $('.search-menu-div .search-settings-div .submit-layered-search-input').val() === 'true'; + const submitStatus = submitFromInput || !!window.layeredSearchHandler.submitStatus; + const layers = window.layeredSearchHandler.buildApplicableLayers([]); + if (!submitStatus || !Array.isArray(layers) || layers.length === 0) return null; + return { + submit: true, + searchId: window.layeredSearchHandler.searchId || '', + layers: layers + }; +} + +function hydrateSearchVizSettingsFromRoute() { + if (!window.uceUiState || !window.searchVizualization) return; + if (window.searchVizualization.__routeHydrated) return; + const routeBins = parseInt(window.uceUiState.get('bins'), 10); + if (Number.isFinite(routeBins) && routeBins > 0) { + window.searchVizualization.settings.nBins = routeBins; + } + const routeFeature = window.uceUiState.get('feature'); + if (routeFeature) { + window.searchVizualization.settings.selectedFeature = routeFeature; + } + const routeChartType = window.uceUiState.get('chartType'); + if (routeChartType) { + window.searchVizualization.settings.chartType = routeChartType; + } + window.searchVizualization.__routeHydrated = true; +} + +function persistSearchVizSettingsToRoute() { + if (!window.uceUiState || !window.searchVizualization) return; + window.uceUiState.set('bins', window.searchVizualization.settings.nBins); + window.uceUiState.set('feature', window.searchVizualization.settings.selectedFeature); + window.uceUiState.set('chartType', window.searchVizualization.settings.chartType || 'bar'); +} + +function getSearchVisualizationExpandedStateFromDom() { + const $expanded = $('#search-results-visualization-container .group-box .expanded').first(); + if ($expanded.length === 0) return null; + return $expanded.is(':visible'); +} + +function setSearchVisualizationExpandedState(shouldOpen, animate = false) { + const $expanded = $('#search-results-visualization-container .group-box .expanded').first(); + if ($expanded.length === 0) return; + $expanded.stop(true, true); + if (shouldOpen) { + $expanded.removeClass('display-none'); + if (animate) $expanded.fadeIn(75); + else $expanded.show(); + } else { + if (animate) { + $expanded.fadeOut(75, function () { + $(this).addClass('display-none'); + }); + } else { + $expanded.addClass('display-none').hide(); + } + } +} + +function persistSearchVisualizationExpandedStateToRoute() { + if (!window.uceUiState) return; + const $expanded = $('#search-results-visualization-container .group-box .expanded').first(); + if ($expanded.length > 0 && $expanded.is(':animated')) return; + const isOpen = getSearchVisualizationExpandedStateFromDom(); + if (isOpen === true) window.uceUiState.set('svOpen', 'true'); + else window.uceUiState.remove('svOpen'); +} + +function applySearchVisualizationExpandedStateFromRoute() { + if (!window.uceUiState) return; + const shouldOpen = String(window.uceUiState.get('svOpen') || '').toLowerCase() === 'true'; + setSearchVisualizationExpandedState(shouldOpen, false); +} + +function persistSearchRequestToRoute(searchInput, corpusId, proMode, layeredState = undefined) { + if (!window.uceUiState) return; + window.uceUiState.set('view', 'search'); + window.uceUiState.set('q', String(searchInput || '')); + window.uceUiState.set('corpusId', String(corpusId || '')); + window.uceUiState.set('proMode', proMode ? 'true' : 'false'); + if (layeredState !== undefined) { + const encoded = encodeLayeredSearchStateForRoute(layeredState); + if (encoded) window.uceUiState.set('ls', encoded); + else window.uceUiState.remove('ls'); + } +} + +function isSearchResultEmpty() { + const $container = $('.view[data-id="search"] .search-result-container'); + if ($container.length === 0) return true; + const hasState = $container.find('.search-state').length > 0; + const hasDocuments = $container.find('.document-card').length > 0; + const hasEmbeddingList = $container.find('.embedding-document-list-include .document-card').length > 0; + return !(hasState || hasDocuments || hasEmbeddingList); +} + +function restoreSearchFromRouteIfNeeded() { + if (!window.uceUiState || searchRestoreInProgress) return; + + const routeView = String(window.uceUiState.get('view') || ''); + const routeSearchId = String(window.uceUiState.get('searchId') || ''); + const routeQuery = String(window.uceUiState.get('q') || ''); + const routeCorpusId = String(window.uceUiState.get('corpusId') || ''); + const routeProMode = String(window.uceUiState.get('proMode') || '').toLowerCase(); + const routeLayeredState = decodeLayeredSearchStateFromRoute(window.uceUiState.get('ls')); + const shouldRestoreSearch = routeView === 'search' || routeSearchId !== '' || routeQuery !== ''; + if (!shouldRestoreSearch) return; + + const activeSearchId = String($('.search-state').data('id') || ''); + if (activeSearchId) return; + if (!isSearchResultEmpty()) return; + + if (routeCorpusId) { + const $select = $('#corpus-select'); + if ($select.length > 0) { + const $matching = $select.find('option').filter(function () { + return String($(this).data('id')) === routeCorpusId; + }).first(); + if ($matching.length > 0) { + const currentSelectedId = String($select.find('option:selected').data('id') || ''); + if (currentSelectedId !== routeCorpusId) { + // Prevent empty auto-search side effects while restoring selected corpus from route. + window.__uceSuppressAutoSearchOnCorpusChange = true; + $select.prop('selectedIndex', $matching.index()); + $select.trigger('change'); + window.__uceSuppressAutoSearchOnCorpusChange = false; + } + } + } + } + + if (routeProMode === 'true') $('#proModeSwitch').prop('checked', true); + if (routeProMode === 'false') $('#proModeSwitch').prop('checked', false); + $('.search-input').val(routeQuery); + + if (routeLayeredState && window.layeredSearchHandler && typeof window.layeredSearchHandler.hydrateFromRouteState === 'function') { + window.layeredSearchHandler.hydrateFromRouteState(routeLayeredState); + } + + searchRestoreInProgress = true; + // Use startNewSearch so all dependent widgets (pagination, universe, viz) are restored consistently. + startNewSearch(routeQuery, false, { + layeredState: routeLayeredState + }); + window.setTimeout(() => { + searchRestoreInProgress = false; + }, 1500); +} + +function applySearchStateFromRoute() { + if (!window.uceUiState) return; + const activeSearchId = String($('.search-state').data('id') || ''); + const routeSearchId = String(window.uceUiState.get('searchId') || ''); + if (!activeSearchId || !routeSearchId || activeSearchId !== routeSearchId) return; + + const routeSortBy = window.uceUiState.get('sortBy'); + const routeSortOrder = String(window.uceUiState.get('sortOrder') || '').toUpperCase(); + if (routeSortBy && (routeSortOrder === 'ASC' || routeSortOrder === 'DESC')) { + const $btn = $(".sort-container .sort-btn[data-orderby='" + routeSortBy + "']"); + if ($btn.length > 0) { + const currentForTrigger = routeSortOrder === 'ASC' ? 'DESC' : 'ASC'; + $btn.data('curorder', currentForTrigger); + $btn.trigger('click'); + } + } + + const routePage = parseInt(window.uceUiState.get('page'), 10); + if (Number.isFinite(routePage) && routePage > 1) { + handleSwitchingOfPage(routePage); + } +} + +function getCurrentSearchQueryForRoute() { + const inputVal = String($('.search-input').val() || '').trim(); + if (inputVal !== '') return inputVal; + const tokenVal = String($('.search-result-container .search-token').first().text() || '').trim(); + return tokenVal; +} + +function syncRouteFromRenderedSearchState() { + if (!window.uceUiState) return; + + const selectElement = document.getElementById("corpus-select"); + const selectedOption = selectElement && selectElement.options + ? selectElement.options[selectElement.selectedIndex] + : undefined; + const corpusId = selectedOption ? String(selectedOption.getAttribute("data-id") || '') : ''; + const proMode = $('#proModeSwitch').is(':checked'); + const searchInput = getCurrentSearchQueryForRoute(); + + const layeredState = getLayeredSearchStateForRoute(); + persistSearchRequestToRoute(searchInput, corpusId, proMode, layeredState); + + const activeSearchId = String($('.search-state').data('id') || ''); + if (activeSearchId) window.uceUiState.set('searchId', activeSearchId); + else window.uceUiState.remove('searchId'); + + const curPage = parseInt($('.search-result-container .pagination').data('cur'), 10); + if (Number.isFinite(curPage) && curPage > 1) window.uceUiState.set('page', String(curPage)); + else window.uceUiState.remove('page'); + + const $activeSortBtn = $('.sort-container .sort-btn.active-sort-btn').first(); + if ($activeSortBtn.length > 0) { + const orderBy = String($activeSortBtn.data('orderby') || '').trim(); + const sortOrder = String($activeSortBtn.data('curorder') || '').toUpperCase(); + if (orderBy) window.uceUiState.set('sortBy', orderBy); + else window.uceUiState.remove('sortBy'); + if (sortOrder === 'ASC' || sortOrder === 'DESC') window.uceUiState.set('sortOrder', sortOrder); + else window.uceUiState.remove('sortOrder'); + } else { + window.uceUiState.remove('sortBy'); + window.uceUiState.remove('sortOrder'); + } + + persistSearchVizSettingsToRoute(); + if (Date.now() >= searchVizToggleInProgressUntil) { + persistSearchVisualizationExpandedStateToRoute(); + } +} + +function ensureSearchViewStateOnEnter() { + if (!window.uceUiState) return; + const routeView = String(window.uceUiState.get('view') || ''); + if (routeView !== 'search') return; + + const activeSearchId = String($('.search-state').data('id') || ''); + if (activeSearchId) { + const routeLayeredState = decodeLayeredSearchStateFromRoute(window.uceUiState.get('ls')); + if (routeLayeredState && window.layeredSearchHandler && typeof window.layeredSearchHandler.hydrateFromRouteState === 'function') { + window.layeredSearchHandler.hydrateFromRouteState(routeLayeredState); + } + applySearchVisualizationExpandedStateFromRoute(); + syncRouteFromRenderedSearchState(); + return; + } + + if (!isSearchResultEmpty()) { + const routeLayeredState = decodeLayeredSearchStateFromRoute(window.uceUiState.get('ls')); + if (routeLayeredState && window.layeredSearchHandler && typeof window.layeredSearchHandler.hydrateFromRouteState === 'function') { + window.layeredSearchHandler.hydrateFromRouteState(routeLayeredState); + } + applySearchVisualizationExpandedStateFromRoute(); + syncRouteFromRenderedSearchState(); + return; + } + + const routeSearchId = String(window.uceUiState.get('searchId') || ''); + const routeQuery = String(window.uceUiState.get('q') || ''); + if (routeSearchId !== '' || routeQuery !== '') { + restoreSearchFromRouteIfNeeded(); + return; + } + + if (searchViewBootstrapInProgress || searchRestoreInProgress) return; + + const selectElement = document.getElementById("corpus-select"); + if (!selectElement || !selectElement.options || selectElement.selectedIndex < 0) return; + const selectedOption = selectElement.options[selectElement.selectedIndex]; + if (!selectedOption || !selectedOption.getAttribute("data-id")) return; + + searchViewBootstrapInProgress = true; + startNewSearch(String($('.search-input').val() || ''), false); + window.setTimeout(() => { + searchViewBootstrapInProgress = false; + }, 2000); +} $('body').on('click', '#search-viz-update-button', function (e) { // TODO more error handling - const nBins = parseInt($('#search-viz-n-bins').val()) - window.searchVizualization.settings.nBins = nBins + const nBins = parseInt($('#search-viz-n-bins').val(), 10) + if (Number.isFinite(nBins) && nBins > 0) { + window.searchVizualization.settings.nBins = nBins + } - const selectedFeature = $('#search-viz-selected-feature').val() + const selectedFeature = $('#search-viz-selected-feature').val() || '' window.searchVizualization.settings.selectedFeature = selectedFeature + persistSearchVizSettingsToRoute() updateSearchVizualization() e.preventDefault() }) -function createHistogramData(data, bins) { +$('body').on('submit', '#search-viz-form', function (e) { + e.preventDefault(); + $('#search-viz-update-button').trigger('click'); +}); + +$('body').on('change', '#search-viz-n-bins, #search-viz-selected-feature', function () { + $('#search-viz-update-button').trigger('click'); +}); + +function createHistogramData(data, bins, featureKey = '') { + function estimateFractionDigits(step) { + if (!Number.isFinite(step) || step <= 0) return 0; + const stepString = step.toString(); + if (stepString.includes('e-')) { + const exp = parseInt(stepString.split('e-')[1], 10); + return Math.min(6, Math.max(0, exp)); + } + const dotIdx = stepString.indexOf('.'); + if (dotIdx === -1) return 0; + return Math.min(6, Math.max(0, stepString.length - dotIdx - 1)); + } + + function formatContinuousBound(value, step) { + if (!Number.isFinite(value)) return ''; + const rounded = Math.round(value); + if (Math.abs(value - rounded) < 1e-9) return String(rounded); + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: estimateFractionDigits(step), + useGrouping: false + }).format(value); + } + + function buildDiscreteIntegerHistogram(values) { + const counts = new Map(); + for (const value of values) { + const key = Math.round(value); + counts.set(key, (counts.get(key) || 0) + 1); + } + const sortedKeys = Array.from(counts.keys()).sort((a, b) => a - b); + const discreteData = sortedKeys.map((k) => counts.get(k)); + const discreteLabels = sortedKeys.map((k) => String(k)); + return [discreteData, discreteLabels]; + } + + function buildDiscreteIntegerBinnedHistogram(values, binCount) { + const intValues = values.map((v) => Math.round(v)); + const minInt = Math.min(...intValues); + const maxInt = Math.max(...intValues); + const rangeSize = maxInt - minInt + 1; + const safeBinCount = Math.max(1, Math.min(binCount, rangeSize)); + const step = rangeSize / safeBinCount; + const buckets = new Array(safeBinCount).fill(0); + + for (const value of intValues) { + const bucketIndex = Math.min( + safeBinCount - 1, + Math.floor((value - minInt) / step) + ); + buckets[bucketIndex]++; + } + + const labels = buckets.map((_, index) => { + const start = Math.floor(minInt + index * step); + const endExclusive = minInt + (index + 1) * step; + const end = Math.max(start, Math.ceil(endExclusive) - 1); + return start === end ? String(start) : (start + ' - ' + end); + }); + + return [buckets, labels]; + } + // extract only the values, filter nan const values = data.map(d => parseFloat(d.value)).filter(v => !isNaN(v)) + if (!values.length) return [[], []]; + + const safeBins = Math.max(1, Number.isFinite(bins) ? bins : 10); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min; + const allIntegers = values.every((value) => Math.abs(value - Math.round(value)) < 1e-9); + const uniqueIntegerCount = allIntegers ? new Set(values.map((v) => Math.round(v))).size : 0; + const likelyYearFeature = ( + allIntegers && + min >= 1000 && + max <= 3000 && + /(year|jahr|date|datum)/i.test(String(featureKey || '')) + ); + + // For integer-like discrete scales (especially years), treat bins as categories instead of fractional ranges. + // This keeps semantics clean (e.g. 1891, 1892) and avoids misleading decimal bucket labels. + const shouldUseDiscreteIntegerBinning = allIntegers && ( + likelyYearFeature || + uniqueIntegerCount <= Math.max(safeBins * 4, 24) || + range <= Math.max(safeBins * 2, 20) + ); + if (shouldUseDiscreteIntegerBinning) { + if (safeBins >= uniqueIntegerCount) { + return buildDiscreteIntegerHistogram(values); + } + return buildDiscreteIntegerBinnedHistogram(values, safeBins); + } - // see https://github.com/texttechnologylab/TextAnnotatorReloaded/blob/main/src/components/BarChart.tsx - const min = Math.min(...values) - const max = Math.max(...values) - const range = max - min + if (range === 0) { + const buckets = new Array(safeBins).fill(0); + buckets[0] = values.length; + const label = formatContinuousBound(min, 1) + " - " + formatContinuousBound(max, 1); + const labels = new Array(safeBins).fill(label); + return [buckets, labels]; + } - const bucketSize = range / bins - const buckets = new Array(bins).fill(0) + const bucketSize = range / safeBins + const buckets = new Array(safeBins).fill(0) for (const value of values) { const bucketIndex = Math.min( Math.floor((value - min) / bucketSize), - bins-1 + safeBins-1 ) buckets[bucketIndex]++ } const labels = buckets.map((_, index) => { - const start = (min + index * bucketSize).toFixed(3) - const end = (min + (index + 1) * bucketSize).toFixed(3) - return start.toString() + " - " + end.toString() + const start = min + index * bucketSize; + const end = min + (index + 1) * bucketSize; + return formatContinuousBound(start, bucketSize) + " - " + formatContinuousBound(end, bucketSize); }) return [buckets, labels] } +function getSearchVizBinBounds(values, featureKey) { + const safeValues = (values || []).filter((v) => Number.isFinite(v)); + if (safeValues.length === 0) return { min: 1, max: 1 }; + + const nValues = safeValues.length; + const allIntegers = safeValues.every((value) => Math.abs(value - Math.round(value)) < 1e-9); + const min = Math.min(...safeValues); + const max = Math.max(...safeValues); + const uniqueIntegerCount = allIntegers ? new Set(safeValues.map((v) => Math.round(v))).size : 0; + const likelyYearFeature = ( + allIntegers && + min >= 1000 && + max <= 3000 && + /(year|jahr|date|datum)/i.test(String(featureKey || '')) + ); + const shouldUseDiscreteIntegerBinning = allIntegers && ( + likelyYearFeature || + uniqueIntegerCount <= Math.max(10 * 4, 24) || + (max - min) <= Math.max(10 * 2, 20) + ); + + // Never allow more bins than samples. For discrete integer scales, cap at unique integer values. + const maxForContinuous = Math.max(1, Math.min(150, nValues)); + const maxForDiscrete = Math.max(1, Math.min(maxForContinuous, uniqueIntegerCount)); + return { + min: 1, + max: shouldUseDiscreteIntegerBinning ? maxForDiscrete : maxForContinuous + }; +} + +function normalizeSearchVisualizationPayload(rawPayload) { + if (!rawPayload) return null; + if (typeof rawPayload === 'string') { + try { + return JSON.parse(rawPayload); + } catch (e) { + console.error('Failed to parse search visualization payload.', e); + return null; + } + } + if (typeof rawPayload === 'object') return rawPayload; + return null; +} + function updateSearchVizualization() { + if (window.searchVizualization && !window.searchVizualization.__routeHydrated) { + hydrateSearchVizSettingsFromRoute(); + } + // Always prefer current UI control values over stale in-memory state. + const uiBins = parseInt($('#search-viz-n-bins').val(), 10); + if (Number.isFinite(uiBins) && uiBins > 0) { + window.searchVizualization.settings.nBins = uiBins; + } + const uiFeature = $('#search-viz-selected-feature').val(); + if (uiFeature) { + window.searchVizualization.settings.selectedFeature = uiFeature; + } + // TODO global state? - const data = window.searchVizualization.vizData["data"] - const currentPage = window.searchVizualization.vizData["currentPage"] - const nBins = window.searchVizualization.settings.nBins + const normalizedVizData = normalizeSearchVisualizationPayload(window.searchVizualization.vizData); + if (!normalizedVizData) return; + window.searchVizualization.vizData = normalizedVizData; + + const data = normalizedVizData["data"] + const currentPage = normalizedVizData["currentPage"] + let nBins = Math.max(1, parseInt(window.searchVizualization.settings.nBins, 10) || 10) + const chartType = String(window.searchVizualization.settings.chartType || 'bar'); if(data === undefined) return; // set the selected feature to the first one if not set const firstFeature = Object.keys(data).sort().shift() - const selectedFeature = window.searchVizualization.settings.selectedFeature || firstFeature + let selectedFeature = window.searchVizualization.settings.selectedFeature || firstFeature + if (!(selectedFeature in data)) { + selectedFeature = firstFeature; + window.searchVizualization.settings.selectedFeature = selectedFeature; + } // update features options based on current data const selectElem = document.getElementById('search-viz-selected-feature') @@ -67,13 +542,28 @@ function updateSearchVizualization() { selectElem.appendChild(option) }) + const numericValuesForFeature = (data[selectedFeature] || []) + .map((d) => parseFloat(d.value)) + .filter((v) => !isNaN(v)); + const binBounds = getSearchVizBinBounds(numericValuesForFeature, selectedFeature); + nBins = Math.max(binBounds.min, Math.min(nBins, binBounds.max)); + window.searchVizualization.settings.nBins = nBins; + const binsInput = $('#search-viz-n-bins'); + binsInput.attr('min', String(binBounds.min)); + binsInput.attr('max', String(binBounds.max)); + binsInput.attr('step', '1'); + binsInput.attr('title', 'Allowed range: ' + binBounds.min + '-' + binBounds.max); + + $('#search-viz-n-bins').val(nBins); + $('#search-viz-selected-feature').val(selectedFeature); + const chartElem = document.getElementById('search-results-visualization-graph') while (chartElem.firstChild) { chartElem.removeChild(chartElem.lastChild) } if (data && Object.keys(data).length > 0 && selectedFeature in data) { - let [chartData, chartLabels] = createHistogramData(data[selectedFeature], nBins) + let [chartData, chartLabels] = createHistogramData(data[selectedFeature], nBins, selectedFeature) console.log("chart_data", chartData) console.log("chart_labels", chartLabels) @@ -90,41 +580,84 @@ function updateSearchVizualization() { { "labels": chartLabels, "data": chartData, + "labelName": selectedFeature, }, - 'bar', + chartType, ) } + persistSearchVizSettingsToRoute(); + applySearchVisualizationExpandedStateFromRoute(); + persistSearchVisualizationExpandedStateToRoute(); } +$('body').on('click', '.search-visualization-container .change-type', function () { + if (!window.searchVizualization || !window.searchVizualization.settings) return; + const type = String($(this).data('type') || '').trim(); + if (!type) return; + window.searchVizualization.settings.chartType = type; + persistSearchVizSettingsToRoute(); +}); + +$('body').on('click', '#search-results-visualization-container .group-box > .flexed.clickable', function () { + const isCurrentlyOpen = getSearchVisualizationExpandedStateFromDom() === true; + const shouldOpen = !isCurrentlyOpen; + searchVizToggleInProgressUntil = Date.now() + 250; + setSearchVisualizationExpandedState(shouldOpen, true); + if (window.uceUiState) { + if (shouldOpen) window.uceUiState.set('svOpen', 'true'); + else window.uceUiState.remove('svOpen'); + } +}); + /** * Starts a new search with the given input */ -function startNewSearch(searchInput, reloadCorpus = true) { +function startNewSearch(searchInput, reloadCorpus = true, options = {}) { if (searchInput === undefined) { return; } - $('.search-menu-div').hide(); - $('.view[data-id="search"] .loader-container').first().fadeIn(150); // Get the selected corpus const selectElement = document.getElementById("corpus-select"); - const selectedOption = selectElement.options[selectElement.selectedIndex]; - const corpusId = selectedOption.getAttribute("data-id"); + const selectedOption = selectElement && selectElement.options + ? selectElement.options[selectElement.selectedIndex] + : null; + const corpusId = selectedOption ? selectedOption.getAttribute("data-id") : null; + if (!corpusId || corpusId === 'null' || corpusId === 'undefined') { + showMessageModal("No Corpus Selected", "Please select a corpus before starting a search."); + return; + } + + $('.search-menu-div').hide(); + $('.view[data-id="search"] .loader-container').first().fadeIn(150); // Get the selected search layers - const fulltextOrNeLayer = $('.search-menu-div .search-settings-div input[name="searchLayerRadioOptions"]:checked').val(); + const fulltextOrNeLayer = $('.search-menu-div .search-settings-div input[name="searchLayerRadioOptions"]:checked').val() || 'FULLTEXT'; const embeddings = $('.search-menu-div .search-settings-div .option input[data-id="EMBEDDINGS"]').is(':checked'); const kwic = $('.search-menu-div .search-settings-div .option input[data-id="KWIC"]').is(':checked'); const enrich = $('.search-menu-div .search-settings-div .option input[data-id="ENRICH"]').is(':checked'); const proMode = $('#proModeSwitch').is(':checked'); const useLayeredSearch = $('.search-menu-div .search-settings-div .submit-layered-search-input').val() === 'true'; + const forcedLayeredState = options && options.layeredState ? options.layeredState : null; let layers = {}; let layeredSearchId = ''; - if (useLayeredSearch === true) { + if (forcedLayeredState && forcedLayeredState.submit === true && Array.isArray(forcedLayeredState.layers) && forcedLayeredState.layers.length > 0) { + layers = forcedLayeredState.layers; + layeredSearchId = forcedLayeredState.searchId + ? String(forcedLayeredState.searchId) + : ((window.layeredSearchHandler && window.layeredSearchHandler.searchId) + ? window.layeredSearchHandler.searchId + : generateUUID().toString().replaceAll("-", "")); + } else if (useLayeredSearch === true) { layers = window.layeredSearchHandler.buildApplicableLayers([]); layeredSearchId = window.layeredSearchHandler.searchId; } + const layeredState = forcedLayeredState + ? ((forcedLayeredState.submit === true && Array.isArray(forcedLayeredState.layers) && forcedLayeredState.layers.length > 0) ? forcedLayeredState : null) + : getLayeredSearchStateForRoute(); + persistSearchRequestToRoute(searchInput, corpusId, proMode, layeredState); + // Get possible uce metadata filters of this selectec corpus let metadataFilters = []; $('.uce-corpus-search-filter[data-id="' + corpusId + '"]').find('.filter-div').each(function () { @@ -172,6 +705,7 @@ function startNewSearch(searchInput, reloadCorpus = true) { success: async function (response) { $('.view .search-result-container').html(response); activatePopovers(); + refreshPaginationControls(); if (reloadCorpus) { reloadCorpusComponents(); // Store the search in the local browser for a history. @@ -179,9 +713,17 @@ function startNewSearch(searchInput, reloadCorpus = true) { } // Load the corpus universe from search const searchId = $('.search-state').data('id'); - currentCorpusUniverseHandler = getNewCorpusUniverseHandler; - await currentCorpusUniverseHandler.createEmptyUniverse('search-universe-container'); - await currentCorpusUniverseHandler.fromSearch(searchId); + if (window.uceUiState && searchId) { + window.uceUiState.set('searchId', String(searchId)); + } + if (typeof getNewCorpusUniverseHandler !== 'undefined') { + currentCorpusUniverseHandler = getNewCorpusUniverseHandler; + await currentCorpusUniverseHandler.createEmptyUniverse('search-universe-container'); + await currentCorpusUniverseHandler.fromSearch(searchId); + } + applySearchStateFromRoute(); + applySearchVisualizationExpandedStateFromRoute(); + persistSearchVisualizationExpandedStateToRoute(); }, error: function (xhr, status, error) { if (xhr.status === 406) { @@ -214,6 +756,44 @@ function addSearchToHistory(searchTerm) { updateSearchHistoryUI(); } +function setEnrichedGroupFilter(btn, key) { + const $btn = $(btn); + const $container = $btn.closest('.children-container'); + if (!$container.length) return; + + $container.find('.grouped-children-chip').removeClass('selected'); + $btn.addClass('selected'); + + $container.find('.grouped-children-list').addClass('display-none'); + $container.find('.grouped-children-list[data-group-key="' + key + '"]').removeClass('display-none'); +} + +function renderGroupedChildrenFromJson(btn, key) { + const $btn = $(btn); + const $container = $btn.closest('.children-container'); + if (!$container.length) return; + + const jsonText = ($container.find('.grouped-children-json').first().text() || '').trim(); + if (!jsonText) return; + + let grouped; + try { + grouped = JSON.parse(jsonText); + } catch (e) { + console.error('Invalid grouped-children JSON payload', e); + return; + } + + const values = Array.isArray(grouped[key]) ? grouped[key] : []; + const html = values.map(function (v) { + return ""; + }).join(''); + + $container.find('.grouped-children-chip').removeClass('selected'); + $btn.addClass('selected'); + $container.find('.grouped-children-json-list').first().html(html); +} + /** * Handles the opening of the current corpus universe */ @@ -325,6 +905,7 @@ $('body').on('click', '.search-result-container .page-btn', function () { }) $('body').on('click', '.search-result-container .next-page-btn', function () { + if ($(this).hasClass('disabled') || $(this).attr('aria-disabled') === 'true') return; const $pagination = $('.search-result-container .pagination'); let curPage = parseInt($pagination.data('cur')); let max = parseInt($pagination.data('max')); @@ -352,9 +933,12 @@ async function handleSwitchingOfPage(page) { $('.view .search-result-container .document-list-include').html(response.documentsList); $('.view .search-result-container .navigation-include').html(response.navigationView); $('.view .search-result-container .keyword-in-context-include').html(response.keywordInContextView); + refreshPaginationControls(); + if (window.uceUiState) window.uceUiState.set('page', page); if(response.searchVisualization && window.searchVizualization){ - const vizData = response.searchVisualization; + const vizData = normalizeSearchVisualizationPayload(response.searchVisualization); + if (!vizData) return; window.searchVizualization.vizData = vizData; updateSearchVizualization(); } @@ -368,6 +952,20 @@ async function handleSwitchingOfPage(page) { }); } +function refreshPaginationControls() { + const $pagination = $('.search-result-container .pagination'); + if ($pagination.length === 0) return; + const curPage = parseInt($pagination.data('cur'), 10); + const max = parseInt($pagination.data('max'), 10); + const $prev = $pagination.find('.next-page-btn[data-direction="-"]'); + const $next = $pagination.find('.next-page-btn[data-direction="+"]'); + + const disablePrev = !Number.isFinite(curPage) || curPage <= 1; + const disableNext = !Number.isFinite(curPage) || !Number.isFinite(max) || curPage >= max; + $prev.toggleClass('disabled', disablePrev).attr('aria-disabled', disablePrev ? 'true' : 'false'); + $next.toggleClass('disabled', disableNext).attr('aria-disabled', disableNext ? 'true' : 'false'); +} + /** * Handles the expanding and de-expanding of the annotation hit container in each document card */ @@ -390,12 +988,13 @@ $('body').on('click', '.search-result-container .annotation-hit-container-expand */ $('body').on('click', '.sort-container .sort-btn', function () { const orderBy = $(this).data('orderby'); - const curOrder = $(this).data('curorder'); + const curOrder = String($(this).data('curorder') || 'ASC').toUpperCase(); + const nextOrder = curOrder === "ASC" ? "DESC" : "ASC"; const searchId = $('.search-state').data('id'); $('.search-result-container .loader-container').first().fadeIn(150); $.ajax({ - url: "/api/search/active/sort?searchId=" + searchId + "&order=" + curOrder + "&orderBy=" + orderBy, + url: "/api/search/active/sort?searchId=" + searchId + "&order=" + nextOrder + "&orderBy=" + orderBy, type: "GET", success: function (response) { // Render the new documents @@ -410,24 +1009,28 @@ $('body').on('click', '.sort-container .sort-btn', function () { }); // Highlight the correct button - if (curOrder === "ASC") { + if (nextOrder === "DESC") { $(this).find('i').removeClass('fa-sort-amount-up').addClass('fa-sort-amount-down'); - $(this).data('curorder', 'DESC'); } else { $(this).find('i').removeClass('fa-sort-amount-down').addClass('fa-sort-amount-up'); - $(this).data('curorder', 'ASC'); } + $(this).data('curorder', nextOrder); $(this).closest('.sort-container').find('.sort-btn').each(function () { $(this).removeClass('active-sort-btn'); }) $(this).addClass('active-sort-btn'); + if (window.uceUiState) { + window.uceUiState.set('sortBy', orderBy); + window.uceUiState.set('sortOrder', nextOrder); + } }) /** * Handles the switching of the search layers */ $('body').on('click', '.sort-container .switch-search-layer-result-btn', function () { + if ($(this).hasClass('is-static')) return; const layer = $(this).data('layer'); $(`.search-result-container .list`).each(function () { $(this).hide(); @@ -511,3 +1114,15 @@ $(window).on('scroll', function () { currentFocusedDocumentId = documentId; }); + +$(document).ready(function () { + refreshPaginationControls(); + // Prevent blank search view after reload: restore from route/search context if needed. + restoreSearchFromRouteIfNeeded(); + ensureSearchViewStateOnEnter(); +}); + +$(window).on('hashchange pageshow', function () { + restoreSearchFromRouteIfNeeded(); + ensureSearchViewStateOnEnter(); +}); diff --git a/uce.portal/resources/templates/js/site.js b/uce.portal/resources/templates/js/site.js index e9edbbc6..2282883e 100644 --- a/uce.portal/resources/templates/js/site.js +++ b/uce.portal/resources/templates/js/site.js @@ -2,6 +2,53 @@ var selectedCorpus = -1; var currentView = undefined; var reloadTimelineMap = false; +function getUiStateParams() { + const rawHash = window.location.hash && window.location.hash.startsWith('#') + ? window.location.hash.substring(1) + : ''; + return new URLSearchParams(rawHash); +} + +function updateUiState(mutator) { + const params = getUiStateParams(); + mutator(params); + const serialized = params.toString(); + const nextUrl = window.location.pathname + window.location.search + (serialized ? ('#' + serialized) : ''); + history.replaceState(null, '', nextUrl); +} + +function stripLegacyLexiconQueryParams() { + const searchParams = new URLSearchParams(window.location.search || ''); + const keys = ['lex_q', 'lex_char', 'lex_filters', 'lex_sort', 'lex_dir', 'lex_page']; + let changed = false; + keys.forEach((key) => { + if (searchParams.has(key)) { + searchParams.delete(key); + changed = true; + } + }); + if (!changed) return; + const cleanSearch = searchParams.toString(); + const nextUrl = window.location.pathname + (cleanSearch ? ('?' + cleanSearch) : '') + window.location.hash; + history.replaceState(null, '', nextUrl); +} + +window.uceUiState = { + get: function (key) { + const value = getUiStateParams().get(key); + return value === null ? undefined : value; + }, + set: function (key, value) { + updateUiState((params) => { + if (value === undefined || value === null || value === '') params.delete(key); + else params.set(key, String(value)); + }); + }, + remove: function (key) { + updateUiState((params) => params.delete(key)); + } +}; + function generateUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0, @@ -19,18 +66,22 @@ $('body').on('click', 'nav .switch-view-btn', function () { navigateToView(id); }) -function navigateToView(id) { +function navigateToView(id, options = {}) { + const preserveInspectorRoute = !!options.preserveInspectorRoute; // Close any potential modals: - $('.corpus-inspector-include').hide(150) + closeCorpusInspector(!preserveInspectorRoute); // Now adjust the main content + let foundView = false; $('.main-content-container .view').each(function () { if ($(this).data('id') === id) { $(this).show(50); + foundView = true; } else { $(this).hide(); } }) + if (!foundView) return; // Show the correct button $('nav .switch-view-btn').each(function (b) { @@ -52,6 +103,104 @@ function navigateToView(id) { } currentView = id; + stripLegacyLexiconQueryParams(); + if (window.uceUiState) { + window.uceUiState.set('view', id); + if (id !== 'search') { + [ + 'q', + 'searchId', + 'page', + 'sortBy', + 'sortOrder', + 'bins', + 'feature', + 'chartType', + 'svOpen', + 'ls', + 'proMode', + 'corpusId' + ].forEach((key) => window.uceUiState.remove(key)); + } + if (id !== 'lexicon') { + [ + 'lex_q', + 'lex_char', + 'lex_filters', + 'lex_sort', + 'lex_dir', + 'lex_page' + ].forEach((key) => window.uceUiState.remove(key)); + } + } + if (id === 'search' && typeof ensureSearchViewStateOnEnter === 'function') { + window.setTimeout(() => ensureSearchViewStateOnEnter(), 0); + } +} + +function setCorpusInspectorRouteState(corpusId) { + if (!window.uceUiState) return; + if (corpusId === undefined || corpusId === null || String(corpusId).trim() === '') { + window.uceUiState.remove('ci'); + window.uceUiState.remove('ciCorpusId'); + return; + } + window.uceUiState.set('ci', 'true'); + window.uceUiState.set('ciCorpusId', String(corpusId)); +} + +function closeCorpusInspector(clearRouteState = true) { + $('.corpus-inspector-include').hide(150); + $('.corpus-inspector-include').removeAttr('data-active-corpus-id'); + if (clearRouteState) { + setCorpusInspectorRouteState(undefined); + } +} + +function openCorpusInspector(corpusId) { + if (corpusId === undefined || corpusId === null || String(corpusId).trim() === '') return; + + $('.corpus-inspector-include').show(0); + $('.corpus-inspector-include').attr('data-active-corpus-id', String(corpusId)); + $('.wiki-page-modal').addClass('wiki-page-modal-minimized'); + setCorpusInspectorRouteState(corpusId); + + $.ajax({ + url: "/api/corpus/inspector?id=" + corpusId, + type: "GET", + success: function (response) { + // Render the corpus view + $('.corpus-inspector-include').html(response); + if (window.wikiHandler && typeof window.wikiHandler.syncCurrentPageLinks === 'function') { + window.wikiHandler.syncCurrentPageLinks(); + } + + // After that, we load documentsListView + loadCorpusDocuments(corpusId, $('.corpus-inspector-include .corpus-documents-list-include')); + }, + error: function (xhr, status, error) { + console.error(xhr.responseText); + $('.corpus-inspector-include').html(xhr.responseText); + } + }); +} + +function restoreCorpusInspectorFromRoute() { + if (!window.uceUiState) return; + const routeCi = String(window.uceUiState.get('ci') || '').toLowerCase(); + const routeCiCorpusId = String(window.uceUiState.get('ciCorpusId') || ''); + const shouldOpen = routeCi === 'true' && routeCiCorpusId !== ''; + const isOpen = $('.corpus-inspector-include:visible').length > 0; + + if (!shouldOpen && isOpen) { + $('.corpus-inspector-include').hide(150); + return; + } + if (!shouldOpen) return; + + const currentInspectorCorpus = String($('.corpus-inspector-include').attr('data-active-corpus-id') || ''); + if (isOpen && currentInspectorCorpus === routeCiCorpusId) return; + openCorpusInspector(routeCiCorpusId); } /** @@ -85,7 +234,10 @@ $('body').on('click', '.view .search-btn', function (event) { * Fires whenever a new corpus is selected. We update some UI components then */ $('body').on('change', '#corpus-select', function () { - const selectedOption = $(this).get(0).options[$(this).get(0).selectedIndex]; + const selectEl = $(this).get(0); + if (!selectEl || !selectEl.options || selectEl.selectedIndex < 0) return; + const selectedOption = selectEl.options[selectEl.selectedIndex]; + if (!selectedOption) return; const hasSr = selectedOption.getAttribute("data-hassr"); const hasBiofidOnthology = selectedOption.getAttribute("data-hasbiofid"); const sparqlAlive = selectedOption.getAttribute("data-sparqlalive"); @@ -96,7 +248,8 @@ $('body').on('change', '#corpus-select', function () { const hasGeoNameAnnotations = selectedOption.getAttribute("data-hasgeonameannotations"); const oldCorpusId = selectedCorpus; selectedCorpus = parseInt(selectedOption.getAttribute("data-id")); - if (oldCorpusId !== selectedCorpus) { + // Do not auto-run a search during first-time initialization. + if (!window.__uceSuppressAutoSearchOnCorpusChange && oldCorpusId !== -1 && oldCorpusId !== selectedCorpus) { // We have switched corpora then, start a new empty search. startNewSearch("", false); } @@ -157,26 +310,27 @@ $('body').on('click', '.open-corpus-inspector-btn', function () { corpusId = selectedOption.getAttribute("data-id"); } - // If the wiki modal is currently open, close it. - $('.corpus-inspector-include').show(0); - $('.wiki-page-modal').addClass('wiki-page-modal-minimized'); + openCorpusInspector(corpusId); +}) - $.ajax({ - url: "/api/corpus/inspector?id=" + corpusId, - type: "GET", - success: function (response) { - // Render the corpus view - $('.corpus-inspector-include').html(response); +$('body').on('click', '.close-corpus-inspector-btn', function () { + closeCorpusInspector(); +}); - // After that, we load documentsListView - loadCorpusDocuments(corpusId, $('.corpus-inspector-include .corpus-documents-list-include')); - }, - error: function (xhr, status, error) { - console.error(xhr.responseText); - $('.corpus-inspector-include').html(xhr.responseText); - } - }); -}) +/** + * Generic disabled-action guard for links/buttons. + * Any interactive element can opt in by using class `ui-action-disabled` + * or `aria-disabled="true"` (optionally with `data-disabled-reason`). + */ +$('body').on('click', 'a.ui-action-disabled, button.ui-action-disabled, [role="button"].ui-action-disabled, a[aria-disabled="true"][data-disabled-reason], button[aria-disabled="true"][data-disabled-reason], [role="button"][aria-disabled="true"][data-disabled-reason]', function (event) { + event.preventDefault(); + event.stopPropagation(); + + const reason = $(this).attr('data-disabled-reason'); + if (reason) { + showMessageModal("Unavailable action", reason); + } +}); /** * Loads the raw document list to a corpus into a target include. @@ -256,13 +410,72 @@ function openNewDocumentReadView(id, searchId) { if (id === undefined || id === '') { return; } - window.open("/documentReader?id=" + id + "&searchId=" + searchId, '_blank'); + const params = new URLSearchParams(); + params.set("id", String(id)); + + if (searchId !== undefined && searchId !== null) { + const raw = String(searchId).trim(); + if (raw !== "" && raw.toLowerCase() !== "undefined" && raw.toLowerCase() !== "null") { + params.set("searchId", raw); + } + } + + window.open("/documentReader?" + params.toString(), '_blank'); +} + +function dismissAllPopovers(options = {}) { + const opts = Object.assign({ dispose: false, removeOrphans: true }, options); + const $targets = $('[data-toggle="popover"]'); + $targets.each(function () { + const $el = $(this); + const hasPopover = !!$el.data('bs.popover'); + if (!hasPopover) return; + try { + $el.popover('hide'); + if (opts.dispose) { + $el.popover('dispose'); + } + } catch (e) { + // Ignore stale plugin instances; we'll still remove dangling DOM below. + } + }); + + if (opts.removeOrphans) { + $('.popover').remove(); + } } function activatePopovers() { - $('[data-toggle="popover"]').popover(); + dismissAllPopovers({ dispose: true, removeOrphans: true }); + $('[data-toggle="popover"]').popover({ + container: 'body' + }); } +(function installPopoverPersistenceGuards() { + if (window.__ucePopoverPersistenceGuardsInstalled) return; + window.__ucePopoverPersistenceGuardsInstalled = true; + + $('body').on('mouseleave', '.breadcrumbs [data-toggle="popover"]', function () { + try { + $(this).popover('hide'); + } finally { + // In case hide event is missed due to DOM updates. + window.setTimeout(function () { + $('.popover').remove(); + }, 120); + } + }); + + $('body').on('click', '.breadcrumbs [data-toggle="popover"]', function () { + dismissAllPopovers({ dispose: false, removeOrphans: true }); + }); + + $(document).on('scroll touchmove', function () { + dismissAllPopovers({ dispose: false, removeOrphans: false }); + }); +})(); + /** * We have some UI components that need to be refreshed when the corpus is loaded. */ @@ -270,11 +483,204 @@ function reloadCorpusComponents() { $('#corpus-select').change(); } +function sanitizeLegacyVizQueryParams() { + const params = new URLSearchParams(window.location.search || ''); + let changed = false; + ['search-viz-n-bins', 'search-viz-selected-feature'].forEach((key) => { + if (params.has(key)) { + params.delete(key); + changed = true; + } + }); + if (!changed) return; + const nextSearch = params.toString(); + const nextUrl = window.location.pathname + (nextSearch ? ('?' + nextSearch) : '') + window.location.hash; + history.replaceState(null, '', nextUrl); +} + $(document).ready(function () { console.log('Webpage loaded!'); + sanitizeLegacyVizQueryParams(); activatePopovers(); reloadCorpusComponents(); + const initialView = window.uceUiState ? window.uceUiState.get('view') : undefined; + if (initialView) { + navigateToView(initialView, { preserveInspectorRoute: true }); + } + restoreCorpusInspectorFromRoute(); // Init the lexicon - if (window.wikiHandler) window.wikiHandler.fetchLexiconEntries(0, 24); + if (window.wikiHandler) window.wikiHandler.initializeLexicon(); }) +$(window).on('hashchange', function () { + if (!window.uceUiState) return; + const routeView = window.uceUiState.get('view'); + if (routeView && routeView !== currentView) { + navigateToView(routeView); + } + restoreCorpusInspectorFromRoute(); +}); + +(function installSessionExpiredHandler() { + if (window.__uceSessionExpiredHandlerInstalled) return; + window.__uceSessionExpiredHandlerInstalled = true; + + const modalSelector = '#sessionExpiredModal'; + const countdownSelector = '#sessionExpiredCountdown'; + const reloginBtnSelector = '#sessionExpiredReloginBtn'; + const homeBtnSelector = '#sessionExpiredHomeBtn'; + + function safeReturnTo() { + return window.location.pathname + window.location.search + window.location.hash; + } + + function getCountdownSeconds() { + const el = document.querySelector(modalSelector); + const raw = el ? el.getAttribute('data-countdown-seconds') : null; + const parsed = parseInt(raw || '30', 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 30; + } + + function getLoginBaseUrl() { + // keep this in sync with the login icon href in index.ftl + return "${uceConfig.getSettings().getAuthentication().getPublicUrl()}/realms/uce/protocol/openid-connect/auth" + + "?client_id=uce-web&response_type=code&scope=openid" + + "&redirect_uri=${uceConfig.getSettings().getAuthentication().getRedirectUrl()}/login"; + } + + let modalOpen = false; + let timerId = null; + let remainingSeconds = 0; + let lastAuthPingAt = 0; + + function isLoggedInUiState() { + const el = document.querySelector('a.user-profile-btn'); + if (!el) return false; + const href = el.getAttribute('href') || ''; + // Logged-in: href="#" and onclick opens profile; logged-out: href points to Keycloak auth endpoint. + if (href === '#') return true; + if (el.getAttribute('onclick')) return true; + return false; + } + + function hadSessionBefore() { + return sessionStorage.getItem('uce:hadSession') === '1'; + } + + function markHadSession() { + sessionStorage.setItem('uce:hadSession', '1'); + } + + function cleanup() { + if (timerId !== null) { + window.clearInterval(timerId); + timerId = null; + } + modalOpen = false; + } + + function startAuthPingOnUserAttention() { + const authEnabled = ${uceConfig.authIsEnabled()?c}; + if (!authEnabled) return; + if (!window.fetch) return; + + function triggerAuthPing() { + if (modalOpen) return; + // Avoid popping "session expired" for users who were never logged in. + if (!hadSessionBefore() && !isLoggedInUiState()) return; + const now = Date.now(); + if (now - lastAuthPingAt < 5000) return; + lastAuthPingAt = now; + window.fetch('/api/auth/ping', {cache: 'no-store'}) + .then(function (res) { + if (res && res.status === 204) { + markHadSession(); + } + }) + .catch(function () {}); + } + + window.addEventListener('focus', function () { + triggerAuthPing(); + }); + window.addEventListener('pageshow', function () { + triggerAuthPing(); + }); + document.addEventListener('visibilitychange', function () { + if (!document.hidden) { + triggerAuthPing(); + } + }); + } + + function hardLogoutToHome() { + cleanup(); + window.location.assign('/logout'); + } + + function relogin() { + cleanup(); + const returnTo = sessionStorage.getItem('uce:returnTo') || '/'; + const url = getLoginBaseUrl() + "&state=" + encodeURIComponent(returnTo); + window.location.assign(url); + } + + function openModalOnce() { + if (modalOpen) return; + // Avoid showing this modal to users who never had a session. + if (!hadSessionBefore() && !isLoggedInUiState()) return; + modalOpen = true; + + markHadSession(); + sessionStorage.setItem('uce:returnTo', safeReturnTo()); + remainingSeconds = getCountdownSeconds(); + + const $modal = $(modalSelector); + $modal.find(countdownSelector).text(String(remainingSeconds)); + $modal.modal({backdrop: 'static', keyboard: false}); + + timerId = window.setInterval(function () { + remainingSeconds -= 1; + $(countdownSelector).text(String(Math.max(0, remainingSeconds))); + if (remainingSeconds <= 0) { + hardLogoutToHome(); + } + }, 1000); + } + + // Buttons (event delegation: safe even if modal is included once at page load) + $('body').on('click', reloginBtnSelector, function () { + relogin(); + }); + $('body').on('click', homeBtnSelector, function () { + hardLogoutToHome(); + }); + $(document).on('hidden.bs.modal', modalSelector, function () { + cleanup(); + }); + + // Intercept fetch() centrally (covers code using fetch directly) + if (window.fetch) { + const originalFetch = window.fetch.bind(window); + window.fetch = function (input, init) { + return originalFetch(input, init).then(function (res) { + if (res && res.status === 401) { + openModalOnce(); + } + return res; + }).catch(function (err) { + throw err; + }); + }; + } + + // Intercept jQuery ajax globally (covers $.ajax usage) + $(document).ajaxError(function (event, xhr) { + if (xhr && xhr.status === 401) { + openModalOnce(); + } + }); + + // Detect session loss when the user returns to the tab/window (avoids keeping the session alive). + startAuthPingOnUserAttention(); +})(); diff --git a/uce.portal/resources/templates/js/wiki.js b/uce.portal/resources/templates/js/wiki.js index c2684518..3d0f6910 100644 --- a/uce.portal/resources/templates/js/wiki.js +++ b/uce.portal/resources/templates/js/wiki.js @@ -7,25 +7,47 @@ let WikiHandler = (function () { WikiHandler.prototype.lexiconState = { skip: 0, take: 24, + hasMoreEntries: true, + lastPageSize: 0, + knownEmptySkip: null, selectedChar: '', searchInput: '', annotationFilters: [], sortColumn: 'occurrence', sortDirection: 'DESC', + searchDebounceHandle: null, } function WikiHandler() { + this.brokenWikiTargets = new Set(); } + WikiHandler.prototype.isLexiconRouteActive = function () { + if (typeof currentView !== 'undefined' && currentView) { + return currentView === 'lexicon'; + } + if (window.uceUiState && typeof window.uceUiState.get === 'function') { + return String(window.uceUiState.get('view') || '') === 'lexicon'; + } + return false; + } + + WikiHandler.prototype.homePage = { + wid: "DOC-SEARCH", + coveredText: "-", + hash: "DOC-SEARCH|-" + }; + // =================== Lexicon Methods =================== WikiHandler.prototype.updateLexiconPage = function () { let curPage = this.lexiconState.skip / this.lexiconState.take + 1; let start = 1; if (curPage <= 3) start = 1; else if (curPage > 4) start = curPage - 3; + const end = this.lexiconState.hasMoreEntries ? curPage + 3 : curPage; const btnList = $('.lexicon-view .lexicon-navigation .pages-count'); btnList.html(""); - for (let i = start; i < curPage + 4; i++) { + for (let i = start; i <= end; i++) { const selected = i === curPage ? "cur-page" : ""; btnList.append( "PAGE" @@ -33,14 +55,164 @@ let WikiHandler = (function () { .replace("SELECTED", selected) ); } + this.updateLexiconNavigationButtons(); + } + + WikiHandler.prototype.updateLexiconNavigationButtons = function () { + const curPage = this.lexiconState.skip / this.lexiconState.take + 1; + const canGoPrevious = curPage > 1; + const canGoNext = this.lexiconState.hasMoreEntries; + + const $prev = $('.lexicon-view .lexicon-navigation .lexicon-prev-page-btn'); + const $next = $('.lexicon-view .lexicon-navigation .lexicon-next-page-btn'); + + $prev.toggleClass('ui-action-disabled', !canGoPrevious); + $prev.attr('aria-disabled', canGoPrevious ? 'false' : 'true'); + $next.toggleClass('ui-action-disabled', !canGoNext); + $next.attr('aria-disabled', canGoNext ? 'false' : 'true'); + } + + WikiHandler.prototype.persistLexiconStateToUrl = function () { + if (!this.isLexiconRouteActive()) return; + const searchInput = String(this.lexiconState.searchInput || '').trim(); + const selectedChar = String(this.lexiconState.selectedChar || '').trim(); + const filters = Array.isArray(this.lexiconState.annotationFilters) + ? this.lexiconState.annotationFilters.filter(Boolean) + : []; + + if (!window.uceUiState) return; + + if (searchInput) window.uceUiState.set('lex_q', searchInput); + else window.uceUiState.remove('lex_q'); + + if (selectedChar) window.uceUiState.set('lex_char', selectedChar); + else window.uceUiState.remove('lex_char'); + + if (filters.length > 0) window.uceUiState.set('lex_filters', filters.join(',')); + else window.uceUiState.remove('lex_filters'); + + if (this.lexiconState.sortColumn) window.uceUiState.set('lex_sort', this.lexiconState.sortColumn); + else window.uceUiState.remove('lex_sort'); + + if (this.lexiconState.sortDirection) window.uceUiState.set('lex_dir', this.lexiconState.sortDirection); + else window.uceUiState.remove('lex_dir'); + + const page = Math.floor(this.lexiconState.skip / this.lexiconState.take) + 1; + if (page > 1) window.uceUiState.set('lex_page', String(page)); + else window.uceUiState.remove('lex_page'); + } + + WikiHandler.prototype.applyLexiconStateFromUrl = function () { + const rawSearch = String((window.uceUiState && window.uceUiState.get('lex_q')) || ''); + const rawChar = String((window.uceUiState && window.uceUiState.get('lex_char')) || '').trim(); + const rawFilters = String((window.uceUiState && window.uceUiState.get('lex_filters')) || '').trim(); + const rawSort = String((window.uceUiState && window.uceUiState.get('lex_sort')) || '').trim().toLowerCase(); + const rawDir = String((window.uceUiState && window.uceUiState.get('lex_dir')) || '').trim().toUpperCase(); + const rawPage = parseInt(String((window.uceUiState && window.uceUiState.get('lex_page')) || '1'), 10); + + this.lexiconState.searchInput = rawSearch; + $('.lexicon-view .search-lexicon-input').val(rawSearch); + + if (rawChar) { + this.lexiconState.selectedChar = rawChar; + $('.lexicon-view .alphabet .char').each(function () { + $(this).toggleClass('selected-char', $(this).html() === rawChar); + }); + } else { + this.lexiconState.selectedChar = ''; + $('.lexicon-view .alphabet .selected-char').removeClass('selected-char'); + } + + const requestedFilters = rawFilters + ? rawFilters.split(',').map(value => value.trim()).filter(Boolean) + : []; + if (requestedFilters.length > 0) { + const requestedFilterSet = new Set(requestedFilters); + let activeFilters = []; + $('.lexicon-view .filter-container .annotation-filter').each(function () { + const label = String($(this).find('label').html() || '').trim(); + const checked = requestedFilterSet.has(label); + $(this).find('input').prop('checked', checked); + if (checked) activeFilters.push(label); + }); + this.lexiconState.annotationFilters = activeFilters; + } + + if (rawSort === 'occurrence' || rawSort === 'alphabet') { + this.lexiconState.sortColumn = rawSort; + } + if (rawDir === 'ASC' || rawDir === 'DESC') { + this.lexiconState.sortDirection = rawDir; + } + + $('.lexicon-view .sortings a').each((_, node) => { + const $node = $(node); + const isSelected = $node.data('id') === this.lexiconState.sortColumn; + $node.toggleClass('selected-sort', isSelected); + if (isSelected) { + $node.data('dir', this.lexiconState.sortDirection); + $node.toggleClass('turn-180', this.lexiconState.sortDirection === 'DESC'); + } else { + $node.removeClass('turn-180'); + } + }); + + const safePage = Number.isFinite(rawPage) && rawPage > 1 ? rawPage : 1; + this.lexiconState.skip = this.lexiconState.take * (safePage - 1); + this.lexiconState.knownEmptySkip = null; + } + + WikiHandler.prototype.initializeLexicon = function () { + this.applyLexiconStateFromUrl(); + this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } WikiHandler.prototype.handleLexiconSearchInputChanged = function ($source) { + if (this.lexiconState.searchDebounceHandle) { + clearTimeout(this.lexiconState.searchDebounceHandle); + this.lexiconState.searchDebounceHandle = null; + } this.lexiconState.searchInput = $source.val(); this.lexiconState.skip = 0; + this.lexiconState.knownEmptySkip = null; this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } + WikiHandler.prototype.handleLexiconSearchInputTyped = function ($source) { + if (this.lexiconState.searchDebounceHandle) { + clearTimeout(this.lexiconState.searchDebounceHandle); + } + this.lexiconState.searchDebounceHandle = setTimeout(() => { + this.handleLexiconSearchInputChanged($source); + }, 300); + } + + WikiHandler.prototype.handleLexiconSearchInputKeyup = function ($source, event) { + if (event && event.key === 'Enter') { + event.preventDefault(); + this.handleLexiconSearchInputChanged($source); + } + } + + WikiHandler.prototype.setLexiconLoadingState = function (isLoading) { + const $loader = $('.lexicon-view .lexicon-content-include .lexicon-loader-container'); + const $status = $('.lexicon-view .lexicon-content-include .lexicon-update-status'); + if (isLoading) { + $status.stop(true, true).addClass('display-none'); + $loader.stop(true, true).fadeIn(120).removeClass('display-none'); + $('.lexicon-view .lexicon-content-include').addClass('is-loading'); + return; + } + + $loader.stop(true, true).fadeOut(100, function () { + $(this).addClass('display-none'); + }); + $('.lexicon-view .lexicon-content-include').removeClass('is-loading'); + $status.stop(true, true).removeClass('display-none').fadeIn(80).delay(600).fadeOut(300, function () { + $(this).addClass('display-none'); + }); + } + WikiHandler.prototype.handleLexiconSortingChanged = function ($source) { this.lexiconState.sortColumn = $source.data('id'); let direction = $source.data('dir'); @@ -56,6 +228,7 @@ let WikiHandler = (function () { $source.toggleClass('turn-180'); $source.data('dir', this.lexiconState.sortDirection); + this.lexiconState.knownEmptySkip = null; this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } @@ -67,6 +240,7 @@ let WikiHandler = (function () { }); this.lexiconState.annotationFilters = activeFilters; this.lexiconState.skip = 0; + this.lexiconState.knownEmptySkip = null; this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } @@ -85,6 +259,7 @@ let WikiHandler = (function () { // In any case, we reset the list to page 1. this.lexiconState.skip = 0; + this.lexiconState.knownEmptySkip = null; this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } @@ -98,6 +273,8 @@ let WikiHandler = (function () { WikiHandler.prototype.fetchLexiconPage = function (pageNum) { if (pageNum < 1) return; + const curPage = this.lexiconState.skip / this.lexiconState.take + 1; + if (!this.lexiconState.hasMoreEntries && pageNum > curPage) return; this.lexiconState.skip = this.lexiconState.take * (pageNum - 1); this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } @@ -109,12 +286,16 @@ let WikiHandler = (function () { } WikiHandler.prototype.fetchNextLexiconEntries = function () { + if (!this.lexiconState.hasMoreEntries) return; this.lexiconState.skip += this.lexiconState.take; this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take); } - WikiHandler.prototype.fetchLexiconEntries = function (skip, take) { + WikiHandler.prototype.fetchLexiconEntries = function (skip, take, allowRetryOnEmpty = true) { + this.lexiconState.skip = skip; const alphabet = this.getLexiconAlphabet(); + this.persistLexiconStateToUrl(); + this.setLexiconLoadingState(true); $.ajax({ url: '/api/wiki/lexicon/entries', type: "POST", @@ -132,22 +313,60 @@ let WikiHandler = (function () { success: (response) => { activatePopovers(); console.log(response); - if(response.rendered) $('.lexicon-content-include').html(response.rendered); - else $('.lexicon-content-include').html(response); + const entries = Array.isArray(response && response.entries) ? response.entries : []; + this.lexiconState.lastPageSize = entries.length; + const knownEmptySkip = this.lexiconState.knownEmptySkip; + this.lexiconState.hasMoreEntries = entries.length >= take && + (knownEmptySkip === null || (skip + take) < knownEmptySkip); + + if (entries.length === 0 && skip > 0 && allowRetryOnEmpty) { + this.lexiconState.knownEmptySkip = skip; + this.lexiconState.hasMoreEntries = false; + this.lexiconState.skip = Math.max(0, skip - take); + this.fetchLexiconEntries(this.lexiconState.skip, this.lexiconState.take, false); + return; + } + + const $entryRegion = $('.lexicon-content-include .lexicon-entry-list-region'); + if(response.rendered) $entryRegion.html(response.rendered); + else $entryRegion.html(response); this.updateLexiconPage(); + this.setLexiconLoadMoreState(false, "Choose a lexicon entry first."); }, error: (xhr, status, error) => { showMessageModal("Unknown Error", "There was an unknown error loading the lexicon entries.") } }).always(() => { + this.setLexiconLoadingState(false); }); } + WikiHandler.prototype.setLexiconLoadMoreState = function (enabled, reason = "") { + const $btn = $('.lexicon-view .lexicon-entry-inspector .lexicon-load-more-btn'); + if ($btn.length === 0) return; + const disabled = !enabled; + $btn.toggleClass('ui-action-disabled', disabled); + $btn.attr('aria-disabled', disabled ? 'true' : 'false'); + if (disabled && reason) { + $btn.attr('data-disabled-reason', reason); + $btn.attr('title', reason); + } else { + $btn.removeAttr('data-disabled-reason'); + $btn.removeAttr('title'); + } + } + WikiHandler.prototype.handleLoadMoreOccurrences = function () { const $target = $('.lexicon-view .lexicon-entry-inspector .occurrences-list'); + const covered = String($target.data('covered') || '').trim(); + const type = String($target.data('type') || '').trim(); + if (!covered || !type) { + this.setLexiconLoadMoreState(false, "Choose a lexicon entry first."); + return; + } let skip = $target.data('skip') + this.occurrencesTake; $target.data('skip', skip); - this.fetchLexiconEntryOccurrences($target.data('covered'), $target.data('type'), + this.fetchLexiconEntryOccurrences(covered, type, skip, $target); } @@ -155,12 +374,17 @@ let WikiHandler = (function () { const $lexiconEntry = $source.closest('.lexicon-entry'); const type = $lexiconEntry.data('type'); const covered = $lexiconEntry.data('covered'); + if (!String(type || '').trim() || !String(covered || '').trim()) { + this.setLexiconLoadMoreState(false, "Choose a valid lexicon entry first."); + return; + } const $target = $('.lexicon-view .lexicon-entry-inspector .occurrences-list'); // clean the inspector list $target.html(''); $target.data('skip', 0); $target.data('covered', covered); $target.data('type', type); + this.setLexiconLoadMoreState(true); this.fetchLexiconEntryOccurrences(covered, type, 0, $target); } @@ -199,15 +423,184 @@ let WikiHandler = (function () { this.loadPage(lastPage, true); } + WikiHandler.prototype.handleHomeBtnClicked = function () { + this.loadPage(this.homePage); + } + + WikiHandler.prototype.normalizeCoveredText = function ($wikiEl) { + const rawCovered = $wikiEl.data('wcovered'); + if (rawCovered !== undefined && rawCovered !== null) { + const normalized = String(rawCovered).trim(); + if (normalized !== '' && normalized.toLowerCase() !== 'undefined' && normalized.toLowerCase() !== 'null') { + return normalized; + } + } + + const text = String($wikiEl.text() || '').trim(); + if (text !== '') return text; + return '-'; + } + + WikiHandler.prototype.normalizeWikiId = function (wid, $wikiEl = undefined) { + if (wid === undefined || wid === null) return ''; + let normalized = String(wid).trim(); + if (normalized === '' || normalized === '-' || normalized.toLowerCase() === 'undefined' || normalized.toLowerCase() === 'null') { + return ''; + } + + // Some legacy ids use underscores instead of hyphens. + normalized = normalized.replace(/^([A-Za-z_]+)_/, '$1-'); + + const inBreadcrumbs = !!($wikiEl && $wikiEl.closest('.breadcrumbs').length > 0); + const inCorpusInspector = !!($wikiEl && $wikiEl.closest('.corpus-inspector').length > 0); + const isCorpusBreadcrumb = !!($wikiEl && $wikiEl.find('.fa-globe').length > 0); + const isDocumentBreadcrumb = !!($wikiEl && $wikiEl.find('.fa-book').length > 0); + + if (normalized.includes('-')) return normalized; + + const elementTypeHint = $wikiEl ? String($wikiEl.data('wtype') || '').trim().toUpperCase() : ''; + + // If we only received a numeric id, derive its type from explicit data hint first. + if (/^\d+$/.test(normalized) && elementTypeHint) { + return elementTypeHint + "-" + normalized; + } + + // Corpus inspector links often point to corpus wiki pages; force numeric ids into corpus ids. + if (/^\d+$/.test(normalized) && inCorpusInspector) { + return "C-" + normalized; + } + + // Legacy/faulty ids can come without separator (e.g. "C123"), normalize to "C-123". + const match = normalized.match(/^([A-Za-z_]+)(\d+)$/); + if (match) return match[1] + "-" + match[2]; + + // Last-resort inference for breadcrumb links if data-wtype is missing. + if (/^\d+$/.test(normalized) && $wikiEl && $wikiEl.closest('.breadcrumbs').length > 0) { + if ($wikiEl.find('.fa-book').length > 0) return "D-" + normalized; + if ($wikiEl.find('.fa-globe').length > 0) return "C-" + normalized; + } + + // Breadcrumb corpus links can come from stale cached markup; use context-derived id only as final fallback. + if (inBreadcrumbs && isCorpusBreadcrumb) { + const corpusIdFromPage = Number($wikiEl.closest('.wiki-page').data('corpusid')); + if (Number.isFinite(corpusIdFromPage) && corpusIdFromPage > 0) { + return "C-" + corpusIdFromPage; + } + } + + // Additional last resort: parse document wiki id from the current page text if needed. + if (inBreadcrumbs && isDocumentBreadcrumb) { + const pageText = String($wikiEl.closest('.wiki-page').text() || ''); + const docMatch = pageText.match(/\bD-\d+\b/); + if (docMatch) return docMatch[0]; + } + + return normalized; + } + + WikiHandler.prototype.buildWikiDtoFromElement = function ($wikiEl) { + const wid = this.normalizeWikiId($wikiEl.data('wid'), $wikiEl); + const coveredText = this.normalizeCoveredText($wikiEl); + if (!wid) return undefined; + return { + wid: wid, + coveredText: coveredText, + hash: wid + "|" + coveredText + }; + } + + WikiHandler.prototype.getWikiTargetKey = function (wikiDto) { + if (!wikiDto || !wikiDto.wid) return ''; + const coveredText = String(wikiDto.coveredText || '').trim() || '-'; + return String(wikiDto.wid).trim() + "|" + coveredText; + } + + WikiHandler.prototype.isErrorTemplateResponse = function (response) { + if (typeof response !== 'string') return false; + const $root = $('
').html(response); + const hasErrorBanner = $root.find('.text-danger').length > 0; + const hasDefaultLogo = $root.find('img[src*=\"img/logo.png\"]').length > 0; + return hasErrorBanner && hasDefaultLogo; + } + + WikiHandler.prototype.markWikiTargetAsBroken = function (wikiDto) { + const key = this.getWikiTargetKey(wikiDto); + if (!key) return; + this.brokenWikiTargets.add(key); + this.syncCurrentPageLinks(); + } + + WikiHandler.prototype.syncCurrentPageLinks = function () { + const current = this.currentPage; + const isModalOpen = $('.wiki-page-modal').length > 0 && !$('.wiki-page-modal').hasClass('wiki-page-modal-minimized'); + $('.open-wiki-page').each((_, node) => { + const $el = $(node); + const dto = this.buildWikiDtoFromElement($el); + const hasConfiguredWid = String($el.data('wid') || '').trim() !== ''; + const isInvalidTarget = hasConfiguredWid && !dto; + const isCurrent = !!(current && dto && dto.hash === current.hash); + const isBroken = !!(dto && this.brokenWikiTargets.has(this.getWikiTargetKey(dto))); + const shouldDisable = (isCurrent && isModalOpen) || isBroken || isInvalidTarget; + const disabledReason = isBroken + ? 'This link target is currently unavailable.' + : ((isCurrent && isModalOpen) + ? 'You are already on this page.' + : (isInvalidTarget ? 'This link has an invalid wiki target.' : '')); + $el.toggleClass('wiki-link-current', isCurrent); + $el.toggleClass('wiki-link-broken', isBroken); + $el.toggleClass('ui-action-disabled', shouldDisable); + if (isCurrent) { + $el.attr('aria-current', 'page'); + } else { + $el.removeAttr('aria-current'); + } + if (shouldDisable) { + $el.attr('aria-disabled', 'true'); + if (disabledReason) { + $el.attr('title', disabledReason); + $el.attr('data-disabled-reason', disabledReason); + } else { + $el.removeAttr('title'); + $el.removeAttr('data-disabled-reason'); + } + } else { + $el.removeAttr('aria-disabled'); + $el.removeAttr('data-disabled-reason'); + if ($el.hasClass('open-wiki-page')) { + $el.removeAttr('title'); + } + } + }); + } + WikiHandler.prototype.loadPage = function (wikiDto, calledFromBackBtn = false) { + if (!wikiDto || !wikiDto.wid) return; + + const safeCoveredText = String(wikiDto.coveredText || '').trim() || '-'; + const safeHash = wikiDto.hash || (wikiDto.wid + "|" + safeCoveredText); + const normalizedWikiDto = { + wid: wikiDto.wid, + coveredText: safeCoveredText, + hash: safeHash + }; + // If the current open page is the clicked wiki annotation, don't reload it. - if (window.wikiHandler.currentPage !== undefined && window.wikiHandler.currentPage.hash === wikiDto.hash) return; - $('.wiki-page-modal .page-content .loading-div').fadeIn(100); + if (window.wikiHandler.currentPage !== undefined && window.wikiHandler.currentPage.hash === normalizedWikiDto.hash) return; + const $loading = $('.wiki-page-modal .page-content .loading-div'); + $loading.stop(true, true).addClass('is-active'); $.ajax({ - url: "/api/wiki/page?wid=" + wikiDto.wid + "&covered=" + encodeURIComponent(wikiDto.coveredText), + url: "/api/wiki/page?wid=" + encodeURIComponent(normalizedWikiDto.wid) + "&covered=" + encodeURIComponent(normalizedWikiDto.coveredText), type: "GET", success: (response) => { + if (this.isErrorTemplateResponse(response)) { + this.markWikiTargetAsBroken(normalizedWikiDto); + showMessageModal("Unavailable link", "This wiki target is currently unavailable and has been disabled."); + return; + } + if (typeof dismissAllPopovers === 'function') { + dismissAllPopovers({ dispose: true, removeOrphans: true }); + } $('.wiki-page-modal .page-content .include').html(response); activatePopovers(); @@ -216,34 +609,29 @@ let WikiHandler = (function () { if (this.currentPage) { this.addPageToHistory(this.currentPage); } - this.currentPage = wikiDto; + this.currentPage = normalizedWikiDto; } else { // Update current page without adding to history - this.currentPage = wikiDto; + this.currentPage = normalizedWikiDto; } + this.syncCurrentPageLinks(); }, error: (xhr, status, error) => { + this.markWikiTargetAsBroken(normalizedWikiDto); console.error(xhr.responseText); showMessageModal("Unknown Error", "There was an unknown error loading your page.") } }).always(() => { - $('.wiki-page-modal .page-content .loading-div').fadeOut(100); + $loading.stop(true, true).removeClass('is-active'); }); } WikiHandler.prototype.handleAnnotationClicked = function ($wikiEl) { - const wid = $wikiEl.data('wid'); - let coveredText = String($wikiEl.data('wcovered')); - if (coveredText === undefined || coveredText === '') { - coveredText = $wikiEl.html(); - } + ensureSingleWikiOverlays(); + const wikiDto = this.buildWikiDtoFromElement($wikiEl); + if (!wikiDto) return; // Show the modal $('.wiki-page-modal').removeClass('wiki-page-modal-minimized'); - const wikiDto = { - wid: wid, - coveredText: coveredText, - hash: wid + coveredText - } this.loadPage(wikiDto); } @@ -310,41 +698,115 @@ function getNewWikiHandler() { return new WikiHandler(); } -$(document).ready(function () { - window.wikiHandler = getNewWikiHandler(); - $('.wiki-page-modal .page-content .loading-div').fadeOut(); - console.log('Created Wiki Handler'); -}); - -/** - * Triggers whenever someone clicks onto an annotation that has a wiki page. - */ -$('body').on('click', '.open-wiki-page', function () { - window.wikiHandler.handleAnnotationClicked($(this)); -}); +function ensureSingleWikiOverlays() { + const $modals = $('.wiki-page-modal'); + if ($modals.length > 1) { + $modals.slice(0, -1).remove(); + } + const $expanded = $('.wiki-metadata-expanded-view'); + if ($expanded.length > 1) { + $expanded.slice(0, -1).remove(); + } +} -/** - * Triggers whenever someone wants to go a wiki page back. - */ -$('body').on('click', '.wiki-page-modal .go-back-btn', function () { - window.wikiHandler.handleGoBackBtnClicked(); -}); +function closeExpandedTextView() { + ensureSingleWikiOverlays(); + const $overlay = $('.wiki-metadata-expanded-view'); + $overlay.removeClass('is-active').attr('aria-hidden', 'true'); +} -/** - * Triggers when the user presses on a clickable rdf node - */ -$('body').on('click', '.clickable-rdf-node', function () { - window.wikiHandler.handleRdfNodeClicked($(this)); +$(document).ready(function () { + ensureSingleWikiOverlays(); + if (!window.wikiHandler) { + window.wikiHandler = getNewWikiHandler(); + } + $('.wiki-page-modal .page-content .loading-div').removeClass('is-active'); + closeExpandedTextView(); + window.wikiHandler.syncCurrentPageLinks(); + bindWikiDomHandlers(); + console.log('Created Wiki Handler'); }); -/** - * Triggers when the user wants to expand a long metadata string - */ -$('body').on('click', '.expand-metadata-string-btn', function () { - const expandedContent = $(this).closest('.item-container').find('md-block').html(); - const title = $(this).closest('.item-container').find('label,.key').html(); - openInExpandedTextView(title, expandedContent); -}); +function bindWikiDomHandlers() { + if (window.__wikiDomHandlersBound) return; + window.__wikiDomHandlersBound = true; + + /** + * Triggers whenever someone clicks onto an annotation that has a wiki page. + */ + $('body').on('click.wiki', '.open-wiki-page', function (event) { + event.preventDefault(); + event.stopPropagation(); + const isModalOpen = $('.wiki-page-modal').length > 0 && !$('.wiki-page-modal').hasClass('wiki-page-modal-minimized'); + if ($(this).hasClass('ui-action-disabled') || $(this).attr('aria-disabled') === 'true') { + const reason = $(this).attr('data-disabled-reason'); + if (reason) showMessageModal("Unavailable action", reason); + return; + } + if ($(this).hasClass('wiki-link-current') && isModalOpen) return; + if ($(this).hasClass('wiki-link-broken')) { + showMessageModal("Unavailable link", "This wiki target is currently unavailable."); + return; + } + if ($(this).closest('.wiki-metadata-expanded-view').length > 0) { + closeExpandedTextView(); + } + window.wikiHandler.handleAnnotationClicked($(this)); + }); + + /** + * Keep wiki trigger links usable after closing/minimizing modal. + */ + $('body').on('click.wiki', '.wiki-page-modal .backdrop, .wiki-page-modal .close-wiki-modal-btn, .wiki-page-modal .minimized-content', function () { + closeExpandedTextView(); + setTimeout(function () { + if (window.wikiHandler && typeof window.wikiHandler.syncCurrentPageLinks === 'function') { + window.wikiHandler.syncCurrentPageLinks(); + } + }, 0); + }); + + /** + * Triggers whenever someone wants to go a wiki page back. + */ + $('body').on('click.wiki', '.wiki-page-modal .go-back-btn', function () { + window.wikiHandler.handleGoBackBtnClicked(); + }); + + /** + * Triggers whenever someone wants to navigate to the wiki home page. + */ + $('body').on('click.wiki', '.wiki-page-modal .wiki-home-btn', function () { + window.wikiHandler.handleHomeBtnClicked(); + }); + + /** + * Triggers when the user presses on a clickable rdf node + */ + $('body').on('click.wiki', '.clickable-rdf-node', function () { + window.wikiHandler.handleRdfNodeClicked($(this)); + }); + + /** + * Triggers when the user wants to expand a long metadata string + */ + $('body').on('click.wiki', '.expand-metadata-string-btn', function () { + const expandedContent = $(this).closest('.item-container').find('md-block').html(); + const title = $(this).closest('.item-container').find('label,.key').html(); + openInExpandedTextView(title, expandedContent); + }); + + $('body').on('click.wiki', '.wiki-metadata-expanded-view .close-expanded-view-btn', function (event) { + event.preventDefault(); + closeExpandedTextView(); + }); + + $('body').on('click.wiki', '.wiki-metadata-expanded-view', function (event) { + if (event.target === this) { + closeExpandedTextView(); + } + }); +} /** * Opens something in a large text window, give title, content and a highlight array @@ -354,6 +816,7 @@ function openInExpandedTextView(title, highlightedWords = [], wikiId = undefined, wikiCoveredText = undefined) { + ensureSingleWikiOverlays(); if (highlightedWords && highlightedWords.length > 0) { highlightedWords.forEach(function (word) { if (word !== '') content = content.replaceAll(word, "" + word + ""); @@ -370,9 +833,11 @@ function openInExpandedTextView(title, } else { $wikiButton.hide(); } - $('.wiki-metadata-expanded-view').fadeIn(25); + $('.wiki-metadata-expanded-view').addClass('is-active').attr('aria-hidden', 'false'); } +bindWikiDomHandlers(); + /** * retrieve and display the list of words for a selected topic */ @@ -400,4 +865,4 @@ function showWords() { } else { wordsContainer.style.display = "none"; } -} \ No newline at end of file +} diff --git a/uce.portal/resources/templates/landing-page.ftl b/uce.portal/resources/templates/landing-page.ftl index 98e75ac8..1ecd16fc 100644 --- a/uce.portal/resources/templates/landing-page.ftl +++ b/uce.portal/resources/templates/landing-page.ftl @@ -15,8 +15,9 @@
-

${languageResource.get("corpora")}

+

+ + ${(uceConfig.getMeta().getCorporaTitle())!languageResource.get("corpora")}

<#if corpora?size == 0>
diff --git a/uce.portal/resources/templates/links/linkableAnnotation.ftl b/uce.portal/resources/templates/links/linkableAnnotation.ftl index ebf18050..872d80a2 100644 --- a/uce.portal/resources/templates/links/linkableAnnotation.ftl +++ b/uce.portal/resources/templates/links/linkableAnnotation.ftl @@ -1,10 +1,12 @@
${anno.getCoveredText()}
- - - + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + + + + diff --git a/uce.portal/resources/templates/reader/components/middlePaneHeader.ftl b/uce.portal/resources/templates/reader/components/middlePaneHeader.ftl new file mode 100644 index 00000000..0f61cbc9 --- /dev/null +++ b/uce.portal/resources/templates/reader/components/middlePaneHeader.ftl @@ -0,0 +1,34 @@ +<#macro render title="" published="" language="" wikiId="" wikiCovered="" externalUrl="" subtitle=""> +
+ + +
+
${title}
+ <#if published?has_content> +

${published}

+ + <#if subtitle?has_content> +

${subtitle}

+ +
+ + <#if language?has_content> +

${language?upper_case}

+ +
+ diff --git a/uce.portal/resources/templates/reader/components/pagesList.ftl b/uce.portal/resources/templates/reader/components/pagesList.ftl index e0934821..8186ac57 100644 --- a/uce.portal/resources/templates/reader/components/pagesList.ftl +++ b/uce.portal/resources/templates/reader/components/pagesList.ftl @@ -8,28 +8,30 @@ -
- - #${page.getPageKeywordDistribution().getYakeTopicOne()} - - - #${page.getPageKeywordDistribution().getYakeTopicTwo()} - - - #${page.getPageKeywordDistribution().getYakeTopicThree()} - - - #${page.getPageKeywordDistribution().getYakeTopicFour()} - -
+ <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> +
+ + #${page.getPageKeywordDistribution().getYakeTopicOne()} + + + #${page.getPageKeywordDistribution().getYakeTopicTwo()} + + + #${page.getPageKeywordDistribution().getYakeTopicThree()} + + + #${page.getPageKeywordDistribution().getYakeTopicFour()} + +
+
diff --git a/uce.portal/resources/templates/reader/documentReaderView.ftl b/uce.portal/resources/templates/reader/documentReaderView.ftl index d89dab03..bade514a 100644 --- a/uce.portal/resources/templates/reader/documentReaderView.ftl +++ b/uce.portal/resources/templates/reader/documentReaderView.ftl @@ -25,11 +25,17 @@ - <#if activeMode?has_content && activeMode == "document_reader_feedback_view"> - - + + <#assign activeKey = ((activeModeKey!activeMode)!"")?string > + <#assign activeHandler = (activeModeHandler!"")?string > + <#assign activeKeyLower = activeKey?lower_case > + <#assign activeHandlerLower = activeHandler?lower_case > + <#assign isFeedbackMode = (activeHandler == "document_reader_feedback_view") + || (activeKey == "document_reader_feedback_view") + || (activeHandlerLower?contains("feedback")) + || (activeKeyLower?contains("feedback")) > + <#assign hasViewModeNav = ((uceConfig.settings.ui.documentReader.showViewModeNav)!true) && renderModes?has_content > + @@ -51,11 +57,9 @@ - - + - ${document.getDocumentTitle()} - - - + ${document.getDocumentTitle()} + + + <#include "*/messageModal.ftl"> + <#include "*/sessionExpiredModal.ftl"> + <#include "*/auth/userShortProfile.ftl"> -<#if activeMode?has_content && activeMode == "document_reader_feedback_view"> - - - <#-- Ensure shared scripts still load in feedback mode --> - <#----> - - - - -<#else> - -
- - <#if renderModes?has_content> + <#if hasViewModeNav> +
+ - +
+ + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> <#include "*/wiki/components/wikiPageModal.ftl"> + - - - -
+ +
+

- ${languageResource.get("loadingPages")} 0/${document.getPages()?size}

+ ${languageResource.get("loadingPages")} + 0/${document.getPages()?size} +

-
- -
    -
  • ${languageResource.get("more")}
  • - -
  • ${languageResource.get("highlight")}
  • -
+ <#if (uceConfig.settings.ui.documentReader.showCustomContextMenu)!true> +
+
    +
  • ${languageResource.get("more")}
  • +
  • ${languageResource.get("highlight")}
  • +
+
-
- -
-
- - -
- - -
- -
-
-
- - - - <#if document.getMetadataTitleInfo().getScrapedUrl()?has_content> - - -
- -
-
${document.getDocumentTitle()}
-

${document.getMetadataTitleInfo().getPublished()}

-
-

${document.getLanguage()?upper_case}

-
-
- - - <#assign uceMetadata = document.getUceMetadataWithoutJson()> - <#if uceMetadata?has_content && uceMetadata?size gt 0> -
-
- <#include "*/document/documentUceMetadata.ftl"> -
- <#if document.hasJsonUceMetadata()> - - -
-
- - - <#if document.getMimeType() == "application/pdf" || document.getMimeType() == "pdf"> - <#include '*/reader/components/viewerPdf.ftl' /> - <#elseif document.getMimeType()?starts_with("image/")> - <#include '*/reader/components/viewerImage.ftl' /> +
+
+
+ <#if middlePaneTemplate??> + <#attempt> + <#include "*/" + middlePaneTemplate> + <#recover> + <#include "*/reader/modes/defaultMiddlePane.ftl"> + <#else> -
- -
- -
-
-
-
-
-
+ <#include "*/reader/modes/defaultMiddlePane.ftl"> - -
-
- - -
-
-

${languageResource.get("topicSettings")}

-
- - -
-
- -
- - -
- -
- - -
- - -
- -
- - -
- - - - -
- -
- - + <#if hasRightPane?? && hasRightPane && rightPaneTemplate??> + +
-
-
- <#----> - + <#if (uceConfig.settings.ui.documentReader.showVisualizationTab)!true> + + - - - + diff --git a/uce.portal/resources/templates/reader/modes/defaultMiddlePane.ftl b/uce.portal/resources/templates/reader/modes/defaultMiddlePane.ftl new file mode 100644 index 00000000..36f4e619 --- /dev/null +++ b/uce.portal/resources/templates/reader/modes/defaultMiddlePane.ftl @@ -0,0 +1,238 @@ +<#import "*/reader/components/middlePaneHeader.ftl" as middleHeader> + +
+
+
+ + <#if (uceConfig.settings.ui.documentReader.showTopicNavigationButtons)!true> +
+ + +
+ + + <#if (uceConfig.settings.ui.documentReader.showHeader)!true> + <@middleHeader.render + title=document.getDocumentTitle() + published=document.getMetadataTitleInfo().getPublished()!"" + language=document.getLanguage()!"" + wikiId=document.getWikiId()!"" + wikiCovered=document.getDocumentTitle()!"" + externalUrl=document.getMetadataTitleInfo().getScrapedUrl()!"" + /> + + + <#if (uceConfig.settings.ui.documentReader.showUceMetadata)!true> + <#assign uceMetadata = document.getUceMetadataWithoutJson()> + <#if uceMetadata?has_content && uceMetadata?size gt 0> +
+
+ <#include "*/document/documentUceMetadata.ftl"> +
+ <#if document.hasJsonUceMetadata()> +
+ <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + + + + ${languageResource.get("showUceMetadata")}... + + + +
+ +
+
+ + + +
+ +
+
+
+
+
+
+
+
+ + <#if (uceConfig.settings.ui.documentReader.showSidebar)!true> + + + + + + <#if (uceConfig.settings.ui.documentReader.showTopicSettingsPanel)!true> +
+
+

${languageResource.get("topicSettings")}

+
+ + +
+
+ +
+ + +
+ +
+ +
+ + +
+
+ + +
+ + + + +
+ +
+ + +
+
+ + +
diff --git a/uce.portal/resources/templates/search/components/documentCardContent.ftl b/uce.portal/resources/templates/search/components/documentCardContent.ftl index 115c07ea..d181c4e1 100644 --- a/uce.portal/resources/templates/search/components/documentCardContent.ftl +++ b/uce.portal/resources/templates/search/components/documentCardContent.ftl @@ -47,9 +47,11 @@ data-id="${document.getId()?string?replace('.', '')?replace(',', '')}"> - - + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + + + ${document.getMetadataTitleInfo().getAuthor()}
- <#if document.getDocumentKeywordDistribution()?has_content> - - - + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + <#if document.getDocumentKeywordDistribution()?has_content> + + + + - <#if document.getDocumentTopThreeTopics()?has_content> - <#assign documentTopThreeTopics = document.getDocumentTopThreeTopics()!> - <#assign documentTopThreeTopicsWikiId = document.getDocumentTopThreeTopics().getWikiId()!> - <#assign documentTopicOne = document.getDocumentTopThreeTopics().getTopicOne()!> - <#assign documentTopicTwo = document.getDocumentTopThreeTopics().getTopicTwo()!> - <#assign documentTopicThree = document.getDocumentTopThreeTopics().getTopicThree()!> - <#if documentTopThreeTopics?has_content> - <#if documentTopicOne?has_content> - - - <#if documentTopicTwo?has_content> - - - <#if documentTopicThree?has_content> - + <#if (uceConfig.settings.ui.mainPage.showWikiModal)!true> + <#if document.getDocumentTopThreeTopics()?has_content> + <#assign documentTopThreeTopics = document.getDocumentTopThreeTopics()!> + <#assign documentTopThreeTopicsWikiId = document.getDocumentTopThreeTopics().getWikiId()!> + <#assign documentTopicOne = document.getDocumentTopThreeTopics().getTopicOne()!> + <#assign documentTopicTwo = document.getDocumentTopThreeTopics().getTopicTwo()!> + <#assign documentTopicThree = document.getDocumentTopThreeTopics().getTopicThree()!> + <#if documentTopThreeTopics?has_content> + <#if documentTopicOne?has_content> + + + <#if documentTopicTwo?has_content> + + + <#if documentTopicThree?has_content> + + @@ -256,7 +262,7 @@ --> - <#if !isReducedView && document.getUceMetadataWithoutJson()?size gt 0> + <#if !isReducedView && showFeatureValuesInCard && document.getUceMetadataWithoutJson()?size gt 0> <#assign uceMetadata = document.getUceMetadataWithoutJson()>
-
\ No newline at end of file +
diff --git a/uce.portal/uce.common/pom.xml b/uce.portal/uce.common/pom.xml index 474a4352..a103da11 100644 --- a/uce.portal/uce.common/pom.xml +++ b/uce.portal/uce.common/pom.xml @@ -174,6 +174,12 @@ 0.1.4 + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + + + com.zaxxer + HikariCP + 5.0.1 + + diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/CommonConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/CommonConfig.java index 2f27d533..77e0b49f 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/CommonConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/CommonConfig.java @@ -7,15 +7,20 @@ import java.io.BufferedReader; import java.io.InputStream; +import java.io.InputStream; import java.io.InputStreamReader; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Properties; public class CommonConfig { + private static final Path EXTERNAL_COMMON_CONFIG_PATH = Path.of("/app/config/commonEmpty.conf"); + private static final Path LEGACY_COMMON_CONFIG_PATH = Path.of("uce.common/src/main/resources/commonEmpty.conf"); private final Properties properties; private final List geoNamesFeatureCodes; @@ -45,32 +50,30 @@ public CommonConfig() { // throw new RuntimeException("Error loading config.conf: " + e.getMessage()); // } - // Load in the common conf in the java properties() style - try { - InputStream inputStream = null; - - // 1) Prefer an external config file (works in Docker + fat JAR) - Path external = Path.of("/app/config/commonEmpty.conf"); - if (Files.exists(external) && Files.isRegularFile(external) && Files.size(external) > 0) { - inputStream = Files.newInputStream(external); - } else { -// // 2) Fallback to classpath resource commonEmpty.conf (if present and non-empty) -// inputStream = getClass().getClassLoader().getResourceAsStream("commonEmpty.conf"); -// if (inputStream != null) { -// // If commonEmpty.conf exists but is empty, fallback to common.conf -// if (inputStream.available() == 0) { -// inputStream.close(); -// inputStream = null; +// // Load in the common conf in the java properties() style +// try { +// // Load the .conf file from the resources directory +// // commonEmpty.conf is for loading with DUUI cas importer +// Path external = Path.of("/app/config/commonEmpty.conf"); +// var inputStream = getClass().getClassLoader().getResourceAsStream("commonEmpty.conf"); +// if (inputStream != null) { +// // Check inputStream is empty, if so load the default common.conf from the classpath +// if (inputStream.available() == 0) { +// inputStream = getClass().getClassLoader().getResourceAsStream("common.conf"); +// if (inputStream == null) { +// throw new RuntimeException("common.conf not found not found in the classpath"); // } // } +// properties.load(inputStream); +// } else { +// throw new RuntimeException("common.conf not found not found in the classpath"); +// } +// } catch (Exception e) { +// throw new RuntimeException("Error loading config.conf: " + e.getMessage()); +// } - // 3) Final fallback: default common.conf from classpath - inputStream = getClass().getClassLoader().getResourceAsStream("common.conf"); - if (inputStream == null) { - throw new RuntimeException("common.conf not found in the classpath"); - } - } - + try { + InputStream inputStream = resolveCommonConfigInputStream(); try (InputStream is = inputStream) { properties.load(is); } @@ -78,6 +81,8 @@ public CommonConfig() { throw new RuntimeException("Error loading common config: " + e.getMessage(), e); } + applyEnvironmentOverrides(); + // Now load in the GeoNames feature Codes. try { // Load the .conf file from the resources directory @@ -98,6 +103,85 @@ public CommonConfig() { } } + private void applyEnvironmentOverrides() { + // Convention: env var names mirror property keys: + // - dots become underscores, all uppercase + // Example: keycloak.auth_server_url -> KEYCLOAK_AUTH_SERVER_URL + // + // Only overrides existing keys to avoid silently accepting typos. + var env = System.getenv(); + for (var key : properties.stringPropertyNames()) { + var envKey = toEnvVarName(key); + var value = env.get(envKey); + if (value == null || value.isBlank()) { + value = getEnvAliasValue(key, env); + } + if (value != null && !value.isBlank()) { + properties.setProperty(key, value); + } + } + } + + private static String getEnvAliasValue(String propertyKey, Map env) { + // Backward-/cross-compat aliases so users don't need to define the same concept multiple times. + // + // Keycloak: + // - keycloak.realm <- KC_REALM + // - keycloak.client <- KC_CLIENT_ID + // - keycloak.auth_server_url <- UCE_AUTH_PUBLIC_URL, then KC_BASE_URL + // + // PostgreSQL (app runtime): + // - postgresql.hibernate.connection.username <- POSTGRES_USER + // - postgresql.hibernate.connection.password <- POSTGRES_PASSWORD + return switch (propertyKey) { + case "keycloak.realm" -> env.get("KC_REALM"); + case "keycloak.client" -> env.get("KC_CLIENT_ID"); + case "keycloak.auth_server_url" -> { + var v = env.get("UCE_AUTH_PUBLIC_URL"); + if (v == null || v.isBlank()) { + v = env.get("KC_BASE_URL"); + } + yield v; + } + case "postgresql.hibernate.connection.username" -> env.get("POSTGRES_USER"); + case "postgresql.hibernate.connection.password" -> env.get("POSTGRES_PASSWORD"); + default -> null; + }; + } + + private static String toEnvVarName(String propertyKey) { + return propertyKey + .replace('.', '_') + .replace('-', '_') + .toUpperCase(); + } + + private InputStream resolveCommonConfigInputStream() throws Exception { + for (var path : List.of(EXTERNAL_COMMON_CONFIG_PATH, LEGACY_COMMON_CONFIG_PATH)) { + if (isNonEmptyFile(path)) { + return Files.newInputStream(path); + } + } + + var classpathOverride = getClass().getClassLoader().getResourceAsStream("commonEmpty.conf"); + if (classpathOverride != null) { + if (classpathOverride.available() > 0) { + return classpathOverride; + } + classpathOverride.close(); + } + + var defaultConfig = getClass().getClassLoader().getResourceAsStream("common.conf"); + if (defaultConfig == null) { + throw new RuntimeException("common.conf not found in the classpath"); + } + return defaultConfig; + } + + private static boolean isNonEmptyFile(Path path) throws Exception { + return Files.exists(path) && Files.isRegularFile(path) && Files.size(path) > 0; + } + public String getProperty(String key) { return properties.getProperty(key); } @@ -114,6 +198,53 @@ public String getPostgresqlProperty(String prop) { return getProperty("postgresql." + prop); } public int getLocationEnrichmentLimit() {return Integer.parseInt(getPostgresqlProperty("enrichment.location.max"));} + public int getPostgresqlSearchStatementTimeoutMs() { + var value = getPostgresqlProperty("search.statement.timeout.ms"); + if (value == null || value.isBlank()) return 90000; + return Math.max(0, Integer.parseInt(value)); + } + + public int getPostgresqlSearchLockTimeoutMs() { + var value = getPostgresqlProperty("search.lock.timeout.ms"); + if (value == null || value.isBlank()) return 5000; + return Math.max(0, Integer.parseInt(value)); + } + + public int getPostgresqlPoolConnectionTimeoutMs() { + var value = getPostgresqlProperty("pool.connection.timeout.ms"); + if (value == null || value.isBlank()) return 30000; + return Math.max(1000, Integer.parseInt(value)); + } + + public int getPostgresqlPoolMinimumIdle() { + var value = getPostgresqlProperty("pool.minimum.idle"); + if (value == null || value.isBlank()) return 2; + return Math.max(0, Integer.parseInt(value)); + } + + public int getPostgresqlPoolMaximumSize() { + var value = getPostgresqlProperty("pool.maximum.size"); + if (value == null || value.isBlank()) return 10; + return Math.max(1, Integer.parseInt(value)); + } + + public int getPostgresqlPoolIdleTimeoutMs() { + var value = getPostgresqlProperty("pool.idle.timeout.ms"); + if (value == null || value.isBlank()) return 600000; + return Math.max(10000, Integer.parseInt(value)); + } + + public int getPostgresqlPoolMaxLifetimeMs() { + var value = getPostgresqlProperty("pool.max.lifetime.ms"); + if (value == null || value.isBlank()) return 1800000; + return Math.max(30000, Integer.parseInt(value)); + } + + public int getPostgresqlPoolLeakDetectionThresholdMs() { + var value = getPostgresqlProperty("pool.leak.detection.threshold.ms"); + if (value == null || value.isBlank()) return 60000; + return Math.max(0, Integer.parseInt(value)); + } public String getGbifOccurrencesSearchUrl() { return getProperty("gbif.occurrences.search.url"); @@ -129,6 +260,24 @@ public String getSparqlEndpoint() { public int getSparqlMaxEnrichment() { return Integer.parseInt(getProperty("sparql.max.enrichment")); } + public int getSparqlBatchSize() { + var value = getProperty("sparql.batch.size"); + if (value == null || value.isBlank()) return 10; + return Math.max(1, Integer.parseInt(value)); + } + + public int getSparqlConcurrentRequestsMax() { + var value = getProperty("sparql.concurrent.requests.max"); + if (value == null || value.isBlank()) return 8; + return Math.max(1, Integer.parseInt(value)); + } + + public int getQueryCacheMaxEntries() { + var value = getProperty("query.cache.max.entries"); + if (value == null || value.isBlank()) return 5000; + return Math.max(1, Integer.parseInt(value)); + } + public long getSessionJobInterval() { return Long.parseLong(getProperty("session.job.interval")); } @@ -142,7 +291,11 @@ public String getRAGWebserverBaseUrl() { } public String getEmbeddingWebserverBaseUrl() { - return getProperty("embedding.webserver.base.url"); + var embeddingUrl = getProperty("embedding.webserver.base.url"); + if (embeddingUrl != null && !embeddingUrl.isBlank()) { + return embeddingUrl; + } + return getRAGWebserverBaseUrl(); } public Configuration getKeyCloakConfiguration() { @@ -227,4 +380,4 @@ public String getMinioSecret() { public List getGeoNamesFeatureCodesList() { return this.geoNamesFeatureCodes; } -} \ No newline at end of file +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/HibernateConf.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/HibernateConf.java index bbaaca7f..cb97e58d 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/HibernateConf.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/HibernateConf.java @@ -98,8 +98,10 @@ public static SessionFactory buildSessionFactory() { private static HashMap getSettings() { var settings = new HashMap<>(); var config = new CommonConfig(); - settings.put("connection.driver_class", config.getPostgresqlProperty("connection.driver_class")); - settings.put("dialect", config.getPostgresqlProperty("dialect")); + // Hibernate expects the fully-qualified keys here (hibernate.*). If these are wrong, + // Hibernate falls back and the logs show "using driver [null]". + settings.put("hibernate.connection.driver_class", config.getPostgresqlProperty("connection.driver_class")); + settings.put("hibernate.dialect", config.getPostgresqlProperty("dialect")); settings.put("hibernate.connection.url",config.getPostgresqlProperty("hibernate.connection.url")); settings.put("hibernate.connection.username", config.getPostgresqlProperty("hibernate.connection.username")); settings.put("hibernate.connection.password", config.getPostgresqlProperty("hibernate.connection.password")); @@ -107,6 +109,20 @@ private static HashMap getSettings() { settings.put("hibernate.show_sql", config.getPostgresqlProperty("hibernate.show_sql")); settings.put("hibernate.format_sql", config.getPostgresqlProperty("hibernate.format_sql")); settings.put("hibernate.hbm2ddl.auto", config.getPostgresqlProperty("hibernate.hbm2ddl.auto")); + + // Keep pool implementation internal to the PostgreSQL adapter boundary. + settings.put("hibernate.hikari.connectionTimeout", String.valueOf(config.getPostgresqlPoolConnectionTimeoutMs())); + settings.put("hibernate.hikari.minimumIdle", String.valueOf(config.getPostgresqlPoolMinimumIdle())); + settings.put("hibernate.hikari.maximumPoolSize", String.valueOf(config.getPostgresqlPoolMaximumSize())); + settings.put("hibernate.hikari.idleTimeout", String.valueOf(config.getPostgresqlPoolIdleTimeoutMs())); + settings.put("hibernate.hikari.maxLifetime", String.valueOf(config.getPostgresqlPoolMaxLifetimeMs())); + settings.put("hibernate.hikari.autoCommit", "false"); + settings.put("hibernate.hikari.poolName", "UCEHikariPool"); + settings.put("hibernate.hikari.leakDetectionThreshold", String.valueOf(config.getPostgresqlPoolLeakDetectionThresholdMs())); + + // Enable HikariCP as connection provider + settings.put("hibernate.connection.provider_class", "com.zaxxer.hikari.hibernate.HikariConnectionProvider"); + return settings; } } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/UceConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/UceConfig.java index ac201898..f9c1e45c 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/UceConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/UceConfig.java @@ -3,6 +3,8 @@ import com.google.gson.Gson; import lombok.Getter; import lombok.Setter; + +import org.texttechnologylab.uce.common.config.uceConfig.AuthConfig; import org.texttechnologylab.uce.common.config.uceConfig.CorporateConfig; import org.texttechnologylab.uce.common.config.uceConfig.MetaConfig; import org.texttechnologylab.uce.common.config.uceConfig.SettingsConfig; @@ -19,10 +21,61 @@ public boolean authIsEnabled(){ } public static UceConfig fromJson(String uceConfigJson){ + if (uceConfigJson == null || uceConfigJson.isBlank()) { + return null; + } var gson = new Gson(); var config = gson.fromJson(uceConfigJson, UceConfig.class); + if (config != null) { + config.normalize(); + config.applyEnvironmentOverrides(); + } return config; } + public void applyEnvironmentOverrides() { + if (settings == null) { + return; + } + + var port = System.getenv("UCE_PORT"); + if (port != null && !port.isBlank()) { + try { + settings.setPort(Integer.parseInt(port)); + } catch (NumberFormatException ignored) { + // Keep the existing port value if parsing fails. + } + } + + var auth = settings.getAuthentication(); + if (auth != null) { + var authEnabled = System.getenv("UCE_AUTH_ENABLED"); + if (authEnabled != null && !authEnabled.isBlank()) { + auth.setActivated(Boolean.parseBoolean(authEnabled)); + } + + var authPublicUrl = System.getenv("UCE_AUTH_PUBLIC_URL"); + if (authPublicUrl != null && !authPublicUrl.isBlank()) { + auth.setPublicUrl(authPublicUrl); + } + + var authRedirectUrl = System.getenv("UCE_AUTH_REDIRECT_URL"); + if (authRedirectUrl != null && !authRedirectUrl.isBlank()) { + auth.setRedirectUrl(authRedirectUrl); + } + } + } + + public void normalize() { + if (settings == null) { + return; + } + var auth = settings.getAuthentication(); + if (auth != null) { + auth.setPublicUrl(AuthConfig.normalizeUrlBase(auth.getPublicUrl())); + auth.setRedirectUrl(AuthConfig.normalizeUrlBase(auth.getRedirectUrl())); + } + } + public UceConfig(){} } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/corpusConfig/RenderModeConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/corpusConfig/RenderModeConfig.java index 8834a02a..142098bb 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/corpusConfig/RenderModeConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/corpusConfig/RenderModeConfig.java @@ -32,4 +32,15 @@ public class RenderModeConfig { * Optional description so UIs can surface more context about the mode. */ private String description; + + /** + * Optional pointer to a JSON render specification used by generic renderers. + *

+ * Supported prefixes: + *

    + *
  • {@code FILE::/absolute/or/relative/path.json}
  • + *
  • {@code CLASSPATH::render-specs/mySpec.json}
  • + *
+ */ + private String specPath; } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/AuthConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/AuthConfig.java index 74656f62..ece539cf 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/AuthConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/AuthConfig.java @@ -9,4 +9,26 @@ public class AuthConfig { private boolean isActivated; private String publicUrl; private String redirectUrl; + + public void setPublicUrl(String publicUrl) { + this.publicUrl = normalizeUrlBase(publicUrl); + } + + public void setRedirectUrl(String redirectUrl) { + this.redirectUrl = normalizeUrlBase(redirectUrl); + } + + public static String normalizeUrlBase(String url) { + if (url == null) { + return null; + } + var normalized = url.trim(); + if (normalized.isEmpty()) { + return null; + } + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/CorpusInspectorUiConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/CorpusInspectorUiConfig.java new file mode 100644 index 00000000..20cb091a --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/CorpusInspectorUiConfig.java @@ -0,0 +1,15 @@ +package org.texttechnologylab.uce.common.config.uceConfig; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CorpusInspectorUiConfig { + private boolean showHeader = true; + private boolean showMeta = true; + private boolean showAnnotations = true; + private boolean showDocuments = true; + private boolean showSearchHint = true; +} + diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/DocumentReaderUiConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/DocumentReaderUiConfig.java new file mode 100644 index 00000000..4f7c32ad --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/DocumentReaderUiConfig.java @@ -0,0 +1,19 @@ +package org.texttechnologylab.uce.common.config.uceConfig; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class DocumentReaderUiConfig { + private boolean showViewModeNav = true; + private boolean showWikiModal = true; + private boolean showCustomContextMenu = true; + private boolean showTopicNavigationButtons = true; + private boolean showHeader = true; + private boolean showUceMetadata = true; + private boolean showSidebar = true; + private boolean showVisualizationTab = true; + private boolean showTopicSettingsPanel = true; +} + diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MainPageUiConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MainPageUiConfig.java new file mode 100644 index 00000000..94d25e86 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MainPageUiConfig.java @@ -0,0 +1,17 @@ +package org.texttechnologylab.uce.common.config.uceConfig; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class MainPageUiConfig { + private boolean showSystemStatus = true; + private boolean showCorpusSelector = true; + private boolean showNavButtons = true; + private boolean showLanguageSelector = true; + private boolean showAuthButton = true; + private boolean showWikiModal = true; + private boolean showRagbotChat = true; +} + diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MetaConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MetaConfig.java index b42362a9..9583875f 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MetaConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/MetaConfig.java @@ -4,6 +4,7 @@ public class MetaConfig { private String name; private String version; private String description; + private String corporaTitle; public MetaConfig(){} @@ -30,4 +31,12 @@ public String getDescription() { public void setDescription(String description) { this.description = description; } + + public String getCorporaTitle() { + return corporaTitle; + } + + public void setCorporaTitle(String corporaTitle) { + this.corporaTitle = corporaTitle; + } } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/SettingsConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/SettingsConfig.java index 3d2671f7..a527ada8 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/SettingsConfig.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/SettingsConfig.java @@ -12,4 +12,5 @@ public class SettingsConfig { private EmbeddingsConfig embeddings; private AuthConfig authentication; private MCPConfig mcp = new MCPConfig(); + private UiConfig ui = new UiConfig(); } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/UiConfig.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/UiConfig.java new file mode 100644 index 00000000..09f49d0b --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/config/uceConfig/UiConfig.java @@ -0,0 +1,12 @@ +package org.texttechnologylab.uce.common.config.uceConfig; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UiConfig { + private DocumentReaderUiConfig documentReader = new DocumentReaderUiConfig(); + private MainPageUiConfig mainPage = new MainPageUiConfig(); + private CorpusInspectorUiConfig corpusInspector = new CorpusInspectorUiConfig(); +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchQuery.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchQuery.java index 508aabc8..5f1c4614 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchQuery.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchQuery.java @@ -7,6 +7,15 @@ import org.texttechnologylab.uce.common.exceptions.DocumentAccessDeniedException; import org.texttechnologylab.uce.common.models.corpus.GeoNameFeatureClass; import org.texttechnologylab.uce.common.models.dto.map.LocationDto; +import org.texttechnologylab.uce.common.models.search.promode.CommandExpansionPass; +import org.texttechnologylab.uce.common.models.search.promode.EnrichedTokenViewAdapter; +import org.texttechnologylab.uce.common.models.search.promode.ExpandedTermsExtractor; +import org.texttechnologylab.uce.common.models.search.promode.NormalizationPass; +import org.texttechnologylab.uce.common.models.search.promode.ProExpansionResolver; +import org.texttechnologylab.uce.common.models.search.promode.ProModeSyntaxException; +import org.texttechnologylab.uce.common.models.search.promode.ProQueryParser; +import org.texttechnologylab.uce.common.models.search.promode.ProTsQueryCompiler; +import org.texttechnologylab.uce.common.models.search.promode.TaxonEnrichmentPass; import org.texttechnologylab.uce.common.models.viewModels.CorpusViewModel; import org.texttechnologylab.uce.common.services.JenaSparqlService; import org.texttechnologylab.uce.common.services.PostgresqlDataInterface_Impl; @@ -16,7 +25,11 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Set; import java.util.stream.Stream; /** @@ -30,6 +43,8 @@ public class EnrichedSearchQuery { public static final String[] LOCATION_COMMANDS = {"LOC::", "R::"}; public static final String[] TIME_COMMANDS = {"Y::", "M::", "D::", "E::", "T::"}; private static final CommonConfig config = new CommonConfig(); + @Getter + private List expandedTerms; public static String getFullTaxonRankByCode(String code) { return switch (code) { @@ -80,12 +95,18 @@ public EnrichedSearchQuery(String query, public EnrichedSearchQuery parse(boolean proModeEnabled, long corpusId) throws DatabaseOperationException, IOException, DocumentAccessDeniedException { var corpusVm = db.getCorpusById(corpusId).getViewModel(); + this.enrichedQueryIsCutOff = false; + if (proModeEnabled) { + return parseProMode(corpusVm, corpusId); + } + var searchQuery = StringUtils.replaceSpacesInQuotes(this.originalQuery); var tokens = searchQuery.split(" "); var delimiter = proModeEnabled ? "'" : "\""; var or = proModeEnabled ? " | " : " or "; this.enrichedSearchTokens = new ArrayList<>(); + this.expandedTerms = new ArrayList<>(); var enrichedSearchQuery = new StringBuilder(); for (var token : tokens) { @@ -126,6 +147,167 @@ public EnrichedSearchQuery parse(boolean proModeEnabled, long corpusId) throws D return this; } + private EnrichedSearchQuery parseProMode(CorpusViewModel corpusVm, + long corpusId) throws DatabaseOperationException, IOException, DocumentAccessDeniedException { + this.enrichedSearchTokens = new ArrayList<>(); + this.expandedTerms = new ArrayList<>(); + + var ast = new ProQueryParser().parse(this.originalQuery); + ProExpansionResolver resolver = new ProExpansionResolver() { + @Override + public ExpansionResult resolveCommand(String command, String value) throws Exception { + return resolveCommandExpansion(command, value, corpusVm, corpusId); + } + + @Override + public ExpansionResult resolveTaxonTerm(String value) throws Exception { + return resolveTaxonTermExpansion(value); + } + }; + + try { + new CommandExpansionPass().apply(ast, resolver); + if (this.parseTaxonomy && SystemStatus.JenaSparqlStatus.isAlive()) { + new TaxonEnrichmentPass().apply(ast, resolver); + } + } catch (ProModeSyntaxException ex) { + throw ex; + } catch (Exception ex) { + if (ex instanceof DatabaseOperationException dbEx) throw dbEx; + if (ex instanceof IOException ioEx) throw ioEx; + if (ex instanceof DocumentAccessDeniedException daEx) throw daEx; + throw new IOException("Failed to apply pro-mode enrichment passes", ex); + } + new NormalizationPass().apply(ast); + + this.expandedTerms = new ExpandedTermsExtractor().extract(ast); + this.enrichedSearchTokens = new EnrichedTokenViewAdapter().fromAst(ast); + this.enrichedQuery = new ProTsQueryCompiler().compile(ast); + this.enrichedSearchTokens = enrichedSearchTokens.stream() + .filter(t -> t.getValue() != null && !t.getValue().trim().isBlank()) + .toList(); + + return this; + } + + private ProExpansionResolver.ExpansionResult resolveCommandExpansion(String command, + String value, + CorpusViewModel corpusVm, + long corpusId) throws DatabaseOperationException, DocumentAccessDeniedException, IOException { + var token = command + value; + if (Stream.of(LOCATION_COMMANDS).anyMatch(command::equals)) { + if (!shouldHandleLocationCommand(token, corpusVm)) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.LOCATION_COMMAND); + } + var geoNames = fetchLocationNames(command, value, corpusId); + if (geoNames.size() >= config.getLocationEnrichmentLimit()) this.enrichedQueryIsCutOff = true; + return new ProExpansionResolver.ExpansionResult(geoNames, new LinkedHashMap<>(), EnrichedSearchTokenType.LOCATION_COMMAND); + } + if (Stream.of(TIME_COMMANDS).anyMatch(command::equals)) { + if (!shouldHandleTimeCommand(token, corpusVm)) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TIME_COMMAND); + } + var times = fetchTimeNames(command, value, corpusId); + return new ProExpansionResolver.ExpansionResult(times, new LinkedHashMap<>(), EnrichedSearchTokenType.TIME_COMMAND); + } + if (Stream.of(TAX_RANKS).anyMatch(command::equals)) { + if (!shouldHandleTaxonomicCommand(token)) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TAXON_COMMAND); + } + var rank = getFullTaxonRankByCode(command.replace("::", "")); + var speciesIds = jenaSparqlService.getSpeciesIdsOfUpperRank(rank, value, config.getSparqlMaxEnrichment()); + if (speciesIds.size() >= config.getSparqlMaxEnrichment()) this.enrichedQueryIsCutOff = true; + var grouped = jenaSparqlService.getAlternativeNamesGroupedDetailedOfTaxons(speciesIds); + return new ProExpansionResolver.ExpansionResult( + flattenGroupedNames(grouped), + mapGroupedTaxonChildren(grouped), + EnrichedSearchTokenType.TAXON_COMMAND + ); + } + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TOKEN); + } + + private ProExpansionResolver.ExpansionResult resolveTaxonTermExpansion(String value) throws DatabaseOperationException, IOException, DocumentAccessDeniedException { + var cleaned = cleanToken(value); + if (cleaned.isBlank()) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TOKEN); + } + if (!this.parseTaxonomy || !SystemStatus.JenaSparqlStatus.isAlive()) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TOKEN); + } + var taxonIds = db.getIdentifiableTaxonsByValue(cleaned.toLowerCase()); + if (taxonIds == null || taxonIds.isEmpty()) { + return new ProExpansionResolver.ExpansionResult(Collections.emptyList(), new LinkedHashMap<>(), EnrichedSearchTokenType.TOKEN); + } + + var grouped = jenaSparqlService.getAlternativeNamesGroupedDetailedOfTaxons(taxonIds); + return new ProExpansionResolver.ExpansionResult( + flattenGroupedNames(grouped), + mapGroupedTaxonChildren(grouped), + EnrichedSearchTokenType.TAXON + ); + } + + private List fetchLocationNames(String command, + String value, + long corpusId) throws DatabaseOperationException, DocumentAccessDeniedException { + if ("R::".equals(command)) { + LocationDto locationDto; + try { + locationDto = parseLocationRadiusCommand("R::" + value); + } catch (Exception ex) { + throw new ProModeSyntaxException("Invalid R:: command payload. Use lng=;lat=;r=."); + } + return db.getDistinctGeonamesNamesByRadius( + locationDto.getLongitude(), + locationDto.getLatitude(), + locationDto.getRadius(), + corpusId, + config.getLocationEnrichmentLimit() + ); + } + if ("LOC::".equals(command)) { + var split = value.split("\\."); + var featureClass = split[0]; + var featureCode = split.length > 1 ? split[1] : ""; + GeoNameFeatureClass geoNameFeatureClass; + try { + geoNameFeatureClass = GeoNameFeatureClass.valueOf(featureClass); + } catch (Exception ex) { + throw new ProModeSyntaxException("Invalid LOC:: feature class '" + featureClass + "'."); + } + return db.getDistinctGeonamesNamesByFeatureCode( + geoNameFeatureClass, + featureCode, + corpusId, + config.getLocationEnrichmentLimit() + ); + } + return Collections.emptyList(); + } + + private List fetchTimeNames(String command, + String value, + long corpusId) throws DatabaseOperationException, DocumentAccessDeniedException { + var unitCode = command.replace("::", ""); + var unitName = getFullTimeUnitByCode(unitCode).toLowerCase(); + + String condition; + if (!unitName.equals("range")) { + var formattedValue = unitName.equals("year") ? value : "'" + value + "'"; + condition = String.format("t.%s = %s AND t.pageId IS NOT NULL", unitName, formattedValue); + } else if (value.contains("-")) { + var split = value.split("-"); + var from = split[0].trim(); + var to = split[1].trim(); + condition = String.format("t.year >= %s AND t.year <= %s AND t.pageId IS NOT NULL", from, to); + } else { + condition = "1=0"; + } + var matched = db.getDistinctTimesByCondition(condition, corpusId, 200); + return matched == null ? Collections.emptyList() : matched; + } + private boolean isOperator(String token) { return Arrays.asList(QUERY_OPERATORS).contains(token); } @@ -189,6 +371,7 @@ private boolean handleLocationCommand(@NotNull String token, } if(geoNames.size() >= config.getLocationEnrichmentLimit()) this.enrichedQueryIsCutOff = true; + expandedTerms.addAll(geoNames); if (geoNames.isEmpty()) { query.append(delimiter).append(value).append(delimiter).append(" "); @@ -269,7 +452,9 @@ private boolean handleTaxonomicCommand(@NotNull String token, var speciesIds = jenaSparqlService.getSpeciesIdsOfUpperRank( getFullTaxonRankByCode(command.replace("::", "")), value, config.getSparqlMaxEnrichment()); if(speciesIds.size() == config.getSparqlMaxEnrichment()) this.enrichedQueryIsCutOff = true; - var names = jenaSparqlService.getAlternativeNamesOfTaxons(speciesIds); + var grouped = jenaSparqlService.getAlternativeNamesGroupedDetailedOfTaxons(speciesIds); + var names = flattenGroupedNames(grouped); + if (names != null) expandedTerms.addAll(names); if (names == null || names.isEmpty()) { query.append(delimiter).append(value).append(delimiter).append(" "); @@ -277,6 +462,7 @@ private boolean handleTaxonomicCommand(@NotNull String token, appendEnrichedNames(query, names, value, delimiter, or); enrichedToken.setChildren(names.stream() .map(n -> new EnrichedSearchToken(n, EnrichedSearchTokenType.TAXON)).toList()); + enrichedToken.setGroupedChildren(mapGroupedTaxonChildren(grouped)); } return true; } @@ -294,7 +480,9 @@ private boolean handleBasicTaxon(String cleanedToken, } enrichedToken.setType(EnrichedSearchTokenType.TAXON); - var names = jenaSparqlService.getAlternativeNamesOfTaxons(taxonIds); + var grouped = jenaSparqlService.getAlternativeNamesGroupedDetailedOfTaxons(taxonIds); + var names = flattenGroupedNames(grouped); + if (names != null) expandedTerms.addAll(names); if (names == null || names.isEmpty()) { query.append(originalToken).append(" "); return true; @@ -303,10 +491,43 @@ private boolean handleBasicTaxon(String cleanedToken, appendEnrichedNames(query, names, originalToken.replaceAll("__", " "), delimiter, or); enrichedToken.setChildren(names.stream() .map(n -> new EnrichedSearchToken(n, EnrichedSearchTokenType.TAXON)).toList()); + enrichedToken.setGroupedChildren(mapGroupedTaxonChildren(grouped)); return true; } + private List flattenGroupedNames(LinkedHashMap> grouped) { + var flattened = new ArrayList(); + if (grouped == null || grouped.isEmpty()) return flattened; + for (var children : grouped.values()) { + if (children == null || children.isEmpty()) continue; + for (var child : children) { + if (child == null || child.value() == null || child.value().isBlank()) continue; + flattened.add(child.value()); + } + } + return flattened.stream().distinct().toList(); + } + + private LinkedHashMap> mapGroupedTaxonChildren(LinkedHashMap> grouped) { + var mapped = new LinkedHashMap>(); + if (grouped == null || grouped.isEmpty()) return mapped; + for (var entry : grouped.entrySet()) { + if (entry.getValue() == null || entry.getValue().isEmpty()) continue; + mapped.put(entry.getKey(), + entry.getValue().stream() + .map(child -> { + var token = new EnrichedSearchToken(child.value(), EnrichedSearchTokenType.TAXON); + token.setMetadata(child.meta()); + token.setBadgeText(child.badgeText()); + token.setBadgeTone(child.badgeTone()); + return token; + }) + .toList()); + } + return mapped; + } + private void appendEnrichedNames(StringBuilder query, List names, String original, @@ -337,4 +558,41 @@ public EnrichedSearchQuery withGeonames() { this.parseGeonames = true; return this; } + + // Note: PostgreSQL safe functions handle escaping internally + // No Java-side preprocessing needed + + /** + * Chunk a list of terms into smaller batches for PostgreSQL tsquery. + * PostgreSQL tsquery has limits on query size, so we need to chunk large lists. + * + * @param terms The list of terms to chunk + * @param chunkSize Maximum size of each chunk (default 20) + * @return List of chunks + */ + public static List> chunkTerms(List terms, int chunkSize) { + if (terms == null || terms.isEmpty()) { + return Collections.emptyList(); + } + + List> chunks = new ArrayList<>(); + for (int i = 0; i < terms.size(); i += chunkSize) { + int end = Math.min(terms.size(), i + chunkSize); + chunks.add(new ArrayList<>(terms.subList(i, end))); + } + return chunks; + } + + /** + * Chunk a list of terms into smaller batches for PostgreSQL tsquery. + * Uses default chunk size of 20. + * + * @param terms The list of terms to chunk + * @return List of chunks + */ + public static List> chunkTerms(List terms) { + return chunkTerms(terms, 20); + } + + } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchToken.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchToken.java index c5d64eb3..544403ff 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchToken.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/EnrichedSearchToken.java @@ -1,12 +1,24 @@ package org.texttechnologylab.uce.common.models.search; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; public class EnrichedSearchToken { + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); private String value; private EnrichedSearchTokenType type; + private String metadata; + private String badgeText; + private String badgeTone; private List children; + private LinkedHashMap> groupedChildren; + private String groupedChildrenJson; public EnrichedSearchToken(String value, EnrichedSearchTokenType type) { this.value = value; @@ -38,6 +50,30 @@ public void setType(EnrichedSearchTokenType type) { this.type = type; } + public String getMetadata() { + return metadata; + } + + public void setMetadata(String metadata) { + this.metadata = metadata; + } + + public String getBadgeText() { + return badgeText; + } + + public void setBadgeText(String badgeText) { + this.badgeText = badgeText; + } + + public String getBadgeTone() { + return badgeTone; + } + + public void setBadgeTone(String badgeTone) { + this.badgeTone = badgeTone; + } + public List getChildren() { return children; } @@ -45,4 +81,42 @@ public List getChildren() { public void setChildren(List children) { this.children = children; } + + public LinkedHashMap> getGroupedChildren() { + return groupedChildren; + } + + public void setGroupedChildren(LinkedHashMap> groupedChildren) { + this.groupedChildren = groupedChildren; + this.groupedChildrenJson = toGroupedChildrenJson(groupedChildren); + } + + public boolean hasGroupedChildren() { + return groupedChildren != null && !groupedChildren.isEmpty(); + } + + public String getGroupedChildrenJson() { + return groupedChildrenJson == null ? "" : groupedChildrenJson; + } + + private String toGroupedChildrenJson(Map> groupedChildren) { + if (groupedChildren == null || groupedChildren.isEmpty()) return ""; + var flat = new LinkedHashMap>>(); + for (var entry : groupedChildren.entrySet()) { + var values = new ArrayList>(); + if (entry.getValue() != null) { + for (var token : entry.getValue()) { + if (token == null || token.getValue() == null || token.getValue().isBlank()) continue; + var child = new LinkedHashMap(); + child.put("value", token.getValue()); + child.put("meta", token.getMetadata() == null ? "" : token.getMetadata()); + child.put("badgeText", token.getBadgeText() == null ? "" : token.getBadgeText()); + child.put("badgeTone", token.getBadgeTone() == null ? "" : token.getBadgeTone()); + values.add(child); + } + } + flat.put(entry.getKey(), values); + } + return GSON.toJson(flat); + } } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/CommandExpansionPass.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/CommandExpansionPass.java new file mode 100644 index 00000000..acff99c0 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/CommandExpansionPass.java @@ -0,0 +1,31 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class CommandExpansionPass { + + public void apply(ProQueryAst ast, ProExpansionResolver resolver) throws Exception { + visit(ast.root(), resolver); + } + + private void visit(ProQueryExpression expression, ProExpansionResolver resolver) throws Exception { + if (expression instanceof ProBinaryNode b) { + visit(b.left(), resolver); + visit(b.right(), resolver); + return; + } + if (expression instanceof ProUnaryNode u) { + visit(u.operand(), resolver); + return; + } + if (expression instanceof ProGroupNode g) { + visit(g.inner(), resolver); + return; + } + if (expression instanceof ProCommandNode c) { + var res = resolver.resolveCommand(c.command(), c.value()); + if (res == null) return; + c.enrichment().setTokenType(res.tokenType()); + if (res.flatValues() != null) c.enrichment().getExpandedValues().addAll(res.flatValues()); + c.enrichment().setGroupedChildren(res.groupedChildren()); + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichedTokenViewAdapter.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichedTokenViewAdapter.java new file mode 100644 index 00000000..f5c3d8b1 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichedTokenViewAdapter.java @@ -0,0 +1,57 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import org.texttechnologylab.uce.common.models.search.EnrichedSearchToken; +import org.texttechnologylab.uce.common.models.search.EnrichedSearchTokenType; + +import java.util.ArrayList; +import java.util.List; + +public class EnrichedTokenViewAdapter { + + public List fromAst(ProQueryAst ast) { + var out = new ArrayList(); + walk(ast.root(), out); + return out.stream().filter(t -> t.getValue() != null && !t.getValue().trim().isBlank()).toList(); + } + + private void walk(ProQueryExpression expression, List out) { + if (expression instanceof ProBinaryNode b) { + walk(b.left(), out); + String op = switch (b.operator()) { + case AND -> "&"; + case OR -> "|"; + case FOLLOWED_BY -> b.followDistance() <= 1 ? "<->" : "<" + b.followDistance() + ">"; + }; + out.add(new EnrichedSearchToken(op, EnrichedSearchTokenType.OPERATOR)); + walk(b.right(), out); + return; + } + if (expression instanceof ProUnaryNode u) { + out.add(new EnrichedSearchToken("!", EnrichedSearchTokenType.OPERATOR)); + walk(u.operand(), out); + return; + } + if (expression instanceof ProGroupNode g) { + out.add(new EnrichedSearchToken("(", EnrichedSearchTokenType.OPERATOR)); + walk(g.inner(), out); + out.add(new EnrichedSearchToken(")", EnrichedSearchTokenType.OPERATOR)); + return; + } + if (expression instanceof ProQueryOperand o) { + var token = new EnrichedSearchToken(o.enrichment().getOriginal(), o.enrichment().getTokenType()); + var expanded = o.enrichment().getExpandedValues(); + if (expanded != null && !expanded.isEmpty()) { + EnrichedSearchTokenType childType = switch (o.enrichment().getTokenType()) { + case LOCATION_COMMAND -> EnrichedSearchTokenType.LOCATION; + case TIME_COMMAND -> EnrichedSearchTokenType.TIME; + default -> EnrichedSearchTokenType.TAXON; + }; + token.setChildren(expanded.stream().map(v -> new EnrichedSearchToken(v, childType)).toList()); + } + if (o.enrichment().getGroupedChildren() != null && !o.enrichment().getGroupedChildren().isEmpty()) { + token.setGroupedChildren(o.enrichment().getGroupedChildren()); + } + out.add(token); + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichmentBundle.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichmentBundle.java new file mode 100644 index 00000000..7e93bea5 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/EnrichmentBundle.java @@ -0,0 +1,50 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import org.texttechnologylab.uce.common.models.search.EnrichedSearchToken; +import org.texttechnologylab.uce.common.models.search.EnrichedSearchTokenType; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + +public class EnrichmentBundle { + private final String original; + private EnrichedSearchTokenType tokenType; + private final List expandedValues; + private LinkedHashMap> groupedChildren; + + public EnrichmentBundle(String original) { + this.original = original; + this.tokenType = EnrichedSearchTokenType.TOKEN; + this.expandedValues = new ArrayList<>(); + this.groupedChildren = new LinkedHashMap<>(); + } + + public String getOriginal() { + return original; + } + + public EnrichedSearchTokenType getTokenType() { + return tokenType; + } + + public void setTokenType(EnrichedSearchTokenType tokenType) { + this.tokenType = tokenType; + } + + public List getExpandedValues() { + return expandedValues; + } + + public LinkedHashMap> getGroupedChildren() { + return groupedChildren; + } + + public void setGroupedChildren(LinkedHashMap> groupedChildren) { + this.groupedChildren = groupedChildren == null ? new LinkedHashMap<>() : groupedChildren; + } + + public boolean isEnriched() { + return !expandedValues.isEmpty(); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ExpandedTermsExtractor.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ExpandedTermsExtractor.java new file mode 100644 index 00000000..4709d4e7 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ExpandedTermsExtractor.java @@ -0,0 +1,33 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +public class ExpandedTermsExtractor { + + public List extract(ProQueryAst ast) { + var out = new LinkedHashSet(); + collect(ast.root(), out); + return new ArrayList<>(out); + } + + private void collect(ProQueryExpression expression, LinkedHashSet out) { + if (expression instanceof ProBinaryNode b) { + collect(b.left(), out); + collect(b.right(), out); + return; + } + if (expression instanceof ProUnaryNode u) { + collect(u.operand(), out); + return; + } + if (expression instanceof ProGroupNode g) { + collect(g.inner(), out); + return; + } + if (expression instanceof ProQueryOperand o) { + out.addAll(o.enrichment().getExpandedValues()); + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/NormalizationPass.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/NormalizationPass.java new file mode 100644 index 00000000..4d1a2f9b --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/NormalizationPass.java @@ -0,0 +1,38 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.ArrayList; +import java.util.LinkedHashSet; + +public class NormalizationPass { + + public void apply(ProQueryAst ast) { + visit(ast.root()); + } + + private void visit(ProQueryExpression expression) { + if (expression instanceof ProBinaryNode b) { + visit(b.left()); + visit(b.right()); + return; + } + if (expression instanceof ProUnaryNode u) { + visit(u.operand()); + return; + } + if (expression instanceof ProGroupNode g) { + visit(g.inner()); + return; + } + if (expression instanceof ProQueryOperand o) { + var distinct = new LinkedHashSet(); + for (var v : o.enrichment().getExpandedValues()) { + if (v == null) continue; + var trimmed = v.trim(); + if (trimmed.isEmpty()) continue; + distinct.add(trimmed); + } + o.enrichment().getExpandedValues().clear(); + o.enrichment().getExpandedValues().addAll(new ArrayList<>(distinct)); + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryNode.java new file mode 100644 index 00000000..239cafb9 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryNode.java @@ -0,0 +1,42 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProBinaryNode implements ProQueryExpression { + private final ProBinaryOperator operator; + private final int followDistance; + private final ProQueryExpression left; + private final ProQueryExpression right; + private final SourceSpan span; + + public ProBinaryNode(ProBinaryOperator operator, + int followDistance, + ProQueryExpression left, + ProQueryExpression right, + SourceSpan span) { + this.operator = operator; + this.followDistance = followDistance; + this.left = left; + this.right = right; + this.span = span; + } + + public ProBinaryOperator operator() { + return operator; + } + + public int followDistance() { + return followDistance; + } + + public ProQueryExpression left() { + return left; + } + + public ProQueryExpression right() { + return right; + } + + @Override + public SourceSpan span() { + return span; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryOperator.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryOperator.java new file mode 100644 index 00000000..3a89fe8f --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProBinaryOperator.java @@ -0,0 +1,7 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public enum ProBinaryOperator { + AND, + OR, + FOLLOWED_BY +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProCommandNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProCommandNode.java new file mode 100644 index 00000000..ef66e18f --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProCommandNode.java @@ -0,0 +1,20 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProCommandNode extends ProQueryOperand { + private final String command; + private final String value; + + public ProCommandNode(String command, String value, SourceSpan span) { + super(span, value); + this.command = command; + this.value = value; + } + + public String command() { + return command; + } + + public String value() { + return value; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProExpansionResolver.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProExpansionResolver.java new file mode 100644 index 00000000..36feca94 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProExpansionResolver.java @@ -0,0 +1,17 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.LinkedHashMap; +import java.util.List; +import org.texttechnologylab.uce.common.models.search.EnrichedSearchToken; + +public interface ProExpansionResolver { + ExpansionResult resolveCommand(String command, String value) throws Exception; + + ExpansionResult resolveTaxonTerm(String value) throws Exception; + + record ExpansionResult( + List flatValues, + LinkedHashMap> groupedChildren, + org.texttechnologylab.uce.common.models.search.EnrichedSearchTokenType tokenType + ) {} +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProGroupNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProGroupNode.java new file mode 100644 index 00000000..807d67b3 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProGroupNode.java @@ -0,0 +1,20 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProGroupNode implements ProQueryExpression { + private final ProQueryExpression inner; + private final SourceSpan span; + + public ProGroupNode(ProQueryExpression inner, SourceSpan span) { + this.inner = inner; + this.span = span; + } + + public ProQueryExpression inner() { + return inner; + } + + @Override + public SourceSpan span() { + return span; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProModeSyntaxException.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProModeSyntaxException.java new file mode 100644 index 00000000..e695e145 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProModeSyntaxException.java @@ -0,0 +1,7 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProModeSyntaxException extends RuntimeException { + public ProModeSyntaxException(String message) { + super(message); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryAst.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryAst.java new file mode 100644 index 00000000..4281c401 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryAst.java @@ -0,0 +1,13 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProQueryAst { + private final ProQueryExpression root; + + public ProQueryAst(ProQueryExpression root) { + this.root = root; + } + + public ProQueryExpression root() { + return root; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryExpression.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryExpression.java new file mode 100644 index 00000000..674d020b --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryExpression.java @@ -0,0 +1,4 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public interface ProQueryExpression extends ProQueryNode { +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryLexer.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryLexer.java new file mode 100644 index 00000000..c6074ec0 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryLexer.java @@ -0,0 +1,105 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.ArrayList; +import java.util.List; + +public class ProQueryLexer { + + public List tokenize(String input) { + var tokens = new ArrayList(); + if (input == null) input = ""; + int i = 0; + while (i < input.length()) { + char c = input.charAt(i); + if (Character.isWhitespace(c)) { + i++; + continue; + } + int start = i; + + if (c == '(') { + tokens.add(ProQueryToken.of(ProQueryTokenType.LPAREN, "(", start, ++i)); + continue; + } + if (c == ')') { + tokens.add(ProQueryToken.of(ProQueryTokenType.RPAREN, ")", start, ++i)); + continue; + } + if (c == '&') { + tokens.add(ProQueryToken.of(ProQueryTokenType.AND, "&", start, ++i)); + continue; + } + if (c == '|') { + tokens.add(ProQueryToken.of(ProQueryTokenType.OR, "|", start, ++i)); + continue; + } + if (c == '!') { + tokens.add(ProQueryToken.of(ProQueryTokenType.NOT, "!", start, ++i)); + continue; + } + if (startsWith(input, i, "<->")) { + tokens.add(ProQueryToken.followedBy("<->", 1, start, i + 3)); + i += 3; + continue; + } + if (c == '<') { + int end = input.indexOf('>', i + 1); + if (end > i + 1) { + String between = input.substring(i + 1, end).trim(); + if (between.matches("\\d+")) { + int distance = Integer.parseInt(between); + if (distance <= 0) { + throw new ProModeSyntaxException("Invalid distance at position " + i + ": must be > 0"); + } + tokens.add(ProQueryToken.followedBy("<" + between + ">", distance, start, end + 1)); + i = end + 1; + continue; + } + } + throw new ProModeSyntaxException("Invalid followed-by operator at position " + i + ". Use <-> or ."); + } + if (c == '\'' || c == '"') { + char quote = c; + i++; + StringBuilder sb = new StringBuilder(); + boolean closed = false; + while (i < input.length()) { + char qc = input.charAt(i); + if (qc == '\\' && i + 1 < input.length()) { + sb.append(input.charAt(i + 1)); + i += 2; + continue; + } + if (qc == quote) { + closed = true; + i++; + break; + } + sb.append(qc); + i++; + } + if (!closed) { + throw new ProModeSyntaxException("Unclosed quote starting at position " + start); + } + tokens.add(ProQueryToken.of(ProQueryTokenType.QUOTED, sb.toString(), start, i)); + continue; + } + + while (i < input.length()) { + char cc = input.charAt(i); + if (Character.isWhitespace(cc) || cc == '(' || cc == ')' || cc == '&' || cc == '|' || cc == '!') break; + if (cc == '<') break; + i++; + } + String text = input.substring(start, i); + if (!text.isBlank()) tokens.add(ProQueryToken.of(ProQueryTokenType.TERM, text, start, i)); + } + + tokens.add(ProQueryToken.of(ProQueryTokenType.EOF, "", input.length(), input.length())); + return tokens; + } + + private boolean startsWith(String s, int idx, String prefix) { + return s.regionMatches(idx, prefix, 0, prefix.length()); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryNode.java new file mode 100644 index 00000000..168c05fd --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryNode.java @@ -0,0 +1,5 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public interface ProQueryNode { + SourceSpan span(); +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryOperand.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryOperand.java new file mode 100644 index 00000000..ffacb51e --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryOperand.java @@ -0,0 +1,20 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public abstract class ProQueryOperand implements ProQueryExpression { + private final SourceSpan span; + private final EnrichmentBundle enrichment; + + protected ProQueryOperand(SourceSpan span, String originalValue) { + this.span = span; + this.enrichment = new EnrichmentBundle(originalValue); + } + + @Override + public SourceSpan span() { + return span; + } + + public EnrichmentBundle enrichment() { + return enrichment; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParser.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParser.java new file mode 100644 index 00000000..1d88473b --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParser.java @@ -0,0 +1,163 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.List; +import java.util.Set; + +public class ProQueryParser { + + private static final Set COMMAND_PREFIXES = Set.of( + "K::", "P::", "C::", "O::", "F::", "G::", "S::", + "LOC::", "R::", + "Y::", "M::", "D::", "E::", "T::" + ); + + private List tokens; + private int pos; + + public ProQueryAst parse(String query) { + this.tokens = new ProQueryLexer().tokenize(query); + this.pos = 0; + ProQueryExpression expr = parseOr(); + ProQueryToken current = current(); + if (current.type() != ProQueryTokenType.EOF) { + throw syntax("Unexpected token '" + current.text() + "'", current); + } + return new ProQueryAst(expr); + } + + private ProQueryExpression parseOr() { + ProQueryExpression left = parseAnd(); + while (match(ProQueryTokenType.OR)) { + ProQueryToken op = previous(); + ProQueryExpression right = parseAnd(); + left = new ProBinaryNode(ProBinaryOperator.OR, 0, left, right, SourceSpan.of(left.span().startInclusive(), right.span().endExclusive())); + } + return left; + } + + private ProQueryExpression parseAnd() { + ProQueryExpression left = parseFollowedBy(); + while (match(ProQueryTokenType.AND)) { + ProQueryToken op = previous(); + ProQueryExpression right = parseFollowedBy(); + left = new ProBinaryNode(ProBinaryOperator.AND, 0, left, right, SourceSpan.of(left.span().startInclusive(), right.span().endExclusive())); + } + return left; + } + + private ProQueryExpression parseFollowedBy() { + ProQueryExpression left = parseUnary(); + while (match(ProQueryTokenType.FOLLOWED_BY)) { + ProQueryToken op = previous(); + ProQueryExpression right = parseUnary(); + left = new ProBinaryNode(ProBinaryOperator.FOLLOWED_BY, op.followDistance(), left, right, + SourceSpan.of(left.span().startInclusive(), right.span().endExclusive())); + } + return left; + } + + private ProQueryExpression parseUnary() { + if (match(ProQueryTokenType.NOT)) { + ProQueryToken op = previous(); + ProQueryExpression operand = parseUnary(); + return new ProUnaryNode(operand, SourceSpan.of(op.span().startInclusive(), operand.span().endExclusive())); + } + return parsePrimary(); + } + + private ProQueryExpression parsePrimary() { + if (match(ProQueryTokenType.LPAREN)) { + ProQueryToken left = previous(); + ProQueryExpression inner = parseOr(); + ProQueryToken right = consume(ProQueryTokenType.RPAREN, "Expected ')' to close group"); + return new ProGroupNode(inner, SourceSpan.of(left.span().startInclusive(), right.span().endExclusive())); + } + + if (match(ProQueryTokenType.QUOTED)) { + ProQueryToken token = previous(); + return new ProTermNode(token.text(), true, false, token.span()); + } + + if (match(ProQueryTokenType.TERM)) { + ProQueryToken token = previous(); + String text = token.text(); + String commandPrefix = commandPrefix(text); + if (commandPrefix != null) { + String command = commandPrefix; + String value = text.length() > command.length() ? text.substring(command.length()) : ""; + if (value.isBlank()) { + throw syntax("Command '" + command + "' requires a value", token); + } + validateCommandValue(command, value, token); + return new ProCommandNode(command, value, token.span()); + } + + boolean prefixSearch = text.endsWith(":*"); + if (prefixSearch && text.length() <= 2) { + throw syntax("Prefix search ':*' requires a term", token); + } + String normalized = prefixSearch ? text.substring(0, text.length() - 2) : text; + if (normalized.isBlank()) { + throw syntax("Empty term is not allowed", token); + } + return new ProTermNode(normalized, false, prefixSearch, token.span()); + } + + throw syntax("Expected a term, phrase, command or group", current()); + } + + private boolean looksLikeCommand(String text) { + return commandPrefix(text) != null; + } + + private String commandPrefix(String text) { + if (text == null || text.isBlank()) return null; + return COMMAND_PREFIXES.stream() + .filter(text::startsWith) + .sorted((a, b) -> Integer.compare(b.length(), a.length())) + .findFirst() + .orElse(null); + } + + private void validateCommandValue(String command, String value, ProQueryToken token) { + if ("R::".equals(command)) { + if (!(value.contains("lng=") && value.contains("lat=") && value.contains("r="))) { + throw syntax("R:: command must use format lng=;lat=;r=", token); + } + } + if ("T::".equals(command)) { + if (!value.matches("\\d{1,4}\\s*-\\s*\\d{1,4}")) { + throw syntax("T:: command must use format -", token); + } + } + } + + private ProModeSyntaxException syntax(String message, ProQueryToken token) { + return new ProModeSyntaxException(message + " at position " + token.span().startInclusive()); + } + + private boolean match(ProQueryTokenType type) { + if (check(type)) { + pos++; + return true; + } + return false; + } + + private ProQueryToken consume(ProQueryTokenType type, String message) { + if (check(type)) return tokens.get(pos++); + throw syntax(message, current()); + } + + private boolean check(ProQueryTokenType type) { + return current().type() == type; + } + + private ProQueryToken current() { + return tokens.get(Math.min(pos, tokens.size() - 1)); + } + + private ProQueryToken previous() { + return tokens.get(Math.max(0, pos - 1)); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryToken.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryToken.java new file mode 100644 index 00000000..d6e38cee --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryToken.java @@ -0,0 +1,11 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public record ProQueryToken(ProQueryTokenType type, String text, int followDistance, SourceSpan span) { + public static ProQueryToken of(ProQueryTokenType type, String text, int startInclusive, int endExclusive) { + return new ProQueryToken(type, text, 0, SourceSpan.of(startInclusive, endExclusive)); + } + + public static ProQueryToken followedBy(String text, int distance, int startInclusive, int endExclusive) { + return new ProQueryToken(ProQueryTokenType.FOLLOWED_BY, text, distance, SourceSpan.of(startInclusive, endExclusive)); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryTokenType.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryTokenType.java new file mode 100644 index 00000000..e18ca197 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryTokenType.java @@ -0,0 +1,13 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public enum ProQueryTokenType { + TERM, + QUOTED, + AND, + OR, + NOT, + LPAREN, + RPAREN, + FOLLOWED_BY, + EOF +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTermNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTermNode.java new file mode 100644 index 00000000..eb480308 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTermNode.java @@ -0,0 +1,26 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProTermNode extends ProQueryOperand { + private final String value; + private final boolean quoted; + private final boolean prefixSearch; + + public ProTermNode(String value, boolean quoted, boolean prefixSearch, SourceSpan span) { + super(span, value); + this.value = value; + this.quoted = quoted; + this.prefixSearch = prefixSearch; + } + + public String value() { + return value; + } + + public boolean quoted() { + return quoted; + } + + public boolean prefixSearch() { + return prefixSearch; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTsQueryCompiler.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTsQueryCompiler.java new file mode 100644 index 00000000..69639a7d --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProTsQueryCompiler.java @@ -0,0 +1,65 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import java.util.List; + +public class ProTsQueryCompiler { + + public String compile(ProQueryAst ast) { + return compileExpression(ast.root(), true).trim(); + } + + private String compileExpression(ProQueryExpression expression, boolean topLevel) { + if (expression instanceof ProBinaryNode b) { + String op = switch (b.operator()) { + case AND -> " & "; + case OR -> " | "; + case FOLLOWED_BY -> (b.followDistance() <= 1 ? " <-> " : " <" + b.followDistance() + "> "); + }; + String left = compileExpression(b.left(), false); + String right = compileExpression(b.right(), false); + String result = left + op + right; + return topLevel ? result : "(" + result + ")"; + } + if (expression instanceof ProUnaryNode u) { + return "!" + compileExpression(u.operand(), false); + } + if (expression instanceof ProGroupNode g) { + return "(" + compileExpression(g.inner(), true) + ")"; + } + if (expression instanceof ProTermNode t) { + return compileOperand(t.enrichment().getOriginal(), t.prefixSearch(), t.enrichment().getExpandedValues()); + } + if (expression instanceof ProCommandNode c) { + return compileOperand(c.value(), false, c.enrichment().getExpandedValues()); + } + + throw new ProModeSyntaxException("Unsupported expression node: " + expression.getClass().getSimpleName()); + } + + private String compileOperand(String original, boolean prefixSearch, List expansions) { + String normalizedOriginal = sanitizeTerm(original, prefixSearch); + if (expansions == null || expansions.isEmpty()) return normalizedOriginal; + + StringBuilder sb = new StringBuilder("("); + sb.append(normalizedOriginal); + for (var n : expansions) { + sb.append(" | ").append(quoteTerm(n)); + } + sb.append(")"); + return sb.toString(); + } + + private String sanitizeTerm(String original, boolean prefixSearch) { + if (original == null) return "''"; + String raw = original.trim(); + if (raw.isEmpty()) return "''"; + if (prefixSearch) { + return quoteTerm(raw) + ":*"; + } + return quoteTerm(raw); + } + + private String quoteTerm(String value) { + return "'" + value.replace("'", "") + "'"; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProUnaryNode.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProUnaryNode.java new file mode 100644 index 00000000..3437ec51 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/ProUnaryNode.java @@ -0,0 +1,20 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class ProUnaryNode implements ProQueryExpression { + private final ProQueryExpression operand; + private final SourceSpan span; + + public ProUnaryNode(ProQueryExpression operand, SourceSpan span) { + this.operand = operand; + this.span = span; + } + + public ProQueryExpression operand() { + return operand; + } + + @Override + public SourceSpan span() { + return span; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/SourceSpan.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/SourceSpan.java new file mode 100644 index 00000000..fc521bb7 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/SourceSpan.java @@ -0,0 +1,7 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public record SourceSpan(int startInclusive, int endExclusive) { + public static SourceSpan of(int startInclusive, int endExclusive) { + return new SourceSpan(Math.max(0, startInclusive), Math.max(startInclusive, endExclusive)); + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/TaxonEnrichmentPass.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/TaxonEnrichmentPass.java new file mode 100644 index 00000000..d8dff40a --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/models/search/promode/TaxonEnrichmentPass.java @@ -0,0 +1,32 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +public class TaxonEnrichmentPass { + + public void apply(ProQueryAst ast, ProExpansionResolver resolver) throws Exception { + visit(ast.root(), resolver); + } + + private void visit(ProQueryExpression expression, ProExpansionResolver resolver) throws Exception { + if (expression instanceof ProBinaryNode b) { + visit(b.left(), resolver); + visit(b.right(), resolver); + return; + } + if (expression instanceof ProUnaryNode u) { + visit(u.operand(), resolver); + return; + } + if (expression instanceof ProGroupNode g) { + visit(g.inner(), resolver); + return; + } + if (expression instanceof ProTermNode t) { + if (t.prefixSearch()) return; + var res = resolver.resolveTaxonTerm(t.value()); + if (res == null) return; + t.enrichment().setTokenType(res.tokenType()); + if (res.flatValues() != null) t.enrichment().getExpandedValues().addAll(res.flatValues()); + t.enrichment().setGroupedChildren(res.groupedChildren()); + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/DataInterface.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/DataInterface.java index 3906f934..f6c29b44 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/DataInterface.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/DataInterface.java @@ -208,7 +208,8 @@ public DocumentSearchResult defaultSearchForDocuments(int skip, List uceMetadataFilters, boolean useTsVectorSearch, String schema, - String sourceTable + String sourceTable, + List expandedTerms ) throws DatabaseOperationException, DocumentAccessDeniedException; /** diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/JenaSparqlService.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/JenaSparqlService.java index 01265ebe..0ce7ecc0 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/JenaSparqlService.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/JenaSparqlService.java @@ -1,6 +1,8 @@ package org.texttechnologylab.uce.common.services; +import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; import org.jsoup.HttpStatusException; import org.texttechnologylab.uce.common.config.CommonConfig; import org.texttechnologylab.uce.common.models.biofid.BiofidTaxon; @@ -9,6 +11,7 @@ import org.texttechnologylab.uce.common.models.dto.rdf.RDFRequestDto; import org.texttechnologylab.uce.common.models.dto.rdf.RDFSelectQueryDto; import org.texttechnologylab.uce.common.models.util.HealthStatus; +import org.texttechnologylab.uce.common.utils.QueryResultCache; import org.texttechnologylab.uce.common.utils.RDFNodeDtoJsonDeserializer; import org.texttechnologylab.uce.common.utils.StringUtils; import org.texttechnologylab.uce.common.utils.SystemStatus; @@ -16,14 +19,22 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.io.UncheckedIOException; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; /** * UPDATE 12-2024: I completely replaced the org.apache.jena.rdfconnection imports and libraries as they were @@ -34,6 +45,24 @@ public class JenaSparqlService { private final CommonConfig config = new CommonConfig(); + private final QueryResultCache queryCache = QueryResultCache.global(config); + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + private final ExecutorService sparqlExecutor = Executors.newVirtualThreadPerTaskExecutor(); + private final SparqlChunkParallelizer chunkParallelizer = + new SparqlChunkParallelizer(sparqlExecutor, config.getSparqlConcurrentRequestsMax()); + private final int taxonBatchSize = config.getSparqlBatchSize(); + private static final int TAXON_TRAVERSAL_MAX_DEPTH = 4; + private static final int TAXON_TRAVERSAL_MAX_NODES = 2000; + private static final String DWC = "http://rs.tdwg.org/dwc/terms/"; + private static final String P_VERNACULAR = DWC + "vernacularName"; + private static final String P_CLEANED = DWC + "cleanedScientificName"; + private static final String P_SCIENTIFIC = DWC + "scientificName"; + private static final String P_SCIENTIFIC_AUTHORSHIP = DWC + "scientificNameAuthorship"; + private static final String P_ACCEPTED = DWC + "acceptedNameUsageID"; + private static final String P_PARENT = DWC + "parentNameUsageID"; + private static final String P_STATUS = DWC + "taxonomicStatus"; + private static final String P_TAXON_ID = DWC + "taxonID"; + private static final String P_TAXON_RANK = DWC + "taxonRank"; /** * Initializes the service like setting the default connection url. Service has to be initialized before it can be used. @@ -106,58 +135,6 @@ public List getSpeciesIdsOfUpperRank(String rank, String name, int limit return getSpeciesOfRank(rank, rankIds, limit); } - /** - * Given a list of accepted taxon URIs, return a list of URIs of synonyms that refer to them. - */ - public List getPossibleSynonymIdsOfTaxon(List biofidUrls) throws IOException { - var synonymIds = new HashSet(); - - for (var url : biofidUrls) { - var query = """ - PREFIX dwc: - SELECT ?subject - WHERE { - ?subject dwc:acceptedNameUsageID <%s> . - ?subject dwc:taxonomicStatus ?status . - FILTER(lcase(str(?status)) = "synonym") - } - """.formatted(url); - - var result = executeCommand(query, RDFSelectQueryDto.class); - if (result != null && result.getResults() != null && result.getResults().getBindings() != null) { - for (var binding : result.getResults().getBindings()) { - synonymIds.add(binding.getSubject().getValue()); - } - } - } - return new ArrayList<>(synonymIds); - } - - public List getSubordinateTaxonIds(List biofidUrls) throws IOException { - var subordinateIds = new HashSet(); - - for (var url : biofidUrls) { - var query = """ - PREFIX dwc: - SELECT ?subject ?object - WHERE { - ?subject dwc:parentNameUsageID <%s> . - ?subject dwc:taxonRank ?object . - FILTER(lcase(str(?object)) IN ("subspecies", "varietas", "variety", "forma", "form")) - } - """.formatted(url); - - var result = executeCommand(query, RDFSelectQueryDto.class); - if (result != null && result.getResults() != null && result.getResults().getBindings() != null) { - for (var binding : result.getResults().getBindings()) { - subordinateIds.add(binding.getSubject().getValue()); - } - } - } - - return new ArrayList<>(subordinateIds); - } - /** * Given a taxonid, it searches the sparql database for alternative names, synonyms, subspecies and more and returns a list of names. * E.g. BioFID id: https://www.biofid.de/bio-ontologies/gbif/4299368 @@ -181,40 +158,460 @@ public List getSubordinateTaxonIds(List biofidUrls) throws IOExc * @return */ public List getAlternativeNamesOfTaxons(List biofidIds) throws IOException { + var grouped = getAlternativeNamesGroupedOfTaxons(biofidIds); + var flattened = new ArrayList(); + for (var names : grouped.values()) { + flattened.addAll(names); + } + return flattened; + } + + /** + * Returns grouped names for taxon enrichment, e.g. scientific names of seed ids, + * scientific names of synonym ids, scientific names of subordinate taxa and all vernacular names. + */ + public LinkedHashMap> getAlternativeNamesGroupedOfTaxons(List biofidIds) throws IOException { + var detailed = getAlternativeNamesGroupedDetailedOfTaxons(biofidIds); + var grouped = new LinkedHashMap>(); + for (var entry : detailed.entrySet()) { + if (entry.getValue() == null || entry.getValue().isEmpty()) continue; + grouped.put(entry.getKey(), entry.getValue().stream() + .map(GroupedTaxonChild::value) + .filter(v -> v != null && !v.isBlank()) + .toList()); + } + return grouped; + } + + /** + * Returns grouped enrichment entries including light metadata used by UI badges/tooltips. + */ + public LinkedHashMap> getAlternativeNamesGroupedDetailedOfTaxons(List biofidIds) throws IOException { if (!SystemStatus.JenaSparqlStatus.isAlive()) { - return new ArrayList<>(); + return new LinkedHashMap<>(); + } + + var seedIds = new LinkedHashSet<>(biofidIds.stream() + .filter(id -> id != null && !id.isBlank()) + .toList()); + if (seedIds.isEmpty()) return new LinkedHashMap<>(); + + var cacheKey = buildAlternativeNamesCacheKey(seedIds); + try { + var cachedJson = queryCache.getOrLoad(cacheKey, () -> { + try { + var grouped = computeAlternativeNamesGroupedDetailedOfTaxons(seedIds); + return serializeGroupedChildren(grouped); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }); + return deserializeGroupedChildren(String.valueOf(cachedJson)); + } catch (UncheckedIOException ex) { + throw ex.getCause(); } + } - biofidIds = biofidIds.stream().distinct().toList(); - var allIds = new HashSet<>(biofidIds); + private LinkedHashMap> computeAlternativeNamesGroupedDetailedOfTaxons(Set seedIds) throws IOException { + var traversal = traverseTaxonGraph(seedIds); + var factsById = queryTaxonFactsByIds(traversal.visitedIds); + var preferredNameBySubject = new LinkedHashMap(); + for (var entry : factsById.entrySet()) { + var preferred = entry.getValue().getPrimaryScientificName(); + if (preferred != null && !preferred.isBlank()) { + preferredNameBySubject.put(entry.getKey(), preferred); + } + } - // Step 1: Add synonyms - allIds.addAll(getPossibleSynonymIdsOfTaxon(biofidIds)); + var grouped = new LinkedHashMap>(); + grouped.put("SCIENTIFIC", new LinkedHashMap<>()); + grouped.put("SYNONYM", new LinkedHashMap<>()); + grouped.put("SUBORDINATE", new LinkedHashMap<>()); + grouped.put("VERNACULAR", new LinkedHashMap<>()); + + for (var entry : factsById.entrySet()) { + var subject = entry.getKey(); + var facts = entry.getValue(); + var ownerName = preferredNameBySubject.getOrDefault(subject, facts.getPrimaryScientificName()); + if (ownerName == null || ownerName.isBlank()) ownerName = "Unknown species"; + var rank = facts.getPrimaryRank(); + var rankLabel = formatRankLabel(rank); + + for (var vernacular : facts.vernacularNames) { + if (vernacular == null || vernacular.isBlank()) continue; + var child = new GroupedTaxonChild(vernacular, buildTooltipMeta("VERNACULAR", ownerName), "V", "neutral", "vernacular"); + grouped.get("VERNACULAR").putIfAbsent(vernacular, child); + } - // Step 2: Add subordinate taxa (subspecies, varieties, etc.) - allIds.addAll(getSubordinateTaxonIds(biofidIds)); + var scientificNames = facts.getPreferredScientificNames(); + if (scientificNames.isEmpty()) continue; + + var isSynonym = traversal.synonymIds.contains(subject) || facts.hasSynonymStatus(); + var isSubordinate = traversal.subordinateIds.contains(subject); + if (isSubordinate) { + var parentName = facts.resolveParentName(preferredNameBySubject); + var relationValue = parentName == null || parentName.isBlank() ? "Unknown parent" : parentName; + var relationWithRank = buildTooltipMeta(rankLabel, relationValue); + for (var name : scientificNames) { + if (name == null || name.isBlank()) continue; + grouped.get("SUBORDINATE").putIfAbsent(name, + new GroupedTaxonChild(name, relationWithRank, "SUB", "neutral", facts.getPrimaryRank())); + } + } else if (isSynonym) { + var acceptedName = facts.resolveAcceptedName(preferredNameBySubject); + var relationValue = acceptedName == null || acceptedName.isBlank() + ? "Unknown accepted species" + : acceptedName; + for (var name : scientificNames) { + if (name == null || name.isBlank()) continue; + grouped.get("SYNONYM").putIfAbsent(name, + new GroupedTaxonChild(name, buildTooltipMeta("SYNONYM", relationValue), "SYN", "neutral", facts.getPrimaryRank())); + } + } else if (seedIds.contains(subject) || traversal.acceptedIds.contains(subject)) { + var status = facts.getPrimaryStatus(); + var badgeTone = "neutral"; + var badgeText = "i"; + if (status != null && !status.isBlank()) { + if (status.contains("accepted")) { + badgeTone = "accepted"; + badgeText = "A"; + } else if (status.contains("doubtful")) { + badgeTone = "doubtful"; + badgeText = "D"; + } + } + var meta = buildTooltipMeta(rankLabel, formatStatusLabel(status)); + for (var name : scientificNames) { + if (name == null || name.isBlank()) continue; + grouped.get("SCIENTIFIC").putIfAbsent(name, + new GroupedTaxonChild(name, meta, badgeText, badgeTone, facts.getPrimaryRank())); + } + } else { + for (var name : scientificNames) { + if (name == null || name.isBlank()) continue; + grouped.get("SYNONYM").putIfAbsent(name, + new GroupedTaxonChild(name, buildTooltipMeta("SYNONYM", "Unknown accepted species"), "SYN", "neutral", facts.getPrimaryRank())); + } + } + } - // Step 3: Query for names of all taxon URIs - var command = "SELECT ?subject ?predicate ?object " + - "WHERE {" + - " VALUES ?subject { {BIOFID_IDS} }" + - " ?subject ?predicate ?object . " + - " FILTER(?predicate IN (, )) " + - "}"; - command = command.replace("{BIOFID_IDS}", String.join("\n", allIds.stream().map(id -> "<" + id + ">").toList())); + var result = new LinkedHashMap>(); + for (var entry : grouped.entrySet()) { + if (entry.getValue().isEmpty()) continue; + result.put(entry.getKey(), new ArrayList<>(entry.getValue().values())); + } + return result; + } - var result = executeCommand(command, RDFSelectQueryDto.class); - var alternativeNames = new ArrayList(); - if (result == null || result.getResults() == null || result.getResults().getBindings() == null) - return alternativeNames; + private String buildAlternativeNamesCacheKey(Set seedIds) { + var normalized = seedIds.stream() + .filter(id -> id != null && !id.isBlank()) + .map(String::trim) + .distinct() + .sorted() + .collect(Collectors.joining("|")); + return "sparql-alt-names-grouped:v4:" + + config.getSparqlHost() + + config.getSparqlEndpoint() + + ":" + + normalized; + } + + private String serializeGroupedChildren(LinkedHashMap> grouped) { + if (grouped == null || grouped.isEmpty()) return "{}"; + return GSON.toJson(grouped); + } + + private LinkedHashMap> deserializeGroupedChildren(String json) { + if (json == null || json.isBlank()) return new LinkedHashMap<>(); + var type = new TypeToken>>() {}.getType(); + LinkedHashMap> parsed = GSON.fromJson(json, type); + return parsed == null ? new LinkedHashMap<>() : parsed; + } + + private TraversalState traverseTaxonGraph(Set seedIds) throws IOException { + var state = new TraversalState(); + state.seedIds.addAll(seedIds); + + var frontier = new LinkedHashSet<>(seedIds); + int depth = 0; + while (!frontier.isEmpty() && depth <= TAXON_TRAVERSAL_MAX_DEPTH && state.visitedIds.size() < TAXON_TRAVERSAL_MAX_NODES) { + frontier.removeAll(state.visitedIds); + if (frontier.isEmpty()) break; + + state.visitedIds.addAll(frontier); + var facts = queryTaxonFactsByIds(frontier); + var nextFrontier = new LinkedHashSet(); + + for (var nodeFacts : facts.values()) { + if (!nodeFacts.acceptedUsageIds.isEmpty()) { + state.acceptedIds.addAll(nodeFacts.acceptedUsageIds); + nextFrontier.addAll(nodeFacts.acceptedUsageIds); + } + if (nodeFacts.hasSynonymStatus()) { + state.synonymIds.add(nodeFacts.subject); + } + } + + var reverseSynonyms = querySubjectsByObject(P_ACCEPTED, frontier); + state.synonymIds.addAll(reverseSynonyms); + nextFrontier.addAll(reverseSynonyms); + + var children = querySubjectsByObject(P_PARENT, frontier); + state.subordinateIds.addAll(children); + nextFrontier.addAll(children); + + nextFrontier.removeAll(state.visitedIds); + if (state.visitedIds.size() + nextFrontier.size() > TAXON_TRAVERSAL_MAX_NODES) { + var capped = new LinkedHashSet(); + int remaining = TAXON_TRAVERSAL_MAX_NODES - state.visitedIds.size(); + for (var id : nextFrontier) { + if (remaining-- <= 0) break; + capped.add(id); + } + nextFrontier = capped; + } + + frontier = nextFrontier; + depth++; + } + + return state; + } + + private LinkedHashMap queryTaxonFactsByIds(Set ids) throws IOException { + var bySubject = new LinkedHashMap(); + if (ids == null || ids.isEmpty()) return bySubject; + + var predicates = String.join(", ", + "<" + P_VERNACULAR + ">", + "<" + P_CLEANED + ">", + "<" + P_SCIENTIFIC + ">", + "<" + P_SCIENTIFIC_AUTHORSHIP + ">", + "<" + P_ACCEPTED + ">", + "<" + P_PARENT + ">", + "<" + P_STATUS + ">", + "<" + P_TAXON_ID + ">", + "<" + P_TAXON_RANK + ">"); + + var chunks = partition(ids, taxonBatchSize); + var chunkResults = chunkParallelizer.runAll(chunks, chunk -> { + var local = new LinkedHashMap(); + var command = "SELECT ?subject ?predicate ?object " + + "WHERE {" + + " VALUES ?subject { {BIOFID_IDS} }" + + " ?subject ?predicate ?object . " + + " FILTER(?predicate IN ({PREDICATES})) " + + "}"; + command = command + .replace("{BIOFID_IDS}", String.join("\n", chunk.stream().map(id -> "<" + id + ">").toList())) + .replace("{PREDICATES}", predicates); + + var queryResult = executeCommand(command, RDFSelectQueryDto.class); + if (queryResult == null || queryResult.getResults() == null || queryResult.getResults().getBindings() == null) return local; + + for (var binding : queryResult.getResults().getBindings()) { + if (binding.getSubject() == null || binding.getPredicate() == null || binding.getObject() == null) continue; + var subject = binding.getSubject().getValue(); + var predicate = binding.getPredicate().getValue(); + var object = binding.getObject().getValue(); + local.computeIfAbsent(subject, TaxonNodeFacts::new).add(predicate, object); + } + return local; + }); + + for (var partial : chunkResults) { + for (var entry : partial.entrySet()) { + var subject = entry.getKey(); + var facts = entry.getValue(); + var merged = bySubject.computeIfAbsent(subject, TaxonNodeFacts::new); + merged.scientificNames.addAll(facts.scientificNames); + merged.cleanedScientificNames.addAll(facts.cleanedScientificNames); + merged.scientificAuthorships.addAll(facts.scientificAuthorships); + merged.vernacularNames.addAll(facts.vernacularNames); + merged.acceptedUsageIds.addAll(facts.acceptedUsageIds); + merged.parentUsageIds.addAll(facts.parentUsageIds); + merged.statuses.addAll(facts.statuses); + merged.taxonIds.addAll(facts.taxonIds); + merged.taxonRanks.addAll(facts.taxonRanks); + } + } + + for (var id : ids) bySubject.computeIfAbsent(id, TaxonNodeFacts::new); + return bySubject; + } + + private LinkedHashSet querySubjectsByObject(String predicateUri, Set objectIds) throws IOException { + var subjects = new LinkedHashSet(); + if (objectIds == null || objectIds.isEmpty()) return subjects; + + var chunks = partition(objectIds, taxonBatchSize); + var chunkResults = chunkParallelizer.runAll(chunks, chunk -> { + var localSubjects = new LinkedHashSet(); + var command = "SELECT DISTINCT ?subject WHERE {" + + " ?subject <{PREDICATE}> ?object . " + + " VALUES ?object { {BIOFID_IDS} }" + + "}"; + command = command + .replace("{PREDICATE}", predicateUri) + .replace("{BIOFID_IDS}", String.join("\n", chunk.stream().map(id -> "<" + id + ">").toList())); + + var queryResult = executeCommand(command, RDFSelectQueryDto.class); + if (queryResult == null || queryResult.getResults() == null || queryResult.getResults().getBindings() == null) return localSubjects; + + for (var binding : queryResult.getResults().getBindings()) { + if (binding.getSubject() == null) continue; + localSubjects.add(binding.getSubject().getValue()); + } + return localSubjects; + }); + + for (var partial : chunkResults) { + subjects.addAll(partial); + } + + return subjects; + } + + private List> partition(Set ids, int chunkSize) { + var values = new ArrayList<>(ids); + var chunks = new ArrayList>(); + for (int i = 0; i < values.size(); i += chunkSize) { + chunks.add(values.subList(i, Math.min(i + chunkSize, values.size()))); + } + return chunks; + } + + private String buildTooltipMeta(String label, String value) { + return formatMetaLabel(label) + " | " + formatMetaValue(value); + } + + private String formatRankLabel(String rank) { + return formatMetaLabel(rank == null || rank.isBlank() ? "NAME" : rank); + } + + private String formatStatusLabel(String status) { + if (status == null || status.isBlank()) return "Unknown"; + var normalized = status.trim().replace('_', ' ').toLowerCase(Locale.ROOT); + return Arrays.stream(normalized.split("\\s+")) + .filter(part -> part != null && !part.isBlank()) + .map(part -> Character.toUpperCase(part.charAt(0)) + part.substring(1)) + .collect(Collectors.joining(" ")); + } + + private String formatMetaLabel(String label) { + if (label == null || label.isBlank()) return "NAME"; + return label.trim().replace('_', ' ').toUpperCase(Locale.ROOT); + } - for (var t : result.getResults().getBindings()) { - alternativeNames.add(t.getObject().getValue()); + private String formatMetaValue(String value) { + if (value == null || value.isBlank()) return "Unknown"; + return value.trim(); + } + + private static class TraversalState { + private final LinkedHashSet seedIds = new LinkedHashSet<>(); + private final LinkedHashSet visitedIds = new LinkedHashSet<>(); + private final LinkedHashSet acceptedIds = new LinkedHashSet<>(); + private final LinkedHashSet synonymIds = new LinkedHashSet<>(); + private final LinkedHashSet subordinateIds = new LinkedHashSet<>(); + } + + private static class TaxonNodeFacts { + private final String subject; + private final LinkedHashSet scientificNames = new LinkedHashSet<>(); + private final LinkedHashSet cleanedScientificNames = new LinkedHashSet<>(); + private final LinkedHashSet scientificAuthorships = new LinkedHashSet<>(); + private final LinkedHashSet vernacularNames = new LinkedHashSet<>(); + private final LinkedHashSet acceptedUsageIds = new LinkedHashSet<>(); + private final LinkedHashSet parentUsageIds = new LinkedHashSet<>(); + private final LinkedHashSet statuses = new LinkedHashSet<>(); + private final LinkedHashSet taxonIds = new LinkedHashSet<>(); + private final LinkedHashSet taxonRanks = new LinkedHashSet<>(); + + private TaxonNodeFacts(String subject) { + this.subject = subject; + } + + private void add(String predicate, String value) { + if (value == null || value.isBlank()) return; + if (P_VERNACULAR.equals(predicate)) vernacularNames.add(value); + else if (P_CLEANED.equals(predicate)) cleanedScientificNames.add(value); + else if (P_SCIENTIFIC.equals(predicate)) scientificNames.add(value); + else if (P_SCIENTIFIC_AUTHORSHIP.equals(predicate)) scientificAuthorships.add(value); + else if (P_ACCEPTED.equals(predicate)) acceptedUsageIds.add(value); + else if (P_PARENT.equals(predicate)) parentUsageIds.add(value); + else if (P_STATUS.equals(predicate)) statuses.add(value.toLowerCase()); + else if (P_TAXON_ID.equals(predicate)) taxonIds.add(value); + else if (P_TAXON_RANK.equals(predicate)) taxonRanks.add(value.toLowerCase()); + } + + private boolean hasSynonymStatus() { + for (var status : statuses) { + if (status.contains("synonym")) return true; + } + return false; } - return alternativeNames; + private LinkedHashSet getPreferredScientificNames() { + var names = new LinkedHashSet(); + if (!scientificNames.isEmpty()) { + names.addAll(scientificNames); + return names; + } + + if (!cleanedScientificNames.isEmpty() && !scientificAuthorships.isEmpty()) { + for (var cleaned : cleanedScientificNames) { + for (var authorship : scientificAuthorships) { + if (authorship == null || authorship.isBlank()) continue; + names.add(cleaned + " " + authorship); + } + } + if (!names.isEmpty()) return names; + } + + names.addAll(cleanedScientificNames); + return names; + } + + private String getPrimaryScientificName() { + var names = getPreferredScientificNames(); + return names.isEmpty() ? null : names.iterator().next(); + } + + private String getPrimaryStatus() { + return statuses.isEmpty() ? null : statuses.iterator().next(); + } + + private String getPrimaryRank() { + return taxonRanks.isEmpty() ? null : taxonRanks.iterator().next(); + } + + private String resolveAcceptedName(Map preferredNameBySubject) { + for (var acceptedId : acceptedUsageIds) { + var name = preferredNameBySubject.get(acceptedId); + if (name != null && !name.isBlank()) return name; + } + return null; + } + + private String resolveParentName(Map preferredNameBySubject) { + for (var parentId : parentUsageIds) { + var name = preferredNameBySubject.get(parentId); + if (name != null && !name.isBlank()) return name; + } + return null; + } } + public record GroupedTaxonChild( + String value, + String meta, + String badgeText, + String badgeTone, + String rank + ) {} + /** * Returns from e.g.: https://www.biofid.de/bio-ontologies/gbif/10428508 the taxon id that belongs to it. * We have that stored in our sparql database. Returns -1 if nothing was found. diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/PostgresqlDataInterface_Impl.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/PostgresqlDataInterface_Impl.java index 4d65aa34..ecc9b979 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/PostgresqlDataInterface_Impl.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/PostgresqlDataInterface_Impl.java @@ -16,6 +16,7 @@ import org.springframework.context.annotation.Lazy; import org.texttechnologylab.models.authentication.DocumentPermission; import org.texttechnologylab.uce.common.annotations.Searchable; +import org.texttechnologylab.uce.common.config.CommonConfig; import org.texttechnologylab.uce.common.config.HibernateConf; import org.texttechnologylab.uce.common.exceptions.DatabaseOperationException; import org.texttechnologylab.uce.common.exceptions.DocumentAccessDeniedException; @@ -145,9 +146,11 @@ public Map hasDocumentAccess(String principal, String sql = """ SELECT pd.id FROM permitted_documents(:principal, :minLevel) AS pd - WHERE pd.id = ANY(:documentIds) + WHERE pd.id IN (:documentIds) """; - var query = session.createNativeQuery(sql, Long.class); + var query = session.createNativeQuery(sql) + .unwrap(org.hibernate.query.NativeQuery.class) + .addScalar("id", LongType.INSTANCE); query.setParameter("principal", principal); query.setParameter("minLevel", minLevel.ordinal()); query.setParameter("documentIds", documentIds); @@ -360,10 +363,56 @@ public ArrayList getGeonameClustersFromTimelineMap(double minLng, @Override public List getIdentifiableTaxonsByValue(String token) throws DatabaseOperationException, DocumentAccessDeniedException { return executeOperationSafely((session) -> { - String sql = "SELECT DISTINCT biofidurl FROM biofidtaxon WHERE primaryname ILIKE :token LIMIT 100"; + String normalized = token == null ? "" : token.trim(); + if (normalized.isEmpty()) { + return List.of(); + } + String lowered = normalized.toLowerCase(Locale.ROOT); + boolean allowContains = lowered.length() >= 7; + boolean allowFuzzy = lowered.length() >= 8 && !lowered.contains(" "); + int resultLimit = lowered.length() <= 5 ? 30 : 60; + double minSimilarity = 0.60d; + + String sql = """ + WITH ranked AS ( + SELECT DISTINCT + biofidurl, + CASE + WHEN LOWER(primaryname) = :exact THEN 0 + WHEN EXISTS ( + SELECT 1 + FROM regexp_split_to_table(LOWER(primaryname), '[^[:alnum:]]+') AS part + WHERE part = :exact + ) THEN 1 + WHEN :allowContains = TRUE AND LOWER(primaryname) LIKE :contains THEN 3 + WHEN :allowFuzzy = TRUE AND similarity(LOWER(primaryname), :exact) >= :minSimilarity THEN 4 + ELSE 99 + END AS rank_bucket, + similarity(LOWER(primaryname), :exact) AS sim_score + FROM biofidtaxon + WHERE LOWER(primaryname) = :exact + OR EXISTS ( + SELECT 1 + FROM regexp_split_to_table(LOWER(primaryname), '[^[:alnum:]]+') AS part + WHERE part = :exact + ) + OR (:allowContains = TRUE AND LOWER(primaryname) LIKE :contains) + OR (:allowFuzzy = TRUE AND similarity(LOWER(primaryname), :exact) >= :minSimilarity) + ) + SELECT biofidurl + FROM ranked + WHERE rank_bucket < 99 + ORDER BY rank_bucket ASC, sim_score DESC + LIMIT :resultLimit + """; - var query = session.createNativeQuery(sql); // No type/class here - query.setParameter("token", "%" + token + "%"); + var query = session.createNativeQuery(sql); + query.setParameter("exact", lowered); + query.setParameter("contains", "%" + lowered + "%"); + query.setParameter("allowContains", allowContains); + query.setParameter("allowFuzzy", allowFuzzy); + query.setParameter("minSimilarity", minSimilarity); + query.setParameter("resultLimit", resultLimit); @SuppressWarnings("unchecked") List result = query.getResultList(); @@ -991,7 +1040,9 @@ public DocumentSearchResult completeNegationSearchForDocuments(int skip, if (filters == null || filters.isEmpty()) { useFilters = false; } else { - var applicableFilters = filters.stream().filter(f -> !(f.getValue().isEmpty() || f.getValue().equals("{ANY}"))).toList(); + var applicableFilters = filters.stream() + .filter(f -> !(f.getValue().isEmpty() || f.getValue().equals("{ANY}"))) + .toList(); if (applicableFilters.isEmpty()) { useFilters = false; } @@ -1238,15 +1289,42 @@ public DocumentSearchResult defaultSearchForDocuments(int skip, List uceMetadataFilters, boolean useTsVectorSearch, String schema, - String sourceTable + String sourceTable, + List expandedTerms ) throws DatabaseOperationException, DocumentAccessDeniedException { return executeOperationSafely((session) -> session.doReturningWork((connection) -> { DocumentSearchResult search = null; - try (var storedProcedure = connection.prepareCall("{call uce_search_layer_" + layer.name().toLowerCase() + - "(?::bigint, ?::text[], ?::text, ?::integer, ?::integer, ?::boolean, ?::text, ?::text, ?::jsonb, ?::boolean, ?::text, ?::text, ?::text, ?::integer)}")) { + List processedExpandedTerms = expandedTerms == null ? null : + expandedTerms.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + // Use baseline fulltext function only (no enhanced/safe variants) + String functionName = "uce_search_layer_" + layer.name().toLowerCase(); + + try (var storedProcedure = connection.prepareCall("{call " + functionName + + "(?::bigint, ?::text[], ?::text, ?::integer, ?::integer, ?::boolean, ?::text, ?::text, ?::jsonb, ?::boolean, ?::text, ?::text, ?::text, ?::integer, ?::text[])}")) { + var config = new CommonConfig(); + int statementTimeoutMs = config.getPostgresqlSearchStatementTimeoutMs(); + int lockTimeoutMs = config.getPostgresqlSearchLockTimeoutMs(); + if (statementTimeoutMs > 0 || lockTimeoutMs > 0) { + try (var timeoutStatement = connection.createStatement()) { + if (statementTimeoutMs > 0) { + // Query execution timeout only - Hikari handles connection pooling + timeoutStatement.execute("SET LOCAL statement_timeout = '" + statementTimeoutMs + "ms'"); + storedProcedure.setQueryTimeout(Math.max(1, (int) Math.ceil(statementTimeoutMs / 1000d))); + } + if (lockTimeoutMs > 0) { + timeoutStatement.execute("SET LOCAL lock_timeout = '" + lockTimeoutMs + "ms'"); + } + } + } + storedProcedure.setInt(1, (int) corpusId); - storedProcedure.setArray(2, connection.createArrayOf("text", searchTokens.stream().map(this::escapeSql).toArray())); + storedProcedure.setArray(2, connection.createArrayOf("text", searchTokens.toArray())); storedProcedure.setString(3, ogSearchQuery); storedProcedure.setInt(4, take); storedProcedure.setInt(5, skip); @@ -1271,6 +1349,13 @@ public DocumentSearchResult defaultSearchForDocuments(int skip, // Document access level storedProcedure.setInt(14, DocumentPermission.DOCUMENT_PERMISSION_LEVEL.READ.ordinal()); + // Expanded search terms (batch processing for enriched search) + if (processedExpandedTerms == null || processedExpandedTerms.isEmpty()) { + storedProcedure.setNull(15, java.sql.Types.ARRAY); + } else { + storedProcedure.setArray(15, connection.createArrayOf("text", processedExpandedTerms.toArray())); + } + var result = storedProcedure.executeQuery(); while (result.next()) { var documentCount = result.getInt("total_count_out"); @@ -1936,19 +2021,10 @@ public void saveOrUpdateCorpusTsnePlot(CorpusTsnePlot corpusTsnePlot, Corpus cor @Override public void saveOrUpdateManyAnnotationLinks(List links) throws DatabaseOperationException, DocumentAccessDeniedException { - final int BATCH_SIZE = 1000; - // Since the links go in the hundred of millions for giant documents, we have to chunk the bulk inserts... executeOperationSafely(session -> { - for (int i = 0; i < links.size(); i++) { - session.saveOrUpdate(links.get(i)); - - // Flush and clear the session every BATCH_SIZE records - if (i % BATCH_SIZE == 0 && i > 0) { - session.flush(); - session.clear(); - } + for (AnnotationLink link : links) { + session.saveOrUpdate(link); } - session.flush(); session.clear(); return null; @@ -1957,32 +2033,32 @@ public void saveOrUpdateManyAnnotationLinks(List links) throws D @Override public void saveOrUpdateManyDocumentToAnnotationLinks(List links) throws DatabaseOperationException, DocumentAccessDeniedException { - executeOperationSafely((session -> { + executeOperationSafely((session) -> { for (var link : links) { session.saveOrUpdate(link); } return null; - })); + }); } @Override public void saveOrUpdateManyAnnotationToDocumentLinks(List links) throws DatabaseOperationException, DocumentAccessDeniedException { - executeOperationSafely((session -> { + executeOperationSafely((session) -> { for (var link : links) { session.saveOrUpdate(link); } return null; - })); + }); } @Override public void saveOrUpdateManyDocumentLinks(List documentLinks) throws DatabaseOperationException, DocumentAccessDeniedException { - executeOperationSafely((session -> { + executeOperationSafely((session) -> { for (var link : documentLinks) { session.saveOrUpdate(link); } return null; - })); + }); } @Override @@ -2651,6 +2727,7 @@ private T executeOperationSafely(SessionOperation operation) throws Datab session = sessionFactory.openSession(); transaction = session.beginTransaction(); T result = operation.apply(session); + result = enrichDocumentPublicationFallback(result, session); transaction.commit(); return result; } catch (DocumentAccessDeniedException dade) { @@ -2698,4 +2775,65 @@ private String escapeSql(String input) { return input.replace("(", "\\(").replace(")", "\\)").replace(":", "\\:").replace("|", "\\|"); } + private T enrichDocumentPublicationFallback(T result, Session session) { + if (result == null || session == null) { + return result; + } + + var corpusCreatedCache = new HashMap(); + enrichObjectDocumentFallback(result, session, corpusCreatedCache); + return result; + } + + private void enrichObjectDocumentFallback(Object value, Session session, HashMap corpusCreatedCache) { + if (value == null) { + return; + } + if (value instanceof Document document) { + enrichDocumentFallback(document, session, corpusCreatedCache); + return; + } + if (value instanceof DocumentChunkEmbeddingSearchResult embeddingResult) { + enrichDocumentFallback(embeddingResult.getDocument(), session, corpusCreatedCache); + return; + } + if (value instanceof Map map) { + for (var entryValue : map.values()) { + enrichObjectDocumentFallback(entryValue, session, corpusCreatedCache); + } + return; + } + if (value instanceof Iterable iterable) { + for (var item : iterable) { + enrichObjectDocumentFallback(item, session, corpusCreatedCache); + } + } + } + + private void enrichDocumentFallback(Document document, Session session, HashMap corpusCreatedCache) { + if (document == null || document.getMetadataTitleInfo() == null) { + return; + } + + var corpusId = document.getCorpusId(); + String fallbackDate; + if (corpusCreatedCache.containsKey(corpusId)) { + fallbackDate = corpusCreatedCache.get(corpusId); + } else { + fallbackDate = null; + try { + var corpus = session.get(Corpus.class, corpusId); + if (corpus != null && corpus.getCreated() != null) { + fallbackDate = corpus.getCreated().toString(); + } + } catch (Exception ignored) { + } + corpusCreatedCache.put(corpusId, fallbackDate); + } + + if (fallbackDate != null && !fallbackDate.isBlank()) { + document.getMetadataTitleInfo().setPublished(fallbackDate); + } + } + } diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/SparqlChunkParallelizer.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/SparqlChunkParallelizer.java new file mode 100644 index 00000000..f761811f --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/services/SparqlChunkParallelizer.java @@ -0,0 +1,69 @@ +package org.texttechnologylab.uce.common.services; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Semaphore; + +public final class SparqlChunkParallelizer { + @FunctionalInterface + public interface ChunkTask { + O apply(I input) throws IOException; + } + + private final ExecutorService executor; + private final int maxInFlight; + + public SparqlChunkParallelizer(ExecutorService executor, int maxInFlight) { + this.executor = executor; + this.maxInFlight = Math.max(1, maxInFlight); + } + + public List runAll(List chunks, ChunkTask task) throws IOException { + if (chunks == null || chunks.isEmpty()) return List.of(); + + var semaphore = new Semaphore(maxInFlight); + var futures = new ArrayList>(chunks.size()); + + for (var chunk : chunks) { + var future = CompletableFuture.supplyAsync(() -> { + try { + semaphore.acquire(); + try { + return task.apply(chunk); + } finally { + semaphore.release(); + } + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new CompletionException(ex); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }, executor); + futures.add(future); + } + + try { + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + var results = new ArrayList(futures.size()); + for (var future : futures) { + results.add(future.join()); + } + return results; + } catch (CompletionException ex) { + if (ex.getCause() instanceof UncheckedIOException uio) { + throw uio.getCause(); + } + if (ex.getCause() instanceof InterruptedException iex) { + Thread.currentThread().interrupt(); + throw new IOException("Parallel SPARQL execution interrupted.", iex); + } + throw ex; + } + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/QueryResultCache.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/QueryResultCache.java new file mode 100644 index 00000000..40175576 --- /dev/null +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/QueryResultCache.java @@ -0,0 +1,77 @@ +package org.texttechnologylab.uce.common.utils; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.texttechnologylab.uce.common.config.CommonConfig; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +public final class QueryResultCache { + private static final Logger logger = LogManager.getLogger(QueryResultCache.class); + private static volatile QueryResultCache globalInstance; + + private final Cache cache; + private final long maxEntries; + private final boolean enabled; + + private QueryResultCache(CommonConfig config) { + long configuredEntries = 5000L; + if (config != null) { + configuredEntries = Math.max(0L, config.getQueryCacheMaxEntries()); + } + this.maxEntries = configuredEntries; + this.enabled = configuredEntries > 0; + + if (!enabled) { + this.cache = null; + logger.info("Global query cache disabled (query.cache.max.entries <= 0)."); + return; + } + + this.cache = Caffeine.newBuilder() + .maximumSize(configuredEntries) + .expireAfterAccess(30, TimeUnit.MINUTES) + .recordStats() + .build(); + logger.info("Initialized global query cache with maximumSize={} entries.", configuredEntries); + } + + public static QueryResultCache global(CommonConfig config) { + if (globalInstance == null) { + synchronized (QueryResultCache.class) { + if (globalInstance == null) { + globalInstance = new QueryResultCache(config); + } + } + } + return globalInstance; + } + + public T getOrLoad(String key, Supplier loader) { + if (!enabled || cache == null) return loader.get(); + @SuppressWarnings("unchecked") + var value = (T) cache.get(key, k -> loader.get()); + return value; + } + + public List getOrLoadStringList(String key, Supplier> loader) { + return getOrLoad(key, () -> List.copyOf(loader.get())); + } + + public void invalidate(String key) { + if (!enabled || cache == null) return; + cache.invalidate(key); + } + + public long getMaximumEntries() { + return maxEntries; + } + + public boolean isEnabled() { + return enabled; + } +} diff --git a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/SystemStatus.java b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/SystemStatus.java index c846762e..b87c80c0 100644 --- a/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/SystemStatus.java +++ b/uce.portal/uce.common/src/main/java/org/texttechnologylab/uce/common/utils/SystemStatus.java @@ -27,6 +27,8 @@ public final class SystemStatus { public static UceConfig UceConfig = null; private static final Logger logger = LogManager.getLogger(SystemStatus.class); + private SystemStatus() {} + public static void initSystemStatus(long cleanupInterval, ApplicationContext serviceContext) { var accessManager = serviceContext.getBean(DocumentAccessManager.class); diff --git a/uce.portal/uce.common/src/main/resources/common-release.conf b/uce.portal/uce.common/src/main/resources/common-release.conf index 39d7f049..1a7ac324 100644 --- a/uce.portal/uce.common/src/main/resources/common-release.conf +++ b/uce.portal/uce.common/src/main/resources/common-release.conf @@ -9,11 +9,15 @@ gbif.occurrences.search.url=https://api.gbif.org/v1/occurrence/search?limit=10&m # RAG Webserver properties rag.webserver.base.url=http://uce-rag-service:5678/ +embedding.webserver.base.url=http://uce-rag-service:5678/ # JenaSparksql properties sparql.host=http://uce-fuseki-sparql:5430/ -sparql.endpoint=biofid-gbif/sparql +sparql.endpoint=biofid-search/sparql sparql.max.enrichment=300 +sparql.batch.size=10 +sparql.concurrent.requests.max=8 +query.cache.max.entries=5000 templates.location=../resources/templates/ # Use a classpath instead for the release version @@ -27,7 +31,7 @@ session.job.interval = 3600 system.job.interval = 10 postgresql.connection.driver_class=org.postgresql.Driver -postgresql.dialect=org.hibernate.dialect.PostgreSQL162Dialect +postgresql.dialect=org.hibernate.dialect.PostgreSQLDialect postgresql.hibernate.connection.url=jdbc:postgresql://uce-postgresql-db:5432/uce postgresql.hibernate.connection.username=postgres postgresql.hibernate.connection.password=1234 @@ -36,6 +40,14 @@ postgresql.hibernate.show_sql=false postgresql.hibernate.format_sql=true postgresql.hibernate.hbm2ddl.auto=update postgresql.enrichment.location.max=200 +postgresql.search.statement.timeout.ms=90000 +postgresql.search.lock.timeout.ms=5000 +postgresql.pool.connection.timeout.ms=30000 +postgresql.pool.minimum.idle=2 +postgresql.pool.maximum.size=10 +postgresql.pool.idle.timeout.ms=600000 +postgresql.pool.max.lifetime.ms=1800000 +postgresql.pool.leak.detection.threshold.ms=60000 # s3 storage minio.endpoint = http://uce-minio-storage:9000/ diff --git a/uce.portal/uce.common/src/main/resources/common.conf b/uce.portal/uce.common/src/main/resources/common.conf index 14449b7e..03c91c05 100644 --- a/uce.portal/uce.common/src/main/resources/common.conf +++ b/uce.portal/uce.common/src/main/resources/common.conf @@ -9,11 +9,15 @@ gbif.occurrences.search.url=https://api.gbif.org/v1/occurrence/search?limit=10&m # RAG Webserver properties rag.webserver.base.url=http://localhost:5678/ +embedding.webserver.base.url=http://localhost:5678/ # JenaSparql properties sparql.host=http://localhost:5430/ sparql.endpoint=biofid-search/sparql sparql.max.enrichment=100 +sparql.batch.size=10 +sparql.concurrent.requests.max=8 +query.cache.max.entries=5000 templates.location=uce.portal/resources/templates/ # We want to use an external path for developing to enable hot reloading @@ -27,9 +31,8 @@ session.job.interval = 3600 system.job.interval = 10 postgresql.connection.driver_class=org.postgresql.Driver -postgresql.dialect=org.hibernate.dialect.PostgreSQLDialect -# postgresql.hibernate.connection.url=jdbc:postgresql://localhost:8002/uce -postgresql.hibernate.connection.url=jdbc:postgresql://isengart.hucompute.org:8217/uce +postgresql.dialect=org.hibernate.dialect.PostgreSQL162Dialect +postgresql.hibernate.connection.url=jdbc:postgresql://localhost:8002/uce postgresql.hibernate.connection.username=postgres postgresql.hibernate.connection.password=1234 postgresql.hibernate.current_session_context_class=thread @@ -38,6 +41,14 @@ postgresql.hibernate.format_sql=true # !!! If you put this on "create" it will wipe the database (other is "update") !!! postgresql.hibernate.hbm2ddl.auto=update postgresql.enrichment.location.max=200 +postgresql.search.statement.timeout.ms=90000 +postgresql.search.lock.timeout.ms=5000 +postgresql.pool.connection.timeout.ms=30000 +postgresql.pool.minimum.idle=2 +postgresql.pool.maximum.size=10 +postgresql.pool.idle.timeout.ms=600000 +postgresql.pool.max.lifetime.ms=1800000 +postgresql.pool.leak.detection.threshold.ms=60000 # s3 storage minio.endpoint = http://localhost:9000 @@ -50,4 +61,3 @@ keycloak.realm=uce keycloak.auth_server_url=http://localhost:8080 keycloak.client=uce-web keycloak.credentials.secret=********** - diff --git a/uce.portal/uce.common/src/main/resources/defaultUceConfig.json b/uce.portal/uce.common/src/main/resources/defaultUceConfig.json index 3ccf0a6a..0dac37e3 100644 --- a/uce.portal/uce.common/src/main/resources/defaultUceConfig.json +++ b/uce.portal/uce.common/src/main/resources/defaultUceConfig.json @@ -151,7 +151,36 @@ "publicUrl": "http://localhost:8080", "redirectUrl": "http://localhost:4567/auth" }, + "ui": { + "documentReader": { + "showViewModeNav": true, + "showWikiModal": true, + "showCustomContextMenu": true, + "showTopicNavigationButtons": true, + "showHeader": true, + "showUceMetadata": true, + "showSidebar": true, + "showVisualizationTab": true, + "showTopicSettingsPanel": true + }, + "mainPage": { + "showSystemStatus": true, + "showCorpusSelector": true, + "showNavButtons": true, + "showLanguageSelector": true, + "showAuthButton": true, + "showWikiModal": true, + "showRagbotChat": true + }, + "corpusInspector": { + "showHeader": true, + "showMeta": true, + "showAnnotations": true, + "showDocuments": true, + "showSearchHint": true + } + }, "embeddings": { } } -} \ No newline at end of file +} diff --git a/uce.portal/uce.common/src/test/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParserTest.java b/uce.portal/uce.common/src/test/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParserTest.java new file mode 100644 index 00000000..017d01d1 --- /dev/null +++ b/uce.portal/uce.common/src/test/java/org/texttechnologylab/uce/common/models/search/promode/ProQueryParserTest.java @@ -0,0 +1,209 @@ +package org.texttechnologylab.uce.common.models.search.promode; + +import junit.framework.TestCase; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class ProQueryParserTest extends TestCase { + + public void testParsesBooleanPrecedence() { + var ast = new ProQueryParser().parse("Carex | 'Carex L.' & !divulsa"); + + assertTrue(ast.root() instanceof ProBinaryNode); + var root = (ProBinaryNode) ast.root(); + assertEquals(ProBinaryOperator.OR, root.operator()); + + assertTrue(root.left() instanceof ProTermNode); + assertEquals("Carex", ((ProTermNode) root.left()).value()); + + assertTrue(root.right() instanceof ProBinaryNode); + var andNode = (ProBinaryNode) root.right(); + assertEquals(ProBinaryOperator.AND, andNode.operator()); + + assertTrue(andNode.left() instanceof ProTermNode); + var quoted = (ProTermNode) andNode.left(); + assertEquals("Carex L.", quoted.value()); + assertTrue(quoted.quoted()); + + assertTrue(andNode.right() instanceof ProUnaryNode); + var notNode = (ProUnaryNode) andNode.right(); + assertTrue(notNode.operand() instanceof ProTermNode); + assertEquals("divulsa", ((ProTermNode) notNode.operand()).value()); + } + + public void testParsesGroupedExpression() { + var ast = new ProQueryParser().parse("alpina & (herbst | sommer)"); + assertTrue(ast.root() instanceof ProBinaryNode); + var root = (ProBinaryNode) ast.root(); + assertEquals(ProBinaryOperator.AND, root.operator()); + assertTrue(root.right() instanceof ProGroupNode); + } + + public void testParsesFollowedByDefaultOperator() { + var ast = new ProQueryParser().parse("april <-> 1902"); + assertTrue(ast.root() instanceof ProBinaryNode); + var root = (ProBinaryNode) ast.root(); + assertEquals(ProBinaryOperator.FOLLOWED_BY, root.operator()); + assertEquals(1, root.followDistance()); + } + + public void testParsesFollowedByDistanceOperator() { + var ast = new ProQueryParser().parse("'Carex muricata' <3> divulsa"); + + assertTrue(ast.root() instanceof ProBinaryNode); + var root = (ProBinaryNode) ast.root(); + assertEquals(ProBinaryOperator.FOLLOWED_BY, root.operator()); + assertEquals(3, root.followDistance()); + + assertTrue(root.left() instanceof ProTermNode); + var left = (ProTermNode) root.left(); + assertEquals("Carex muricata", left.value()); + assertTrue(left.quoted()); + + assertTrue(root.right() instanceof ProTermNode); + assertEquals("divulsa", ((ProTermNode) root.right()).value()); + } + + public void testParsesPrefixOperator() { + var ast = new ProQueryParser().parse("pere:*"); + assertTrue(ast.root() instanceof ProTermNode); + var root = (ProTermNode) ast.root(); + assertEquals("pere", root.value()); + assertTrue(root.prefixSearch()); + } + + public void testParsesAllTaxonCommands() { + assertCommand("K::Animalia", "K::", "Animalia"); + assertCommand("P::Tracheophyta", "P::", "Tracheophyta"); + assertCommand("C::Magnoliopsida", "C::", "Magnoliopsida"); + assertCommand("O::Poales", "O::", "Poales"); + assertCommand("F::Cyperaceae", "F::", "Cyperaceae"); + assertCommand("G::Carex", "G::", "Carex"); + assertCommand("S::muricata", "S::", "muricata"); + } + + public void testParsesGeoCommands() { + assertCommand("LOC::H.CNL", "LOC::", "H.CNL"); + assertCommand("LOC::H", "LOC::", "H"); + assertCommand("R::lng=9;lat=50;r=70000", "R::", "lng=9;lat=50;r=70000"); + } + + public void testParsesTimeCommands() { + assertCommand("Y::1880", "Y::", "1880"); + assertCommand("M::2", "M::", "2"); + assertCommand("D::31", "D::", "31"); + assertCommand("E::Winter", "E::", "Winter"); + assertCommand("T::1880-1900", "T::", "1880-1900"); + } + + public void testParsesEscapedQuotedValue() { + var ast = new ProQueryParser().parse("'O\\'Brien'"); + assertTrue(ast.root() instanceof ProTermNode); + var root = (ProTermNode) ast.root(); + assertEquals("O'Brien", root.value()); + assertTrue(root.quoted()); + } + + public void testQuotedJoinAllowed() { + var ast = new ProQueryParser().parse("'Bellis perennis'"); + assertTrue(ast.root() instanceof ProTermNode); + assertTrue(((ProTermNode) ast.root()).quoted()); + } + + public void testUnquotedJoinRejected() { + expectSyntaxErrorContains("Bellis perennis", "Unexpected token"); + } + + public void testRejectsEmptyPrefix() { + expectSyntaxErrorContains(":*", "Prefix search ':*' requires a term"); + } + + public void testRejectsUnclosedQuote() { + expectSyntaxErrorContains("'Bellis perennis", "Unclosed quote"); + } + + public void testRejectsInvalidFollowedByOperatorText() { + expectSyntaxErrorContains("april 1902", "Invalid followed-by operator"); + } + + public void testRejectsZeroFollowedByDistance() { + expectSyntaxErrorContains("april <0> 1902", "must be > 0"); + } + + public void testRejectsUnbalancedGroup() { + expectSyntaxErrorContains("(alpina & herbst", "Expected ')' to close group"); + } + + public void testRejectsCommandWithoutValue() { + expectSyntaxErrorContains("K::", "requires a value"); + } + + public void testRejectsWhitespaceInsideCommandValue() { + expectSyntaxErrorContains("S::Carex muricata", "Unexpected token"); + } + + public void testRejectsInvalidRadiusCommand() { + expectSyntaxErrorContains("R::lng=8;lat=50", "R:: command must use format"); + } + + public void testRejectsInvalidTimeRangeCommand() { + expectSyntaxErrorContains("T::1880/1900", "T:: command must use format"); + } + + public void testParsesMixedFullSyntaxSample() { + var query = "(K::Animalia & LOC::H.CNL) | (!'Bellis perennis' & pere:* <10> T::1880-1900)"; + var ast = new ProQueryParser().parse(query); + assertTrue(ast.root() instanceof ProBinaryNode); + } + + public void testParsesPromodequeryFixtureFile() throws IOException { + var query = Files.readString(resolvePromodeQueryFixture(), StandardCharsets.UTF_8); + var ast = new ProQueryParser().parse(query); + + assertTrue(ast.root() instanceof ProBinaryNode); + var root = (ProBinaryNode) ast.root(); + assertEquals(ProBinaryOperator.AND, root.operator()); + + assertTrue(root.right() instanceof ProTermNode); + var right = (ProTermNode) root.right(); + assertEquals("Wasserstufenzeigerwert", right.value()); + assertTrue(right.quoted()); + } + + private void assertCommand(String query, String expectedCommand, String expectedValue) { + var ast = new ProQueryParser().parse(query); + assertTrue(ast.root() instanceof ProCommandNode); + var root = (ProCommandNode) ast.root(); + assertEquals(expectedCommand, root.command()); + assertEquals(expectedValue, root.value()); + } + + private void expectSyntaxErrorContains(String query, String fragment) { + try { + new ProQueryParser().parse(query); + fail("Expected ProModeSyntaxException for query: " + query); + } catch (ProModeSyntaxException ex) { + assertTrue("Expected message to contain '" + fragment + "' but got: " + ex.getMessage(), + ex.getMessage().contains(fragment)); + } + } + + private Path resolvePromodeQueryFixture() { + var candidates = List.of( + Path.of(".dev/promodequery.txt"), + Path.of("../.dev/promodequery.txt"), + Path.of("../../.dev/promodequery.txt") + ); + for (var candidate : candidates) { + if (Files.exists(candidate)) { + return candidate; + } + } + fail("Could not resolve .dev/promodequery.txt from current working directory"); + return null; + } +} diff --git a/uce.portal/uce.corpus-importer/Dockerfile b/uce.portal/uce.corpus-importer/Dockerfile index c3bfb97c..585c240a 100644 --- a/uce.portal/uce.corpus-importer/Dockerfile +++ b/uce.portal/uce.corpus-importer/Dockerfile @@ -1,22 +1,35 @@ -# Use an appropriate base image with Java (e.g., OpenJDK) -FROM eclipse-temurin:21-jdk +# syntax=docker/dockerfile:1.7 +FROM maven:3.9.6-eclipse-temurin-21 AS builder -# Set the working directory inside the container WORKDIR /app -RUN apt-get update && apt-get install -y maven -# Copy the entire uce.portal directory -COPY ./uce.portal ./uce.portal +COPY ./uce.portal/pom.xml ./uce.portal/pom.xml +COPY ./uce.portal/settings.xml ./uce.portal/settings.xml +COPY ./uce.portal/uce.common/pom.xml ./uce.portal/uce.common/pom.xml +COPY ./uce.portal/uce.corpus-importer/pom.xml ./uce.portal/uce.corpus-importer/pom.xml +COPY ./uce.portal/uce.web/pom.xml ./uce.portal/uce.web/pom.xml +COPY ./uce.portal/uce.search/pom.xml ./uce.portal/uce.search/pom.xml +COPY ./uce.portal/uce.analysis/pom.xml ./uce.portal/uce.analysis/pom.xml -# Override the default config with release settings (container-to-container hosts) +WORKDIR /app/uce.portal +RUN --mount=type=cache,id=uce-m2-repo,target=/root/.m2/repository,sharing=locked \ + mvn -B -T 1C -nsu -DskipTests -Dmaven.repo.local=/root/.m2/repository \ + dependency:go-offline -pl uce.corpus-importer -am + +WORKDIR /app +COPY ./uce.portal ./uce.portal COPY ./uce.portal/uce.common/src/main/resources/common-release.conf /app/uce.portal/uce.common/src/main/resources/common.conf -# Set the working directory to uce.portal WORKDIR /app/uce.portal -RUN mvn clean install -DskipTests +RUN --mount=type=cache,id=uce-m2-repo,target=/root/.m2/repository,sharing=locked \ + mvn -B -T 1C -nsu -DskipTests -Dmaven.repo.local=/root/.m2/repository \ + package -pl uce.corpus-importer -am + +FROM eclipse-temurin:21-jdk + +WORKDIR /app -# Build uce.web -RUN mvn clean package -DskipTests +COPY --from=builder /app/uce.portal/uce.corpus-importer/target/importer.jar /app/uce.portal/uce.corpus-importer/target/importer.jar # Expose the port that your Spark application runs on # EXPOSE 4567 diff --git a/uce.portal/uce.corpus-importer/logs/uce-corpus-importer.log b/uce.portal/uce.corpus-importer/logs/uce-corpus-importer.log new file mode 100644 index 00000000..e69de29b diff --git a/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/DUUICorpusImporter.java b/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/DUUICorpusImporter.java index 1db5edb2..67356841 100644 --- a/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/DUUICorpusImporter.java +++ b/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/DUUICorpusImporter.java @@ -48,6 +48,8 @@ public class DUUICorpusImporter { private static final Logger logger = LogManager.getLogger(DUUICorpusImporter.class); + private static final Path COMMON_CONFIG_PATH = Path.of("/app/config/commonEmpty.conf"); + private static final Path CORPUS_CONFIG_PATH = Path.of("/app/config/UCECorpusConfigEmpty.json"); public static void main(String[] args) throws Exception { DisableLogging.enableLogging(Level.SEVERE); @@ -75,7 +77,7 @@ static class ProcessHandler implements HttpHandler { @Override public void handle(HttpExchange t) throws IOException { - File tf = null; + boolean responseStarted = false; try { jc.reset(); @@ -90,19 +92,9 @@ public void handle(HttpExchange t) throws IOException { InputStream casBody = new ByteArrayInputStream(bodies[1].getBytes(StandardCharsets.UTF_8)); XmiCasDeserializer.deserialize(casBody, jc.getCas(), true, sharedData); - //dump the common into commonEmpty.conf in the resources folder, in that case the given configuration will be loaded by the Corpus Importer instead of the default common.conf - Path commonConfPath = Path.of("/app/config/commonEmpty.conf"); if(args.contains("-c")) { String commonConf = args.split("-c ")[1].split(" -")[0].replace("\\n", System.lineSeparator()); - Path path = commonConfPath; - try { - Files.createDirectories(commonConfPath.getParent()); - Files.writeString(path, commonConf); - } - catch (IOException e) { -// e.printStackTrace(); - logger.error("Failed to write the given common conf to the commonEmpty.conf file in the resources folder. ", e); - } + writeConfigFile(COMMON_CONFIG_PATH, commonConf); } // raise exception if the given conf is not found, otherwise the default common.conf will be loaded, which is not wanted in that case. else @@ -111,28 +103,18 @@ public void handle(HttpExchange t) throws IOException { } //Dokument conf is needed to load the correct configuration into the Database, instead of the given CorpusConf in the folder of the input files. - - Path corpusPath = Path.of("/app/config/UCECorpusConfigEmpty.json"); if(args.contains("-d")) { String corpusConf = args.split("-d ")[1].split(" -")[0] .replace("\\n", "\n"); - Files.createDirectories(corpusPath.getParent()); - try { - ObjectMapper mapper = new ObjectMapper(); - - // Falls der Input schon escaped ist, zuerst ent-escapen: - corpusConf = corpusConf.replace("\\\"", "\""); - - JsonNode json = mapper.readTree(corpusConf); - String prettyJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json); - - Files.writeString(corpusPath, prettyJson, StandardCharsets.UTF_8); - } catch (IOException e) { - logger.error( - "Failed to write the given corpus config to UCECorpusConfigEmpty.json in the resources folder.", - e - ); - } + ObjectMapper mapper = new ObjectMapper(); + + // Falls der Input schon escaped ist, zuerst ent-escapen: + corpusConf = corpusConf.replace("\\\"", "\""); + + JsonNode json = mapper.readTree(corpusConf); + String prettyJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(json); + + writeConfigFile(CORPUS_CONFIG_PATH, prettyJson); } else { @@ -160,21 +142,6 @@ public void handle(HttpExchange t) throws IOException { importer.start(1); } - //Delete commonEmpty.conf - try { - Files.deleteIfExists(commonConfPath); - } - catch (IOException e) { - logger.error("Failed to empty the commonEmpty.conf file in the resources folder after processing the request. ", e); - } - //Delete corpusConfig.json - try { - Files.deleteIfExists(corpusPath); - } - catch (IOException e) { - logger.error("Failed to delete the UCECorpusConfigEmpty.json file in the resources folder after processing the request. ", e); - } - // IMPORTANT: reply CAS back to DUUI ByteArrayOutputStream out = new ByteArrayOutputStream(); XmiCasSerializer.serialize(jc.getCas(), out); // you said this compiles @@ -182,24 +149,41 @@ public void handle(HttpExchange t) throws IOException { byte[] resp = out.toByteArray(); t.getResponseHeaders().set("Content-Type", "application/xml; charset=utf-8"); t.sendResponseHeaders(200, resp.length); + responseStarted = true; try (OutputStream os = t.getResponseBody()) { os.write(resp); } catch (Exception e) { - logger.error("ProcessHandler failed", e); - t.sendResponseHeaders(500, -1); - t.close(); + logger.error("ProcessHandler failed", e); + t.close(); } } catch (Exception e) { logger.error("An error occurred while processing the request in ProcessHandler. This likely means that the corpus import failed. ", e); - t.sendResponseHeaders(404, -1); - return; + if (!responseStarted) { + t.sendResponseHeaders(404, -1); + } + } finally { + deleteConfigFile(COMMON_CONFIG_PATH, "common config"); + deleteConfigFile(CORPUS_CONFIG_PATH, "corpus config"); } } } + private static void writeConfigFile(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + Files.writeString(path, content, StandardCharsets.UTF_8); + } + + private static void deleteConfigFile(Path path, String label) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.error("Failed to delete temporary {} at {}.", label, path, e); + } + } + static class TypesystemHandler implements HttpHandler { @Override public void handle(HttpExchange t) throws IOException { diff --git a/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/Importer.java b/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/Importer.java index 4341d0f4..c737ccd6 100644 --- a/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/Importer.java +++ b/uce.portal/uce.corpus-importer/src/main/java/org/texttechnologylab/uce/corpusimporter/Importer.java @@ -90,6 +90,8 @@ public class Importer { private static final Gson gson = new Gson(); private static final Logger logger = LogManager.getLogger(Importer.class); private static final int BATCH_SIZE = 2000; + private static final Path EXTERNAL_CORPUS_CONFIG_PATH = Path.of("/app/config/UCECorpusConfigEmpty.json"); + private static final Path LEGACY_CORPUS_CONFIG_PATH = Path.of("uce.corpus-importer/src/main/resources/UCECorpusConfigEmpty.json"); private static final Set WANTED_NE_TYPES = Set.of( "LOCATION", "MISC", "PERSON", "ORGANIZATION" ); @@ -250,15 +252,6 @@ public Long storeUploadedXMIToCorpusAsync(InputStream inputStream, Corpus corpus return doc.getId(); } - private static boolean isNonEmptyFile(Path p) { - try { - return Files.exists(p) && Files.isRegularFile(p) && Files.size(p) > 0; - } catch (IOException e) { - logger.error("Error checking file size for path: " + p); - return false; - } - } - /** * Imports all UIMA xmi files in a folder * @throws DocumentAccessDeniedException @@ -273,56 +266,27 @@ public void storeCorpusFromFolderAsync(String folderName, int numThreads) throws if (!SystemStatus.PostgresqlDbStatus.isAlive()) throw new DatabaseOperationException("Postgresql DB is not alive - cancelling import."); -// String pathdefault; -// if (folderName == null && casOnlyRun) { -// pathdefault = Objects.requireNonNull( -// getClass().getClassLoader().getResource("UCECorpusConfigEmpty.json"), -// "Resource UCECorpusConfigEmpty.json not found" -// ).getPath(); -// } else { -// // Read the corpus config. If this doesn't exist, we cannot import the corpus -// // NOTE the config is not updated if the corpus already exists! -// // TODO compare configs and show a warning if they differ (except name, ...) -// if (folderName == null) { -// // get path of defaultCorpusConfig.json -// pathdefault = Objects.requireNonNull( -// getClass().getClassLoader().getResource("defaultCorpusConfig.json"), -// "Resource defaultCorpusConfig.json not found" -// ).getPath(); -// } else { -// pathdefault = Paths.get(folderName, "corpusConfig.json").toString(); -// } -// } -// Path filePathDefault = Paths.get(pathdefault); - - String pathdefault; - - if (folderName == null && casOnlyRun) { - Path external = Path.of("/app/config/UCECorpusConfigEmpty.json"); - if (isNonEmptyFile(external)) { - pathdefault = external.toString(); - } else { - pathdefault = Objects.requireNonNull( - getClass().getClassLoader().getResource("defaultCorpusConfig.json"), - "Resource defaultCorpusConfig.json not found" - ).getPath(); - } - } else { - if (folderName == null) { - pathdefault = Objects.requireNonNull( - getClass().getClassLoader().getResource("defaultCorpusConfig.json"), - "Resource defaultCorpusConfig.json not found" - ).getPath(); - } else { - pathdefault = Paths.get(folderName, "corpusConfig.json").toString(); + // Read the corpus config. If this doesn't exist, we cannot import the corpus + // NOTE the config is not updated if the corpus already exists! + // TODO compare configs and show a warning if they differ (except name, ...) + try (var reader = new FileReader(Paths.get(folderName, "corpusConfig.json").toString(), StandardCharsets.UTF_8)) { + corpusConfig = gson.fromJson(reader, CorpusConfig.class); + try { + Corpus existingCorpus = CreateDBCorpus(corpus, corpusConfig, db); + if (existingCorpus != null) { + corpus = existingCorpus; + if (corpusConfig.getAnnotations().isUceMetadata()) { + this.uceMetadataFilters = new CopyOnWriteArrayList<>(db.getUCEMetadataFiltersByCorpusId(existingCorpus.getId())); + } + } + } catch (DatabaseOperationException e) { + throw new DatabaseOperationException("Error creating or fetching the corpus from the database - cancelling import.", e); } + } catch (JsonIOException | JsonSyntaxException | IOException e) { + throw new MissingResourceException( + "The corpus folder did not contain a properly formatted corpusConfig.json", CorpusConfig.class.toString(), ""); } - Path filePathDefault = Paths.get(pathdefault); - - var corpusInitializationResult = loadCorpusConfigInitializeCorpus(filePathDefault, corpusConfig, corpus, db); - corpusConfig = corpusInitializationResult.corpusConfig(); - corpus = corpusInitializationResult.corpus(); // Store some corpus information in the UCEImport logging if this is the main importer if (this.importerNumber == 1 && !this.casOnlyRun) { var uceImport = db.getUceImportByImportId(this.importId); @@ -366,7 +330,7 @@ public void storeCorpusFromFolderAsync(String folderName, int numThreads) throws if (casView != null) { this.mainCas = this.mainCas.getView(casView); } -// Path filePath = Paths.get(pathdefault); + Path filePathDefault = Paths.get("DUUI-CAS-Import-" + documentId + ".xmi"); var docFuture = CompletableFuture.supplyAsync(() -> { try { batchLatch.get().await(); // wait if a batch is being postprocessed @@ -1367,26 +1331,47 @@ private void setTaxonomy(Document document, JCas jCas, CorpusConfig corpusConfig } taxon.setVerified(true); - ExceptionUtils.tryCatchLog( - () -> taxon.setRecordId(Long.parseLong(Arrays.stream(taxon.getIdentifier().split("/")).toList().getLast())), - (ex) -> logger.warn("Setting the recordId of a Taxon failed, but continuing the import: ", ex)); taxon.setMatchedName(t.getMatchedName()); - taxon.setIdentifier(t.getIdentifier()); taxon.setMatchedCanonical(t.getMatchedCanonicalFull()); - var biofidUrl = StringUtils.BIOFID_URL_BASE + taxon.getRecordId(); - var newBiofidTaxons = ExceptionUtils.tryCatchLog( - () -> jenaSparqlService.queryBiofidTaxon(biofidUrl), - (ex) -> logger.error("Error building a BiofidTaxon object from a potential id.", ex)); - if (newBiofidTaxons != null) { - for (var biofidTaxon : newBiofidTaxons) { - biofidTaxon.setCoveredText(t.getCoveredText()); - biofidTaxon.setBegin(t.getBegin()); - biofidTaxon.setEnd(t.getEnd()); - biofidTaxon.setDocument(document); - biofidTaxon.setBiofidUrl(biofidUrl); - biofidTaxon.setOriginalAnnotatedTaxonTable(ReflectionUtils.getTableAnnotationName(GnFinderTaxon.class)); - biofidTaxa.add(biofidTaxon); + // Identifier can be a list (space and/or pipe separated), similar to Gazetteer. + // Keep the original identifier string unchanged in GnFinderTaxon. + var splited = new ArrayList(); + for (var split : taxon.getIdentifier().split("\\|")) { + splited.addAll(Arrays.asList(split.split(" "))); + } + final var splitIds = splited.stream().filter(id -> id != null && !id.isBlank()).collect(Collectors.toCollection(ArrayList::new)); + if (splitIds.isEmpty()) { + gnFinderTaxa.add(taxon); + return; + } + + // Primary record id from first identifier for compatibility with existing schema behavior. + final var primaryIdentifier = splitIds.getFirst(); + ExceptionUtils.tryCatchLog( + () -> taxon.setRecordId(Long.parseLong(Arrays.stream(primaryIdentifier.split("/")).toList().getLast())), + (ex) -> logger.warn("Setting the recordId of a Taxon failed, but continuing the import: ", ex)); + + // Build BioFID taxon entries for each potential identifier. + for (var potentialBiofidId : splitIds) { + var biofidId = potentialBiofidId; + if (potentialBiofidId.contains("gbif.org")) + biofidId = StringUtils.gbifToBIOfidUrl(potentialBiofidId); + + final var biofidUrl = biofidId; + var newBiofidTaxons = ExceptionUtils.tryCatchLog( + () -> jenaSparqlService.queryBiofidTaxon(biofidUrl), + (ex) -> logger.error("Error building a BiofidTaxon object from a potential id.", ex)); + if (newBiofidTaxons != null) { + for (var biofidTaxon : newBiofidTaxons) { + biofidTaxon.setCoveredText(t.getCoveredText()); + biofidTaxon.setBegin(t.getBegin()); + biofidTaxon.setEnd(t.getEnd()); + biofidTaxon.setDocument(document); + biofidTaxon.setBiofidUrl(biofidUrl); + biofidTaxon.setOriginalAnnotatedTaxonTable(ReflectionUtils.getTableAnnotationName(GnFinderTaxon.class)); + biofidTaxa.add(biofidTaxon); + } } } gnFinderTaxa.add(taxon); @@ -1923,4 +1908,4 @@ private void logImportError(String message, Exception ex, String file) { logger.error(message, ex); } -} \ No newline at end of file +} diff --git a/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/SearchState.java b/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/SearchState.java index d12a0889..556606bf 100644 --- a/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/SearchState.java +++ b/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/SearchState.java @@ -19,6 +19,7 @@ import org.texttechnologylab.uce.common.models.dto.UCEMetadataFilterDto; import org.texttechnologylab.uce.common.models.search.AnnotationSearchResult; import org.texttechnologylab.uce.common.models.search.CacheItem; +import org.texttechnologylab.uce.common.models.search.DocumentSearchResult; import org.texttechnologylab.uce.common.models.search.DocumentChunkEmbeddingSearchResult; import org.texttechnologylab.uce.common.models.search.EnrichedSearchToken; import org.texttechnologylab.uce.common.models.search.OrderByColumn; @@ -55,6 +56,7 @@ public class SearchState extends CacheItem { private Integer currentPage = 1; private Integer take = 10; private long corpusId; + private List expandedTerms; private CorpusConfig corpusConfig; private Integer totalHits; private SearchOrder order = SearchOrder.DESC; @@ -106,6 +108,7 @@ public String getSessionUser() { public void setSessionUser(String sessionUser) { this.sessionUser = sessionUser; + } public boolean isEnrichedSearchQueryIsCutoff() { return enrichedSearchQueryIsCutoff; @@ -251,6 +254,14 @@ public void setCurrentDocumentHits(List currentDocumentHits) { this.currentDocumentHits = currentDocumentHits; } + public CommonConfig getConfig() { + return config; + } + + public void setConfig(CommonConfig config) { + this.config = config; + } + public CorpusConfig getCorpusConfig() { return corpusConfig; } @@ -446,6 +457,14 @@ public void setSearchTokens(List searchTokens) { this.searchTokens = searchTokens; } + public List getExpandedTerms() { + return expandedTerms; + } + + public void setExpandedTerms(List expandedTerms) { + this.expandedTerms = expandedTerms; + } + public List getSearchLayers() { return searchLayers; } @@ -538,4 +557,5 @@ public String getVisualizationData() { data.put("currentPage", this.getCurrentPage()); return new Gson().toJson(data); } + } diff --git a/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/Search_DefaultImpl.java b/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/Search_DefaultImpl.java index 570a35fa..61d58a33 100644 --- a/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/Search_DefaultImpl.java +++ b/uce.portal/uce.search/src/main/java/org/texttechnologylab/uce/search/Search_DefaultImpl.java @@ -10,10 +10,10 @@ import org.texttechnologylab.uce.common.models.authentication.UceUser; import org.texttechnologylab.uce.common.models.dto.UCEMetadataFilterDto; import org.texttechnologylab.uce.common.models.search.*; +import org.texttechnologylab.uce.common.models.search.promode.ProModeSyntaxException; import org.texttechnologylab.uce.common.services.EmbeddingService; import org.texttechnologylab.uce.common.services.JenaSparqlService; import org.texttechnologylab.uce.common.services.PostgresqlDataInterface_Impl; -import org.texttechnologylab.uce.common.services.RAGService; import org.texttechnologylab.uce.common.utils.Pair; import org.texttechnologylab.uce.common.utils.StringUtils; import org.texttechnologylab.uce.common.utils.SystemStatus; @@ -25,7 +25,10 @@ import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Locale; +import java.util.Map; /** * Class that encapsulates all search layers within the biofid class @@ -67,15 +70,21 @@ public Search_DefaultImpl(ApplicationContext serviceContext, // First: enrich if wanted (and we can; we need the graph database for it) if (enrichSearchTerm && !searchPhrase.isBlank()) { - var enrichedSearchQuery = ExceptionUtils.tryCatchLog( - () -> new EnrichedSearchQuery(searchPhrase, db, jenaSparqlService) - .withAll() - .parse(proModeActivated, corpusId), - (ex) -> logger.error("There was an error enriching the following search phrase: " + searchPhrase, ex)); + EnrichedSearchQuery enrichedSearchQuery = null; + try { + enrichedSearchQuery = new EnrichedSearchQuery(searchPhrase, db, jenaSparqlService) + .withAll() + .parse(proModeActivated, corpusId); + } catch (ProModeSyntaxException syntaxEx) { + throw syntaxEx; + } catch (Exception ex) { + logger.error("There was an error enriching the following search phrase: " + searchPhrase, ex); + } if(enrichedSearchQuery != null){ this.searchState.setEnrichedSearchQuery(enrichedSearchQuery.getEnrichedQuery()); this.searchState.setEnrichedSearchTokens(enrichedSearchQuery.getEnrichedSearchTokens()); this.searchState.setEnrichedSearchQueryIsCutoff(enrichedSearchQuery.isEnrichedQueryIsCutOff()); + this.searchState.setExpandedTerms(enrichedSearchQuery.getExpandedTerms()); } } @@ -124,8 +133,19 @@ public void setSearchState(SearchState searchState) { public SearchState initSearch(UceUser user) throws SQLGrammarException { //var countAll = !this.searchState.getSearchQuery().isEmpty(); DocumentSearchResult documentSearchResult = executeSearchOnDatabases(true); - if (documentSearchResult == null) - throw new NullPointerException("Document Init Search returned null - not empty."); + if (documentSearchResult == null) { + logger.warn("Document init search returned null. Returning empty search state instead of failing hard."); + searchState.setCurrentDocuments(new ArrayList<>()); + searchState.setCurrentDocumentHits(new ArrayList<>()); + searchState.setDocumentIdxToSnippets(new HashMap<>()); + searchState.setDocumentIdxToRank(new HashMap<>()); + searchState.setTotalHits(0); + searchState.setFoundNamedEntities(new ArrayList<>()); + searchState.setFoundTaxons(new ArrayList<>()); + searchState.setFoundTimes(new ArrayList<>()); + searchState.setSessionUser(user != null ? user.getUsername() : DocumentPermission.PUBLIC_USERNAME); + return searchState; + } var documents = ExceptionUtils.tryCatchLog(() -> db.getManyDocumentsByIds(documentSearchResult.getDocumentIds()), (ex) -> logger.error("Error getting many documents by a list of ids in the search init. " + @@ -181,7 +201,10 @@ public SearchState getSearchHitsForPage(int page, UceUser user) { // Adjust the current page and execute the search again this.searchState.setCurrentPage(page); var documentSearchResult = executeSearchOnDatabases(false); - if (documentSearchResult == null) throw new NullPointerException("Document Search returned null - not empty."); + if (documentSearchResult == null) { + logger.warn("Document page search returned null for page {}. Keeping previous state.", page); + return searchState; + } var documents = ExceptionUtils.tryCatchLog(() -> db.getManyDocumentsByIds(documentSearchResult.getDocumentIds()), (ex) -> logger.error("Error getting many documents by a list of ids while getting hits for page " + page + " hence returning the last state.", ex)); @@ -200,11 +223,26 @@ public SearchState getSearchHitsForPage(int page, UceUser user) { * @return */ private DocumentSearchResult executeSearchOnDatabases(boolean countAll) throws SQLGrammarException { + String queryForDb = searchState.getEnrichedSearchQuery() == null + ? searchState.getSearchQuery() + : searchState.getEnrichedSearchQuery(); + // For enrichment-heavy searches, pass the original user query and let expanded_terms + // drive widening in SQL to avoid giant tsquery strings. + if (!searchState.isProModeActivated() + && searchState.getExpandedTerms() != null + && !searchState.getExpandedTerms().isEmpty()) { + queryForDb = searchState.getSearchQuery(); + } + + var expandedTermsForDb = searchState.isProModeActivated() ? null : searchState.getExpandedTerms(); + final String dbQuery = queryForDb; + + // Execute search directly without batch processing if (searchState.getSearchLayers().contains(SearchLayer.FULLTEXT)) { try { return db.defaultSearchForDocuments((searchState.getCurrentPage() - 1) * searchState.getTake(), searchState.getTake(), - searchState.getEnrichedSearchQuery() == null ? searchState.getSearchQuery() : searchState.getEnrichedSearchQuery(), + dbQuery, searchState.getSearchTokens(), SearchLayer.FULLTEXT, countAll, @@ -214,7 +252,8 @@ private DocumentSearchResult executeSearchOnDatabases(boolean countAll) throws S searchState.getUceMetadataFilters(), searchState.isProModeActivated(), searchState.getDbSchema(), - searchState.getSourceTable() + searchState.getSourceTable(), + expandedTermsForDb ); } catch (Exception ex) { logger.error("Error executing a search on the database with search layer FULLTEXT. Search can't be executed.", ex); @@ -223,12 +262,12 @@ private DocumentSearchResult executeSearchOnDatabases(boolean countAll) throws S } } - // Execute the Named Entity search + // Execute the Named Entity search (batch processing not applicable for NAMED_ENTITIES) if (searchState.getSearchLayers().contains(SearchLayer.NAMED_ENTITIES)) { return ExceptionUtils.tryCatchLog( () -> db.defaultSearchForDocuments((searchState.getCurrentPage() - 1) * searchState.getTake(), searchState.getTake(), - searchState.getEnrichedSearchQuery() == null ? searchState.getSearchQuery() : searchState.getEnrichedSearchQuery(), + dbQuery, searchState.getSearchTokens(), SearchLayer.NAMED_ENTITIES, countAll, @@ -238,7 +277,8 @@ private DocumentSearchResult executeSearchOnDatabases(boolean countAll) throws S searchState.getUceMetadataFilters(), searchState.isProModeActivated(), searchState.getDbSchema(), - searchState.getSourceTable()), + searchState.getSourceTable(), + expandedTermsForDb), (ex) -> logger.error("Error executing a search on the database with search layer NAMED_ENTITIES. Search can't be executed.", ex)); } @@ -403,5 +443,6 @@ private void initServices(ApplicationContext serviceContext, String languageCode this.embeddingService = serviceContext.getBean(EmbeddingService.class); } -} + +} diff --git a/uce.portal/uce.web/Dockerfile b/uce.portal/uce.web/Dockerfile index 1c4411d8..8e8127eb 100644 --- a/uce.portal/uce.web/Dockerfile +++ b/uce.portal/uce.web/Dockerfile @@ -1,20 +1,29 @@ -# Use an appropriate base image with Java (e.g., OpenJDK) +# syntax=docker/dockerfile:1.7 FROM maven:3.9.6-eclipse-temurin-21 AS builder -# Set the working directory inside the container WORKDIR /app -# Copy the entire uce.portal directory -COPY ./uce.portal ./uce.portal +COPY ./uce.portal/pom.xml ./uce.portal/pom.xml +COPY ./uce.portal/settings.xml ./uce.portal/settings.xml +COPY ./uce.portal/uce.common/pom.xml ./uce.portal/uce.common/pom.xml +COPY ./uce.portal/uce.corpus-importer/pom.xml ./uce.portal/uce.corpus-importer/pom.xml +COPY ./uce.portal/uce.web/pom.xml ./uce.portal/uce.web/pom.xml +COPY ./uce.portal/uce.search/pom.xml ./uce.portal/uce.search/pom.xml +COPY ./uce.portal/uce.analysis/pom.xml ./uce.portal/uce.analysis/pom.xml + +WORKDIR /app/uce.portal +RUN --mount=type=cache,id=uce-m2-repo,target=/root/.m2/repository,sharing=locked \ + mvn -B -T 1C -nsu -DskipTests -Dmaven.repo.local=/root/.m2/repository \ + dependency:go-offline -pl uce.web -am -# Override the default config +WORKDIR /app +COPY ./uce.portal ./uce.portal COPY ./uce.portal/uce.common/src/main/resources/common-release.conf /app/uce.portal/uce.common/src/main/resources/common.conf -# Set the working directory to uce.portal WORKDIR /app/uce.portal - -# We are also building the target jar here; we only need uce.web -RUN mvn clean install -DskipTests -pl uce.web -am +RUN --mount=type=cache,id=uce-m2-repo,target=/root/.m2/repository,sharing=locked \ + mvn -B -T 1C -nsu -DskipTests -Dmaven.repo.local=/root/.m2/repository \ + package -pl uce.web -am FROM eclipse-temurin:21-jdk diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/App.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/App.java index 2b3797fe..64521dcb 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/App.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/App.java @@ -6,6 +6,7 @@ import java.io.IOException; import java.io.InputStreamReader; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.StandardOpenOption; import java.util.HashMap; @@ -109,6 +110,8 @@ public static void main(String[] args) throws IOException { commonConfig = new CommonConfig(); logger.info("Loaded the common config."); + LanguageResources.setTemplatesLocationOverride(commonConfig.getTemplatesLocation()); + logger.info("Language resources templates override path: " + commonConfig.getTemplatesLocation()); logger.info("Adjusting UCE to the UceConfig..."); ExceptionUtils.tryCatchLog( @@ -186,9 +189,6 @@ public static void main(String[] args) throws IOException { try { var result = context.getBean(PostgresqlDataInterface_Impl.class).callGeonameLocationRefresh(); logger.info("Finished updating the geoname locations. Updated locations: " + result); - logger.info("Trying to refresh the timeline map cache..."); - context.getBean(MapService.class).refreshCachedTimelineMap(false); - logger.info("Finished refreshing the timeline map."); } catch (Exception ex){ logger.error("There was an error trying to refresh geoname locations in the startup of the web app. App starts normally though."); } @@ -292,6 +292,9 @@ private static void implementUceConfigurations(CommonConfig commonConfig) throws * @throws IOException */ private static String convertConfigImageString(String imgString) throws Exception { + if (imgString == null || imgString.isBlank()) { + return ""; + } if (imgString.startsWith("BASE64::")) { // If the logo is a base64 string, we only need to remove the prefix return imgString.replace("BASE64::", ""); @@ -314,7 +317,6 @@ private static void parseCommandLine(String[] args) throws ParseException, FileN "This process may take a while but will be executed asynchronous."); var parser = new DefaultParser(); - var gson = new Gson(); var cmd = parser.parse(options, args); forceLexicalization = cmd.hasOption("forceLexicalization"); @@ -322,11 +324,16 @@ private static void parseCommandLine(String[] args) throws ParseException, FileN var configJson = cmd.getOptionValue("configJson"); if (configFile != null && !configFile.isEmpty()) { - var reader = new FileReader(configFile); - SystemStatus.UceConfig = gson.fromJson(reader, UceConfig.class); - logger.info("Read UCE Config from path: " + configFile); + String json; + try { + json = Files.readString(java.nio.file.Path.of(configFile)); + SystemStatus.UceConfig = UceConfig.fromJson(json); + logger.info("Read UCE Config from path: " + configFile); + } catch (IOException e) { + throw new FileNotFoundException(e.getMessage() + ": " + configFile); + } } else if (configJson != null && !configJson.isEmpty()) { - SystemStatus.UceConfig = gson.fromJson(configJson, UceConfig.class); + SystemStatus.UceConfig = UceConfig.fromJson(configJson); logger.info("Parsed UCE Config from JSON."); } @@ -334,7 +341,13 @@ private static void parseCommandLine(String[] args) throws ParseException, FileN if (SystemStatus.UceConfig == null) { var inputStream = App.class.getClassLoader().getResourceAsStream("defaultUceConfig.json"); if (inputStream != null) { - SystemStatus.UceConfig = gson.fromJson(new InputStreamReader(inputStream), UceConfig.class); + String json; + try { + json = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + SystemStatus.UceConfig = UceConfig.fromJson(json); + } catch (IOException e) { + throw new FileNotFoundException(e.getMessage() + ": " + configFile); + } } else { throw new RuntimeException("Default uceConfig.json not found in the classpath."); } @@ -500,6 +513,10 @@ private static void initSparkRoutes(ApplicationContext context, ApiRegistry regi before("/*", (ctx) -> { }); + path("/auth", () -> { + get("/ping", (ctx) -> (registry.get(AuthenticationApi.class)).authPing(ctx)); + }); + path("/ie", () -> { post("/upload/uima", (ctx) -> (registry.get(ImportExportApi.class)).uploadUIMA(ctx)); get("/download/uima", (ctx) -> (registry.get(ImportExportApi.class)).downloadUIMA(ctx)); diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/CustomFreeMarkerEngine.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/CustomFreeMarkerEngine.java index 0246877e..4620933b 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/CustomFreeMarkerEngine.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/CustomFreeMarkerEngine.java @@ -6,6 +6,7 @@ import org.jetbrains.annotations.NotNull; import org.texttechnologylab.uce.web.freeMarker.RequestContextHolder; +import java.util.HashMap; import java.util.Map; /** @@ -21,23 +22,25 @@ public CustomFreeMarkerEngine(freemarker.template.Configuration configuration) { } public String render(String templatePath, Map model) { + Map mutableModel = model == null ? new HashMap<>() : new HashMap<>(model); + // Always inject the uceConfig - model.put("uceConfig", RequestContextHolder.getUceConfig()); + mutableModel.put("uceConfig", RequestContextHolder.getUceConfig()); // Add the LanguageResources object to the model if available in the request var languageResources = RequestContextHolder.getLanguageResources(); if (languageResources != null) { - model.put("languageResource", languageResources); + mutableModel.put("languageResource", languageResources); } // Add the UceUser object to the model if available in the session var uceUser = RequestContextHolder.getAuthenticatedUceUser(); if(uceUser != null){ - model.put("uceUser", uceUser); + mutableModel.put("uceUser", uceUser); } try { - return process(configuration, templatePath, model); + return process(configuration, templatePath, mutableModel); } catch (Exception e) { throw new RuntimeException(e); } diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/LanguageResources.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/LanguageResources.java index a8b73b0b..3da8b185 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/LanguageResources.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/LanguageResources.java @@ -9,15 +9,25 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; +import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.stream.Collectors; public final class LanguageResources { + private static final String TRANSLATIONS_FILE_NAME = "languageTranslations.json"; + private static volatile String templatesLocationOverride = null; private final Document languageTranslations; private final String defaultLanguage; private SupportedLanguages supportedLanguage; + public static void setTemplatesLocationOverride(String templatesLocation) { + templatesLocationOverride = templatesLocation; + } + public LanguageResources(String defaultLanguage) throws IOException { this.defaultLanguage = defaultLanguage; switch (defaultLanguage) { @@ -31,7 +41,10 @@ public LanguageResources(String defaultLanguage) throws IOException { supportedLanguage = SupportedLanguages.GERMAN; break; } - var inputStream = getClass().getClassLoader().getResourceAsStream("languageTranslations.json"); + InputStream inputStream = resolveLanguageResourceInputStream(); + if (inputStream == null) { + throw new IOException("Could not locate " + TRANSLATIONS_FILE_NAME + " in templates override or classpath."); + } String jsonData; try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { jsonData = reader.lines().collect(Collectors.joining(System.lineSeparator())); @@ -40,6 +53,18 @@ public LanguageResources(String defaultLanguage) throws IOException { languageTranslations = gson.fromJson(jsonData, Document.class); } + private InputStream resolveLanguageResourceInputStream() throws IOException { + var override = templatesLocationOverride; + if (override != null && !override.isBlank()) { + Path path = Paths.get(override, TRANSLATIONS_FILE_NAME); + if (Files.isRegularFile(path)) { + return Files.newInputStream(path); + } + } + + return getClass().getClassLoader().getResourceAsStream(TRANSLATIONS_FILE_NAME); + } + /** * Builds a language resource object with the correct language from a request * @@ -73,7 +98,19 @@ public String get(String resourceName) { } public String get(String resourceName, String lang) { - return languageTranslations.get(resourceName, LinkedTreeMap.class).get(lang).toString(); + var resource = languageTranslations.get(resourceName, LinkedTreeMap.class); + if (resource == null) return resourceName; + + var byRequestedLang = resource.get(lang); + if (byRequestedLang != null) return byRequestedLang.toString(); + + var byDefaultLang = resource.get(defaultLanguage); + if (byDefaultLang != null) return byDefaultLang.toString(); + + var byEnglish = resource.get("en-EN"); + if (byEnglish != null) return byEnglish.toString(); + + return resourceName; } } diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/DefaultPaneRenderer.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/DefaultPaneRenderer.java index 363dbc26..31709da3 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/DefaultPaneRenderer.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/DefaultPaneRenderer.java @@ -9,7 +9,7 @@ public final class DefaultPaneRenderer implements PaneRenderer { public static final String HANDLER_KEY = "document_reader_pdf_view"; - private static final String TEMPLATE = "documents/detail.ftl"; + private static final String TEMPLATE = "reader/modes/defaultMiddlePane.ftl"; @Override public RenderResult render(RenderContext context) { diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RenderPrincipal.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RenderPrincipal.java new file mode 100644 index 00000000..73c255bd --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RenderPrincipal.java @@ -0,0 +1,8 @@ +package org.texttechnologylab.uce.web.render; + +/** + * Request-scoped identity information a renderer/spec can use, e.g. to derive + * effective permissions. This keeps renderers decoupled from the HTTP layer. + */ +public record RenderPrincipal(String name) {} + diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RendererConfig.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RendererConfig.java index c1ec1416..3fcd2b1e 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RendererConfig.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/RendererConfig.java @@ -3,6 +3,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.texttechnologylab.uce.web.render.feedback.FeedbackPaneRenderer; +import org.texttechnologylab.uce.web.render.spec.SpecPaneRenderer; @Configuration public class RendererConfig { @@ -11,6 +12,7 @@ public class RendererConfig { public RendererRegistry rendererRegistry() { return new RendererRegistry() .register(DefaultPaneRenderer.HANDLER_KEY, new DefaultPaneRenderer()) - .register(FeedbackPaneRenderer.HANDLER_KEY, new FeedbackPaneRenderer()); + .register(FeedbackPaneRenderer.HANDLER_KEY, new FeedbackPaneRenderer()) + .register(SpecPaneRenderer.HANDLER_KEY, new SpecPaneRenderer()); } } diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/DeclarativeSpecBinder.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/DeclarativeSpecBinder.java new file mode 100644 index 00000000..5a94b91c --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/DeclarativeSpecBinder.java @@ -0,0 +1,234 @@ +package org.texttechnologylab.uce.web.render.spec; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import org.texttechnologylab.uce.common.models.corpus.Document; +import org.texttechnologylab.uce.common.models.corpus.Image; +import org.texttechnologylab.uce.common.models.corpus.UCEMetadata; +import org.texttechnologylab.uce.common.models.corpus.UCEMetadataValueType; +import org.texttechnologylab.uce.web.render.RenderException; +import org.texttechnologylab.uce.web.render.RenderPrincipal; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Builds a pane model from a JSON tree that only supports declarative references. + *

+ * A reference is expressed as an object with a single field {@code "$ref"} whose + * value is a dot-separated path into the runtime context: + *

    + *
  • {@code document.*} (JavaBean properties on {@link Document})
  • + *
  • {@code metadata.} (values from {@link UCEMetadata})
  • + *
  • {@code images} (list of image maps)
  • + *
  • {@code effectivePermission} (permission badge model)
  • + *
+ */ +final class DeclarativeSpecBinder { + + private static final String REF_KEY = "$ref"; + + private final Gson gson = new Gson(); + private final Document document; + private final String principal; + + private final Map metadata; + private final List> images; + private final PermissionBadgeModel effectivePermission; + + DeclarativeSpecBinder(Document document, RenderPrincipal principal) { + this.document = document; + this.principal = principal != null ? principal.name() : null; + this.metadata = buildMetadataMap(document); + this.images = buildImages(document); + this.effectivePermission = EffectivePermissionResolver.resolve(document, this.principal); + } + + Map bindModel(JsonElement element) throws RenderException { + var value = bind(element); + if (value == null) { + return Map.of(); + } + if (value instanceof Map map) { + var out = new HashMap(); + for (var entry : map.entrySet()) { + out.put(String.valueOf(entry.getKey()), entry.getValue()); + } + return out; + } + throw new RenderException("Spec pane model must evaluate to an object/map."); + } + + private Object bind(JsonElement element) throws RenderException { + if (element == null || element instanceof JsonNull) { + return null; + } + if (element.isJsonPrimitive()) { + return bindPrimitive(element.getAsJsonPrimitive()); + } + if (element.isJsonArray()) { + return bindArray(element.getAsJsonArray()); + } + if (element.isJsonObject()) { + return bindObject(element.getAsJsonObject()); + } + return null; + } + + private Object bindPrimitive(JsonPrimitive primitive) { + if (primitive.isBoolean()) { + return primitive.getAsBoolean(); + } + if (primitive.isNumber()) { + return primitive.getAsNumber(); + } + return primitive.getAsString(); + } + + private List bindArray(JsonArray array) throws RenderException { + var out = new ArrayList(array.size()); + for (var item : array) { + out.add(bind(item)); + } + return out; + } + + private Map bindObject(JsonObject obj) throws RenderException { + if (obj.size() == 1 && obj.has(REF_KEY)) { + return Map.of("value", resolveRef(obj.get(REF_KEY).getAsString())); + } + var out = new HashMap(); + for (var entry : obj.entrySet()) { + out.put(entry.getKey(), unwrapValue(bind(entry.getValue()))); + } + return out; + } + + private static Object unwrapValue(Object maybeWrapped) { + if (maybeWrapped instanceof Map map && map.size() == 1 && map.containsKey("value")) { + return map.get("value"); + } + return maybeWrapped; + } + + private Object resolveRef(String ref) throws RenderException { + if (ref == null || ref.isBlank()) { + return null; + } + if ("document".equals(ref)) { + return document; + } + if ("metadata".equals(ref)) { + return metadata; + } + if ("images".equals(ref)) { + return images; + } + if ("effectivePermission".equals(ref)) { + return effectivePermission; + } + + if (ref.startsWith("metadata.")) { + var key = ref.substring("metadata.".length()); + return metadata.get(key); + } + + if (ref.startsWith("document.")) { + var prop = ref.substring("document.".length()); + return readDocumentProperty(prop); + } + + throw new RenderException("Unsupported $ref path: " + ref); + } + + private Object readDocumentProperty(String prop) throws RenderException { + return switch (prop) { + case "id" -> document.getId(); + case "documentId" -> document.getDocumentId(); + case "documentTitle" -> safeDocumentTitle(); + case "language" -> document.getLanguage(); + case "mimeType" -> document.getMimeType(); + case "fullText" -> document.getFullText(); + case "fullTextCleaned" -> document.getFullTextCleaned(); + case "corpusId" -> document.getCorpusId(); + default -> throw new RenderException("Unsupported document property: " + prop); + }; + } + + private String safeDocumentTitle() { + try { + return document.getDocumentTitle(); + } catch (RuntimeException ex) { + return ""; + } + } + + private Map buildMetadataMap(Document document) { + var out = new HashMap(); + for (var meta : Optional.ofNullable(document.getUceMetadata()).orElse(List.of())) { + if (meta == null || meta.getKey() == null) { + continue; + } + out.put(meta.getKey(), coerceMetadataValue(meta)); + } + return out; + } + + private Object coerceMetadataValue(UCEMetadata meta) { + var value = meta.getValue(); + if (value == null) { + return null; + } + + UCEMetadataValueType type = meta.getValueType(); + if (type == null) { + return value; + } + + return switch (type) { + case NUMBER -> { + try { + yield Double.parseDouble(value.replace(",", ".")); + } catch (NumberFormatException ignored) { + yield 0d; + } + } + case JSON -> { + try { + yield gson.fromJson(value, Object.class); + } catch (Exception ignored) { + yield value; + } + } + default -> value; + }; + } + + private static List> buildImages(Document document) { + var out = new ArrayList>(); + for (Image image : Optional.ofNullable(document.getImages()).orElse(List.of())) { + if (image == null) { + continue; + } + out.add(Map.of( + "mimeType", nullToEmpty(image.getMimeType()), + "width", image.getWidth(), + "height", image.getHeight(), + "src", nullToEmpty(image.getSrc()), + "htmlImgSrc", image.getHTMLImgSrc() + )); + } + return out; + } + + private static String nullToEmpty(String value) { + return value != null ? value : ""; + } +} diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/EffectivePermissionResolver.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/EffectivePermissionResolver.java new file mode 100644 index 00000000..1d898975 --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/EffectivePermissionResolver.java @@ -0,0 +1,41 @@ +package org.texttechnologylab.uce.web.render.spec; + +import org.texttechnologylab.models.authentication.DocumentPermission; +import org.texttechnologylab.uce.common.models.corpus.Document; + +import java.util.Optional; + +final class EffectivePermissionResolver { + + private EffectivePermissionResolver() {} + + static PermissionBadgeModel resolve(Document document, String principal) { + var perms = Optional.ofNullable(document.getPermissions()).orElse(java.util.Set.of()); + + if (perms.isEmpty()) { + return new PermissionBadgeModel(PermissionBadgeModel.PermissionLevel.READ); + } + + if (principal == null || principal.isBlank()) { + return null; + } + + return perms.stream() + .filter(permission -> permission.getType() == DocumentPermission.DOCUMENT_PERMISSION_TYPE.EFFECTIVE) + .filter(permission -> principal.equals(permission.getName())) + .findFirst() + .map(permission -> new PermissionBadgeModel(mapPermissionLevel(permission.getLevel()))) + .orElse(null); + } + + private static PermissionBadgeModel.PermissionLevel mapPermissionLevel(DocumentPermission.DOCUMENT_PERMISSION_LEVEL level) { + return switch (level) { + case NONE -> PermissionBadgeModel.PermissionLevel.NONE; + case READ -> PermissionBadgeModel.PermissionLevel.READ; + case WRITE -> PermissionBadgeModel.PermissionLevel.WRITE; + case OWNER -> PermissionBadgeModel.PermissionLevel.OWNER; + case ADMIN -> PermissionBadgeModel.PermissionLevel.ADMIN; + }; + } +} + diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/PermissionBadgeModel.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/PermissionBadgeModel.java new file mode 100644 index 00000000..daac0a69 --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/PermissionBadgeModel.java @@ -0,0 +1,10 @@ +package org.texttechnologylab.uce.web.render.spec; + +/** + * Minimal permission view model tailored to {@code permissionBadge.ftl}. + */ +public record PermissionBadgeModel(PermissionLevel level) { + + public enum PermissionLevel { NONE, READ, WRITE, OWNER, ADMIN } +} + diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/RenderSpec.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/RenderSpec.java new file mode 100644 index 00000000..eac0a29f --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/RenderSpec.java @@ -0,0 +1,38 @@ +package org.texttechnologylab.uce.web.render.spec; + +import com.google.gson.JsonElement; + +/** + * JSON-driven render specification for generic render modes. + */ +public final class RenderSpec { + + private PaneSpec middle; + private PaneSpec right; + + public PaneSpec getMiddle() { + return middle; + } + + public PaneSpec getRight() { + return right; + } + + public boolean hasRight() { + return right != null && right.getTemplate() != null && !right.getTemplate().isBlank(); + } + + public static final class PaneSpec { + private String template; + private JsonElement model; + + public String getTemplate() { + return template; + } + + public JsonElement getModel() { + return model; + } + } +} + diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecLoader.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecLoader.java new file mode 100644 index 00000000..435a94dc --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecLoader.java @@ -0,0 +1,61 @@ +package org.texttechnologylab.uce.web.render.spec; + +import com.google.gson.Gson; +import org.texttechnologylab.uce.web.render.RenderException; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; + +final class SpecLoader { + + private static final String FILE_PREFIX = "FILE::"; + private static final String CLASSPATH_PREFIX = "CLASSPATH::"; + + private final Gson gson = new Gson(); + + RenderSpec load(String specPath) throws RenderException { + if (specPath == null || specPath.isBlank()) { + throw new RenderException("Render mode specPath is missing/blank."); + } + + String json; + try { + if (specPath.startsWith(FILE_PREFIX)) { + var path = specPath.substring(FILE_PREFIX.length()); + json = Files.readString(Path.of(path), StandardCharsets.UTF_8); + } else { + var resource = specPath.startsWith(CLASSPATH_PREFIX) + ? specPath.substring(CLASSPATH_PREFIX.length()) + : specPath; + var inputStream = getClass().getClassLoader().getResourceAsStream(resource); + if (inputStream == null) { + throw new RenderException("Spec resource not found in classpath: " + resource); + } + try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + json = reader.lines().collect(Collectors.joining(System.lineSeparator())); + } + } + } catch (RenderException ex) { + throw ex; + } catch (Exception ex) { + throw new RenderException("Failed to load spec: " + specPath, ex); + } + + try { + var spec = gson.fromJson(json, RenderSpec.class); + if (spec == null || spec.getMiddle() == null || spec.getMiddle().getTemplate() == null) { + throw new RenderException("Spec must define middle.template."); + } + return spec; + } catch (RenderException ex) { + throw ex; + } catch (Exception ex) { + throw new RenderException("Failed to parse spec JSON: " + specPath, ex); + } + } +} + diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecPaneRenderer.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecPaneRenderer.java new file mode 100644 index 00000000..fc71522a --- /dev/null +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/render/spec/SpecPaneRenderer.java @@ -0,0 +1,39 @@ +package org.texttechnologylab.uce.web.render.spec; + +import org.texttechnologylab.uce.common.config.corpusConfig.RenderModeConfig; +import org.texttechnologylab.uce.web.render.PaneRenderer; +import org.texttechnologylab.uce.web.render.RenderContext; +import org.texttechnologylab.uce.web.render.RenderException; +import org.texttechnologylab.uce.web.render.RenderPrincipal; +import org.texttechnologylab.uce.web.render.RenderResult; + +/** + * Generic renderer that loads a JSON spec file and evaluates it into a FreeMarker model. + */ +public final class SpecPaneRenderer implements PaneRenderer { + + public static final String HANDLER_KEY = "document_reader_template_view"; + + private final SpecLoader loader = new SpecLoader(); + + @Override + public RenderResult render(RenderContext context) throws RenderException { + var mode = context.requirePayload(RenderModeConfig.class); + var principal = context.payload(RenderPrincipal.class).orElse(null); + + var spec = loader.load(mode.getSpecPath()); + + var binder = new DeclarativeSpecBinder(context.document(), principal); + var middleModel = binder.bindModel(spec.getMiddle().getModel()); + + var builder = RenderResult.builder() + .middlePane(spec.getMiddle().getTemplate(), middleModel); + + if (spec.hasRight()) { + var rightModel = binder.bindModel(spec.getRight().getModel()); + builder.rightPane(spec.getRight().getTemplate(), rightModel); + } + + return builder.build(); + } +} diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/AuthenticationApi.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/AuthenticationApi.java index a8962b3e..22301fc1 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/AuthenticationApi.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/AuthenticationApi.java @@ -48,6 +48,19 @@ public void logoutCallback(Context ctx) { ctx.redirect("/"); }; + /** + * Used by the frontend to periodically detect expired sessions without waiting for a user action. + * Returns 204 if the session is still logged in, otherwise 401. + */ + public void authPing(Context ctx) { + ctx.header("Cache-Control", "no-store"); + if (ctx.sessionAttribute("uceUser") == null) { + ctx.status(401); + return; + } + ctx.status(204); + } + /** * A route that gets called by the keycloak server AFTER having a user logged in. In this request, we get a * code from keycloak which isn't yet a token. We need to again ask keycloak to send us, based on that code, the final @@ -161,8 +174,14 @@ public void loginCallback(Context ctx) { logger.info("Calculating effective permissions for username={} with groups size={}", user.getUsername(), user.getGroups().size()); db.calculateEffectivePermissions(user.getUsername(), user.getGroups()); - // We redirect back to the main page after logging in. - ctx.redirect("/"); + // Redirect back to the calling page (OIDC `state`) after logging in. + // Only allow local relative paths to prevent open redirects. + var state = ctx.queryParam("state"); + if (state != null && state.startsWith("/") && !state.startsWith("//") && !state.contains("\r") && !state.contains("\n")) { + ctx.redirect(state); + } else { + ctx.redirect("/"); + } } catch (Exception ex){ logger.error("Error in the authentication: a callback produced an error. Request " + "with id=" + ctx.attribute("id") + " to this endpoint for URI parameters.", ex); diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/DocumentApi.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/DocumentApi.java index de066f6e..ee6068be 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/DocumentApi.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/DocumentApi.java @@ -25,11 +25,13 @@ import org.texttechnologylab.uce.web.render.DefaultPaneRenderer; import org.texttechnologylab.uce.web.render.RenderContext; import org.texttechnologylab.uce.web.render.RenderException; +import org.texttechnologylab.uce.web.render.RenderPrincipal; import org.texttechnologylab.uce.web.render.RenderModeDescriptor; import org.texttechnologylab.uce.web.render.RenderResult; import org.texttechnologylab.uce.web.render.RendererRegistry; import org.texttechnologylab.uce.web.render.feedback.FeedbackDocument; import org.texttechnologylab.uce.web.render.feedback.FeedbackDocumentMapper; +import org.texttechnologylab.uce.web.render.feedback.FeedbackPaneRenderer; import java.io.IOException; import java.util.ArrayList; @@ -231,6 +233,12 @@ public void getSingleDocumentReadView(Context ctx) { // Check if we have an searchId parameter. This is optional var searchId = ExceptionUtils.tryCatchLog(() -> ctx.queryParam("searchId"), (ex) -> logger.warn("Opening a document view but no searchId parameter was provided. Currently, this shouldn't happen, but it didn't stop the procedure.")); + if (searchId != null) { + searchId = searchId.trim(); + if (searchId.isEmpty() || "undefined".equalsIgnoreCase(searchId) || "null".equalsIgnoreCase(searchId)) { + searchId = null; + } + } try { @@ -245,16 +253,24 @@ public void getSingleDocumentReadView(Context ctx) { // Build render mode descriptors var modes = buildRenderModes(corpusConfig); - var selectedKey = Optional.ofNullable(ctx.queryParam("mode")) - .filter(key -> modes.stream().anyMatch(m -> m.key().equals(key))) - .orElse(DefaultPaneRenderer.HANDLER_KEY); + if (modes.isEmpty()) { + modes = List.of(buildDefaultPdfMode()); + } + + var requestedMode = ctx.queryParam("mode"); + var selectedKey = (requestedMode != null && modes.stream().anyMatch(m -> m.key().equals(requestedMode))) + ? requestedMode + : modes.get(0).key(); + var activeMode = modes.stream() .filter(m -> m.key().equals(selectedKey)) .findFirst() - .orElseGet(() -> modes.get(0)); + .orElse(modes.get(0)); model.put("renderModes", modes); model.put("activeMode", activeMode.key()); + model.put("activeModeKey", activeMode.key()); + model.put("activeModeHandler", activeMode.handler()); var renderer = rendererRegistry .renderer(activeMode.handler()) @@ -262,11 +278,19 @@ public void getSingleDocumentReadView(Context ctx) { UceUser currentUser = ctx.sessionAttribute("uceUser"); var principal = currentUser != null ? currentUser.getUsername() : DocumentPermission.PUBLIC_USERNAME; - var feedback = feedbackMapper.map(doc, principal); - var renderContext = RenderContext.builder(corpus, doc) - .payload(FeedbackDocument.class, feedback) - .build(); + var activeModeConfig = findRenderModeConfig(corpusConfig, activeMode.key()).orElse(null); + + var renderContextBuilder = RenderContext.builder(corpus, doc) + .payload(RenderPrincipal.class, new RenderPrincipal(principal)); + if (activeModeConfig != null) { + renderContextBuilder.payload(RenderModeConfig.class, activeModeConfig); + } + if (FeedbackPaneRenderer.HANDLER_KEY.equals(activeMode.handler())) { + var feedback = feedbackMapper.map(doc, principal); + renderContextBuilder.payload(FeedbackDocument.class, feedback); + } + var renderContext = renderContextBuilder.build(); RenderResult panes; try { @@ -274,8 +298,15 @@ public void getSingleDocumentReadView(Context ctx) { } catch (RenderException ex) { panes = rendererRegistry.renderer(DefaultPaneRenderer.HANDLER_KEY).orElseThrow() .render(renderContext); - activeMode = modes.stream().filter(m -> m.key().equals("default")).findFirst().orElse(activeMode); - model.put("activeMode", activeMode.key()); + // Fallback to PDF view as a last resort if the configured renderer fails. + if (modes.stream().noneMatch(m -> m.key().equals(DefaultPaneRenderer.HANDLER_KEY))) { + modes = new ArrayList<>(modes); + modes.add(buildDefaultPdfMode()); + model.put("renderModes", modes); + } + model.put("activeMode", DefaultPaneRenderer.HANDLER_KEY); + model.put("activeModeKey", DefaultPaneRenderer.HANDLER_KEY); + model.put("activeModeHandler", DefaultPaneRenderer.HANDLER_KEY); } model.put("middlePaneTemplate", panes.getMiddlePaneTemplate()); @@ -315,15 +346,6 @@ private List buildRenderModes(CorpusConfig config) { List descriptors = new ArrayList<>(); Set seenKeys = new HashSet<>(); - // Default-/PDF-View immer zuerst - descriptors.add(new RenderModeDescriptor( - "document_reader_pdf_view", - "PDF", // UI-Text holen wir später aus LanguageResources - DefaultPaneRenderer.HANDLER_KEY, - null - )); - seenKeys.add("document_reader_pdf_view"); - if (config != null && config.getRenderModes() != null) { for (RenderModeConfig mode : config.getRenderModes()) { if (mode == null || mode.getKey() == null) { @@ -333,17 +355,51 @@ private List buildRenderModes(CorpusConfig config) { continue; } + // Only include render modes that have an actual renderer registered. + if (mode.getHandler() == null || rendererRegistry.renderer(mode.getHandler()).isEmpty()) { + continue; + } + descriptors.add(new RenderModeDescriptor( mode.getKey(), mode.getName(), mode.getHandler(), mode.getDescription() )); + seenKeys.add(mode.getKey()); } } + + // Only include PDF view if explicitly configured, or as a last resort if nothing else is available. + boolean pdfExplicitlyConfigured = config != null + && config.getRenderModes() != null + && config.getRenderModes().stream().anyMatch(m -> m != null && DefaultPaneRenderer.HANDLER_KEY.equals(m.getKey())); + boolean pdfAlreadyPresent = descriptors.stream().anyMatch(m -> DefaultPaneRenderer.HANDLER_KEY.equals(m.key())); + if (descriptors.isEmpty() || (pdfExplicitlyConfigured && !pdfAlreadyPresent)) { + descriptors.add(buildDefaultPdfMode()); + } return descriptors; } + private RenderModeDescriptor buildDefaultPdfMode() { + return new RenderModeDescriptor( + DefaultPaneRenderer.HANDLER_KEY, + "PDF", + DefaultPaneRenderer.HANDLER_KEY, + null + ); + } + + private Optional findRenderModeConfig(CorpusConfig config, String key) { + if (config == null || config.getRenderModes() == null || key == null) { + return Optional.empty(); + } + return config.getRenderModes() + .stream() + .filter(mode -> mode != null && key.equals(mode.getKey())) + .findFirst(); + } + /** * Finds all document ids matching a metadata key, value and value type. */ diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/SearchApi.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/SearchApi.java index 63fc9afa..d77db74e 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/SearchApi.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/SearchApi.java @@ -17,6 +17,7 @@ import org.texttechnologylab.uce.common.models.search.SearchLayer; import org.texttechnologylab.uce.common.models.search.SearchOrder; import org.texttechnologylab.uce.common.models.search.SearchType; +import org.texttechnologylab.uce.common.models.search.promode.ProModeSyntaxException; import org.texttechnologylab.uce.common.services.PostgresqlDataInterface_Impl; import org.texttechnologylab.uce.search.*; import org.texttechnologylab.uce.web.CustomFreeMarkerEngine; @@ -144,8 +145,18 @@ public void search(Context ctx) throws IOException { var languageResources = LanguageResources.fromRequest(ctx); try { + if (requestBody == null || !requestBody.containsKey("corpusId") || requestBody.get("corpusId") == null) { + ctx.status(400); + ctx.result("No corpus selected."); + return; + } var searchInput = requestBody.get("searchInput").toString(); var corpusId = Long.parseLong(requestBody.get("corpusId").toString()); + if (corpusId <= 0) { + ctx.status(400); + ctx.result("No corpus selected."); + return; + } model.put("corpusVm", db.getCorpusById(corpusId).getViewModel()); var fulltextOrNeLayer = requestBody.get("fulltextOrNeLayer").toString(); var useEmbeddings = Boolean.parseBoolean(requestBody.get("useEmbeddings").toString()); @@ -221,6 +232,9 @@ public void search(Context ctx) throws IOException { } catch (SQLGrammarException grammarException) { ctx.status(406); ctx.result(languageResources.get("searchGrammarError")); + } catch (ProModeSyntaxException syntaxException) { + ctx.status(406); + ctx.result(syntaxException.getMessage()); } catch (DocumentAccessDeniedException dade) { AccessDeniedRenderer.render( ctx, @@ -272,6 +286,47 @@ public void layeredSearch(Context ctx) throws IOException { } } + public void batchStatus(Context ctx) { + var result = new HashMap(); + try { + result.put("status", 410); + result.put("message", "batch mode is disabled"); + ctx.json(result); + } catch (Exception ex) { + logger.error("Error loading batch status.", ex); + ctx.status(500); + result.put("status", 500); + result.put("message", "error loading batch status"); + ctx.json(result); + } + } + + public void batchCancel(Context ctx) { + var result = new HashMap(); + try { + result.put("status", 410); + result.put("cancelled", false); + result.put("message", "batch mode is disabled"); + ctx.json(result); + } catch (Exception ex) { + logger.error("Error cancelling batch search.", ex); + ctx.status(500); + result.put("status", 500); + result.put("message", "error cancelling batch search"); + ctx.json(result); + } + } + + public void batchEvents(Context ctx) { + try { + ctx.status(410); + ctx.result("batch mode is disabled"); + } catch (Exception ex) { + logger.error("Error streaming batch SSE events.", ex); + ctx.status(500); + } + } + /** * Old route that is currently not being used. The default search route checks what kind of search it is now. */ diff --git a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/WikiApi.java b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/WikiApi.java index d1877302..37ea1dde 100644 --- a/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/WikiApi.java +++ b/uce.portal/uce.web/src/main/java/org/texttechnologylab/uce/web/routes/WikiApi.java @@ -104,9 +104,10 @@ public void getPage(Context ctx) { return; } - // Determine the type. A wikiID always has the following format: - + // Determine the type. Typical formats are - and taxon variants like TA-GA- / TA-GN-. var split = wid.split("-"); var type = split[0]; + String taxonSubtype = null; // Check if we have loaded, built and cached that wiki page before. We don't re-render it then. // BUT: We have different wiki views for different languages so the lang needs to be part of the key! @@ -122,8 +123,21 @@ public void getPage(Context ctx) { } // A missing id isn't necessarily bad, as we also have documentation pages etc. - var id = ExceptionUtils.tryCatchLog(() -> Long.parseLong(split[1]), (ex) -> { - }); + Long id = null; + if (type.equals("TA") && split.length >= 3) { + taxonSubtype = split[1]; + id = ExceptionUtils.tryCatchLog(() -> Long.parseLong(split[2]), (ex) -> { + }); + } else { + if (type.startsWith("TA_")) { + taxonSubtype = type.substring(3); + type = "TA"; + } + if (split.length >= 2) { + id = ExceptionUtils.tryCatchLog(() -> Long.parseLong(split[1]), (ex) -> { + }); + } + } var renderView = ""; if (type.startsWith("DOC")) { @@ -138,7 +152,12 @@ public void getPage(Context ctx) { renderView = "/wiki/pages/namedEntityAnnotationPage.ftl"; } else if (type.startsWith("TA")) { // We then clicked onto a Taxon wiki item, but which one? - var clazz = type.equals("TA_GN") ? GnFinderTaxon.class : GazetteerTaxon.class; + if (id == null) { + model.put("information", languageResources.get("missingParameterError")); + ctx.render("defaultError.ftl"); + return; + } + var clazz = "GN".equalsIgnoreCase(taxonSubtype) ? GnFinderTaxon.class : GazetteerTaxon.class; model.put("vm", wikiService.buildTaxonWikipageViewModel(id, coveredText, clazz)); renderView = "/wiki/pages/taxonAnnotationPage.ftl"; } else if (type.equals("TP") || type.equals("TD")) { diff --git a/uce.portal/uce.web/src/main/resources/languageTranslations.json b/uce.portal/uce.web/src/main/resources/languageTranslations.json index 555d30c0..6c9da911 100644 --- a/uce.portal/uce.web/src/main/resources/languageTranslations.json +++ b/uce.portal/uce.web/src/main/resources/languageTranslations.json @@ -255,6 +255,10 @@ "de-DE": "Zeigt nur eine Schätzung der gefundenen Dokumente an, die in den meisten Fällen niedriger angesetzt ist.", "en-EN": "Displays only an estimate of the documents found, which in most cases is understated." }, + "searchLayerSwitchInfo": { + "de-DE": "Wechselt zwischen den verfügbaren Ergebnis-Layern dieser Suche (z. B. Volltext und Embedding).", + "en-EN": "Switches between the available result layers of this search (e.g. Fulltext and Embedding)." + }, "showUceMetadata": { "de-DE": "Zeige alle Metadaten an", "en-EN": "Show all metadata" @@ -562,5 +566,25 @@ "documentAccessDeniedError": { "en-EN": "You are not allowed to view this document.", "de-DE": "Sie haben keine Berechtigung, dieses Dokument zu sehen." + }, + "sessionExpiredTitle": { + "en-EN": "Session expired", + "de-DE": "Sitzung abgelaufen" + }, + "sessionExpiredBody": { + "en-EN": "Your login session ended. Please log in again to continue.", + "de-DE": "Ihre Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an, um fortzufahren." + }, + "sessionExpiredCountdownPrefix": { + "en-EN": "Redirecting to home in", + "de-DE": "Weiterleitung zur Startseite in" + }, + "sessionExpiredReloginBtn": { + "en-EN": "Log back in", + "de-DE": "Erneut anmelden" + }, + "sessionExpiredHomeBtn": { + "en-EN": "Go to home now", + "de-DE": "Jetzt zur Startseite" } } diff --git a/uce.portal/uce.web/src/main/resources/public/js/visualization/chartjs.js b/uce.portal/uce.web/src/main/resources/public/js/visualization/chartjs.js index c3f0848a..5a552d4b 100644 --- a/uce.portal/uce.web/src/main/resources/public/js/visualization/chartjs.js +++ b/uce.portal/uce.web/src/main/resources/public/js/visualization/chartjs.js @@ -45,6 +45,9 @@ class ChartJS { this.dataDict = data; const chartType = this.config.type; const isSegmentedChart = ['pie', 'doughnut', 'polarArea', 'radar'].includes(chartType); + const labelName = typeof this.dataDict.labelName === 'string' + ? this.dataDict.labelName + : ''; // Derive the color pallete from our primary color of UCE const baseHex = this.primaryColor.startsWith('#') ? this.primaryColor : rgbToHex(this.primaryColor); @@ -56,7 +59,7 @@ class ChartJS { labels: this.dataDict.labels, datasets: [ { - label: this.dataDict.labelName, + label: labelName, data: this.dataDict.data, backgroundColor, borderColor: backgroundColor @@ -116,4 +119,4 @@ class ChartJS { radar() { this.setType('radar'); } } -export {ChartJS} \ No newline at end of file +export {ChartJS} diff --git a/uce.portal/uce.web/src/main/resources/public/js/visualization/echarts.js b/uce.portal/uce.web/src/main/resources/public/js/visualization/echarts.js index 446bdd74..9c969b86 100644 --- a/uce.portal/uce.web/src/main/resources/public/js/visualization/echarts.js +++ b/uce.portal/uce.web/src/main/resources/public/js/visualization/echarts.js @@ -3,11 +3,20 @@ class ECharts { this.containerId = containerId; this.option = option; const container = document.getElementById(containerId); + if (!container) { + this.chart = null; + return; + } + const existingChart = echarts.getInstanceByDom(container); + if (existingChart) { + existingChart.dispose(); + } this.chart = echarts.init(container); this.render(); } render() { + if (!this.chart) return; this.chart.setOption(this.option); } diff --git a/uce.portal/uce.web/src/main/resources/render-specs/feedback.json b/uce.portal/uce.web/src/main/resources/render-specs/feedback.json new file mode 100644 index 00000000..a8511d44 --- /dev/null +++ b/uce.portal/uce.web/src/main/resources/render-specs/feedback.json @@ -0,0 +1,12 @@ +{ + "middle": { + "template": "feedback/middlePaneSpec.ftl", + "model": { + "documentTitle": { "$ref": "document.documentTitle" }, + "documentId": { "$ref": "document.documentId" }, + "metadata": { "$ref": "metadata" }, + "images": { "$ref": "images" }, + "effectivePermission": { "$ref": "effectivePermission" } + } + } +}