Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Linkout wip #3439

Draft
wants to merge 22 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions kubernetes/loculus/templates/_common-metadata.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
15 changes: 15 additions & 0 deletions kubernetes/loculus/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type DownloadOption = {
includeRestricted: boolean;
dataType: DownloadDataType;
compression: Compression;
dataFormat: string | undefined;
};

/**
Expand Down Expand Up @@ -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);
});
Expand Down
105 changes: 105 additions & 0 deletions website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { type FC, useState } from 'react';

Check failure on line 1 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

`react` import should occur after import of `@headlessui/react`
import { Menu } from '@headlessui/react';

Check failure on line 2 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

There should be at least one empty line between import groups
import IwwaArrowDown from '~icons/iwwa/arrow-down';

Check failure on line 3 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

`~icons/iwwa/arrow-down` import should occur after import of `../../../utils/templateProcessor`
import DashiconsExternal from '~icons/dashicons/external';

Check failure on line 4 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

`~icons/dashicons/external` import should occur after import of `../../../utils/templateProcessor`
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<LinkOutMenuProps> = ({
downloadUrlGenerator,
sequenceFilter,
referenceGenomesSequenceNames,

Check failure on line 27 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

'referenceGenomesSequenceNames' is defined but never used
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,

Check failure on line 54 in website/src/components/SearchPage/DownloadDialog/LinkOutMenu.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

Expected property shorthand
};

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<string, string>);

// Process template with all URLs
return processTemplate(linkOut.url, urlMap);
};

return (
<Menu as="div" className="ml-2 relative inline-block text-left">
<Menu.Button
className="outlineButton flex items-center"
onClick={() => setIsOpen(!isOpen)}
>
Tools
<IwwaArrowDown className="ml-2 h-5 w-5" aria-hidden="true" />
</Menu.Button>

<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{linkOuts.map((linkOut) => (
<Menu.Item key={linkOut.name}>
{({ active }) => (
<a
href={generateLinkOutUrl(linkOut)}
target="_blank"
rel="noopener noreferrer"
className={`
${active ? 'bg-gray-100 text-gray-900' : 'text-gray-700'}
flex items-center justify-between px-4 py-2 text-sm
`}
>
{linkOut.name}
<DashiconsExternal className="h-4 w-4 ml-2" />
</a>
)}
</Menu.Item>
))}
</div>
</Menu.Items>
</Menu>
);
};
13 changes: 13 additions & 0 deletions website/src/components/SearchPage/SearchFullUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
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';

Check failure on line 24 in website/src/components/SearchPage/SearchFullUI.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

`./DownloadDialog/LinkOutMenu.tsx` import should occur before import of `./DownloadDialog/SequenceFilters.tsx`
import type { LinkOut } from '../../types/config.ts';

Check failure on line 25 in website/src/components/SearchPage/SearchFullUI.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

'../../types/config.ts' import is duplicated

Check failure on line 25 in website/src/components/SearchPage/SearchFullUI.tsx

View workflow job for this annotation

GitHub Actions / Check format and types

`../../types/config.ts` type import should occur before import of `../../types/lapis.ts`
import {
getFieldValuesFromQuery,
getColumnVisibilitiesFromQuery,
Expand All @@ -46,6 +48,7 @@
initialCount: number;
initialQueryDict: QueryState;
showEditDataUseTermsControls?: boolean;
linkOuts: LinkOut[] | undefined;
}
interface QueryState {
[key: string]: string;
Expand All @@ -71,6 +74,7 @@
initialData,
initialCount,
initialQueryDict,
linkOuts,
showEditDataUseTermsControls = false,
}: InnerSearchFullUIProps) => {
if (!hiddenFieldValues) {
Expand Down Expand Up @@ -377,6 +381,15 @@
sequenceFilter={sequencesFilter}
referenceGenomesSequenceNames={referenceGenomesSequenceNames}
/>
{linkOuts!==undefined && linkOuts.length > 0 && (
<LinkOutMenu
downloadUrlGenerator={downloadUrlGenerator}
sequenceFilter={sequencesFilter}
referenceGenomesSequenceNames={referenceGenomesSequenceNames}
linkOuts={linkOuts}
/>
)
}
</div>
</div>

Expand Down
1 change: 1 addition & 0 deletions website/src/pages/[organism]/search/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -57,5 +57,6 @@ const { data, totalCount } = await performLapisSearchQueries(
initialData={data}
initialCount={totalCount}
initialQueryDict={initialQueryDict}
linkOuts={schema.linkOuts}
/>
</BaseLayout>
7 changes: 7 additions & 0 deletions website/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof linkOut>;

const schema = z.object({
organismName: z.string(),
Expand All @@ -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<typeof schema>;

Expand Down
32 changes: 32 additions & 0 deletions website/src/utils/templateProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Process a URL template by replacing placeholders with values
*/
export function processTemplate(template: string, params: Record<string, string>) {
// 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);
}
Loading