diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index f553f61ea..7b9fd82ab 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -168,6 +168,13 @@ organisms: schema: {{- with ($instance.schema | include "loculus.patchMetadataSchema" | fromYaml) }} organismName: {{ quote .organismName }} + {{ if .linkOuts }} + linkOuts: + {{- range $linkOut := .linkOuts }} + - name: {{ quote $linkOut.name }} + url: {{ quote $linkOut.url }} + {{- end }} + {{ end }} loadSequencesAutomatically: {{ .loadSequencesAutomatically | default false }} {{- $nucleotideSequences := .nucleotideSequences | default (list "main")}} {{ if .image }} diff --git a/kubernetes/loculus/values.yaml b/kubernetes/loculus/values.yaml index b78aa2eda..f71ef32f1 100644 --- a/kubernetes/loculus/values.yaml +++ b/kubernetes/loculus/values.yaml @@ -33,6 +33,21 @@ defaultOrganismConfig: &defaultOrganismConfig loadSequencesAutomatically: true organismName: "Ebola Sudan" image: "/images/organisms/ebolasudan_small.jpg" + linkOuts: + - name: "Nextclade simple" + url: "https://clades.nextstrain.org/?input-fasta={{[unalignedNucleotideSequences|fasta]}}&dataset-name=nextstrain/ebola/sudan&dataset-server={{https://raw.githubusercontent.com/nextstrain/nextclade_data/ebola/data_output}}" + - name: "Make a renamed fasta" + url: "https://metadata-sequence-combiner.vercel.app/api/combine?sequencesUrl={{[unalignedNucleotideSequences|json]}}&metadataUrl={{[metadata|json]}}&fields=displayName" + - name: "Nextclade renamed fasta" + url: "https://clades.nextstrain.org/?input-fasta={{https://metadata-sequence-combiner.vercel.app/api/combine?sequencesUrl={{[unalignedNucleotideSequences|json]}}&metadataUrl={{[metadata|json]}}&fields=displayName}}&dataset-name=nextstrain/ebola/sudan&dataset-server={{https://raw.githubusercontent.com/nextstrain/nextclade_data/ebola/data_output}}" + - name: "Make a renamed fasta with dates" + url: "https://metadata-sequence-combiner.vercel.app/api/combine?sequencesUrl={{[alignedNucleotideSequences|json]}}&metadataUrl={{[metadata|json]}}&fields=displayName,sampleCollectionDate" + - name: "Analyse with Delphy (not working for some reason)" + url: "https://delphy.fathom.info/?https://metadata-sequence-combiner.vercel.app/api/combine?sequencesUrl={{[alignedNucleotideSequences|json]}}&metadataUrl={{[metadata|json]}}&fields=displayName,sampleCollectionDate&filterForValidDateString=sampleCollectionDate" + - name: "Analyse with Delphy (use proxy, better but still not working)" + url: "https://delphy.fathom.info/?https://delphy.fathom.info/proxy/metadata-sequence-combiner.vercel.app/api/combine?sequencesUrl={{[alignedNucleotideSequences|json]}}&metadataUrl={{[metadata|json]}}&fields=displayName,sampleCollectionDate&filterForValidDateString=sampleCollectionDate" + + earliestReleaseDate: enabled: true externalFields: diff --git a/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts b/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts index 2f9b24bac..53dc31e3a 100644 --- a/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts +++ b/website/src/components/SearchPage/DownloadDialog/DownloadUrlGenerator.ts @@ -12,6 +12,7 @@ export type DownloadOption = { includeRestricted: boolean; dataType: DownloadDataType; compression: Compression; + dataFormat: string | undefined; }; /** @@ -45,13 +46,20 @@ export class DownloadUrlGenerator { if (!option.includeRestricted) { params.set('dataUseTerms', 'OPEN'); } - if (option.dataType.type === 'metadata') { + if (option.dataType.type === 'metadata' ) { params.set('dataFormat', metadataDefaultDownloadDataFormat); + } else { + params.set('dataFormat', 'fasta'); } if (option.compression !== undefined) { params.set('compression', option.compression); } + if(option.dataFormat!==undefined){ + params.delete('dataFormat'); + params.set('dataFormat', option.dataFormat); + } + downloadParameters.toUrlSearchParams().forEach(([name, value]) => { params.append(name, value); }); diff --git a/website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx b/website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx new file mode 100644 index 000000000..c50e5d158 --- /dev/null +++ b/website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx @@ -0,0 +1,105 @@ +import { type FC, useState } from 'react'; +import { Menu } from '@headlessui/react'; +import IwwaArrowDown from '~icons/iwwa/arrow-down'; +import DashiconsExternal from '~icons/dashicons/external'; +import { type DownloadUrlGenerator, type DownloadOption } from './DownloadUrlGenerator'; +import { type SequenceFilter } from './SequenceFilters'; +import { type ReferenceGenomesSequenceNames } from '../../../types/referencesGenomes'; +import { processTemplate } from '../../../utils/templateProcessor'; + +const DATA_TYPES = ["unalignedNucleotideSequences", "metadata", "alignedNucleotideSequences"] as const; + +type LinkOut = { + name: string; + url: string; +}; + +type LinkOutMenuProps = { + downloadUrlGenerator: DownloadUrlGenerator; + sequenceFilter: SequenceFilter; + referenceGenomesSequenceNames: ReferenceGenomesSequenceNames; + linkOuts: LinkOut[]; +}; + +export const LinkOutMenu: FC = ({ + downloadUrlGenerator, + sequenceFilter, + referenceGenomesSequenceNames, + linkOuts, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const generateLinkOutUrl = (linkOut: LinkOut) => { + // Find all placeholders in the template that match [type] or [type|format] + const placeholderRegex = /\[([\w]+)(?:\|([\w]+))?\]/g; + const placeholders = Array.from(linkOut.url.matchAll(placeholderRegex)); + + // Generate URLs for all found placeholders + const urlMap = placeholders.reduce((acc, match) => { + const [fullMatch, dataType, dataFormat] = match; + + // Skip if not a valid data type + if (!DATA_TYPES.includes(dataType as any)) { + return acc; + } + + const downloadOption: DownloadOption = { + includeOldData: false, + includeRestricted: false, + dataType: { + type: dataType, + segment: undefined, + }, + compression: undefined, + dataFormat: dataFormat, + }; + + const { url } = downloadUrlGenerator.generateDownloadUrl(sequenceFilter, downloadOption); + + // Use the full match (including format if present) as the key + // This ensures we replace exactly what was in the template + return { + ...acc, + [fullMatch.slice(1, -1)]: url, // Remove the [] brackets + }; + }, {} as Record); + + // Process template with all URLs + return processTemplate(linkOut.url, urlMap); + }; + + return ( + + setIsOpen(!isOpen)} + > + Tools + + + +
+ {linkOuts.map((linkOut) => ( + + {({ active }) => ( + + {linkOut.name} + + + )} + + ))} +
+
+
+ ); +}; \ No newline at end of file diff --git a/website/src/components/SearchPage/SearchFullUI.tsx b/website/src/components/SearchPage/SearchFullUI.tsx index 3ba3bf3c2..2dff0332d 100644 --- a/website/src/components/SearchPage/SearchFullUI.tsx +++ b/website/src/components/SearchPage/SearchFullUI.tsx @@ -21,6 +21,8 @@ import { type OrderBy } from '../../types/lapis.ts'; import type { ReferenceGenomesSequenceNames } from '../../types/referencesGenomes.ts'; import type { ClientConfig } from '../../types/runtimeConfig.ts'; import { formatNumberWithDefaultLocale } from '../../utils/formatNumber.tsx'; +import { LinkOutMenu } from './DownloadDialog/LinkOutMenu.tsx'; +import type { LinkOut } from '../../types/config.ts'; import { getFieldValuesFromQuery, getColumnVisibilitiesFromQuery, @@ -46,6 +48,7 @@ interface InnerSearchFullUIProps { initialCount: number; initialQueryDict: QueryState; showEditDataUseTermsControls?: boolean; + linkOuts: LinkOut[] | undefined; } interface QueryState { [key: string]: string; @@ -71,6 +74,7 @@ export const InnerSearchFullUI = ({ initialData, initialCount, initialQueryDict, + linkOuts, showEditDataUseTermsControls = false, }: InnerSearchFullUIProps) => { if (!hiddenFieldValues) { @@ -377,6 +381,15 @@ export const InnerSearchFullUI = ({ sequenceFilter={sequencesFilter} referenceGenomesSequenceNames={referenceGenomesSequenceNames} /> + {linkOuts!==undefined && linkOuts.length > 0 && ( + + ) + } diff --git a/website/src/pages/[organism]/search/index.astro b/website/src/pages/[organism]/search/index.astro index 2f3b4dccd..3d65e7b92 100644 --- a/website/src/pages/[organism]/search/index.astro +++ b/website/src/pages/[organism]/search/index.astro @@ -57,5 +57,6 @@ const { data, totalCount } = await performLapisSearchQueries( initialData={data} initialCount={totalCount} initialQueryDict={initialQueryDict} + linkOuts={schema.linkOuts} /> diff --git a/website/src/types/config.ts b/website/src/types/config.ts index 477866f00..f9ee752b4 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -89,6 +89,12 @@ export type GroupedMetadataFilter = { notSearchable?: boolean; initiallyVisible?: boolean; }; +export const linkOut = z.object({ + name: z.string(), + url: z.string(), +}); + +export type LinkOut = z.infer; const schema = z.object({ organismName: z.string(), @@ -102,6 +108,7 @@ const schema = z.object({ defaultOrderBy: z.string(), defaultOrder: orderByType, loadSequencesAutomatically: z.boolean().optional(), + linkOuts: z.array(linkOut).optional(), }); export type Schema = z.infer; diff --git a/website/src/utils/templateProcessor.ts b/website/src/utils/templateProcessor.ts new file mode 100644 index 000000000..36befe887 --- /dev/null +++ b/website/src/utils/templateProcessor.ts @@ -0,0 +1,32 @@ +/** + * Process a URL template by replacing placeholders with values + */ +export function processTemplate(template: string, params: Record) { + // Helper function to recursively process {{ }} expressions + function processNestedBraces(str: string): string { + // Regex to find innermost {{ }} pairs that don't contain other {{ }} + const regex = /{{([^{}]+)}}/g; + + // If no more {{ }} found, return the string + if (!str.match(regex)) { + return str; + } + + // Replace all innermost {{ }} with their URL encoded content + const processed = str.replace(regex, (match, content) => { + return encodeURIComponent(content.trim()); + }); + + // Recursively process any remaining {{ }} + return processNestedBraces(processed); + } + + // Replace special placeholders with their values + let result = template; + Object.entries(params).forEach(([key, value]) => { + result = result.replace(`[${key}]`, value || ''); + }); + + // Process all {{ }} expressions recursively + return processNestedBraces(result); +}