@@ -12,6 +12,7 @@ import { track } from "@/utils/analytics";
1212import { withTimeout } from "@/utils/api" ;
1313import { SERVER_URL } from "@/utils/constants" ;
1414import { DB_LABELS , SEARCH_DBS , type SearchDb } from "@/utils/db-colors" ;
15+ import { downloadCsv } from "@/utils/exportCsv" ;
1516import { getProjectShortUrl } from "@/utils/shortUrl" ;
1617import { SearchResult } from "@/utils/types" ;
1718import {
@@ -246,6 +247,36 @@ function appendFilterParams(url: string, f: SearchFilterParams): string {
246247 return url ;
247248}
248249
250+ // Dataset-level download columns. Must stay in step with _DOWNLOAD_COLUMNS in the
251+ // API (main.py): the text path gets this CSV from the server and the geo path
252+ // builds it in the browser, and the two should be the same file.
253+ const DOWNLOAD_COLUMNS = [
254+ "accession" ,
255+ "source" ,
256+ "title" ,
257+ "summary" ,
258+ "updated_at" ,
259+ "organisms" ,
260+ "countries" ,
261+ "instrument_models" ,
262+ "library_strategies" ,
263+ ] ;
264+
265+ function resultToCsvRow ( r : SearchResult ) : Record < string , unknown > {
266+ const join = ( v : string [ ] | null | undefined ) => ( v ?? [ ] ) . join ( "; " ) ;
267+ return {
268+ accession : r . accession ,
269+ source : r . source ,
270+ title : r . title ,
271+ summary : r . summary ,
272+ updated_at : r . updated_at ,
273+ organisms : join ( r . organisms ) ,
274+ countries : join ( r . countries ) ,
275+ instrument_models : join ( r . instrument_models ) ,
276+ library_strategies : join ( r . library_strategies ) ,
277+ } ;
278+ }
279+
249280function buildSearchUrl (
250281 query : string ,
251282 db : string | null ,
@@ -1884,52 +1915,58 @@ export default function SearchPageBody() {
18841915 } , [ ] ) ;
18851916
18861917 const handleDownloadResults = async ( ) => {
1887- if ( isDownloading || ! query ) return ;
1918+ if ( isDownloading ) return ;
1919+ if ( ! isGeoSearch && ! query ) return ;
18881920
18891921 setIsDownloading ( true ) ;
18901922 setDownloadFailed ( false ) ;
18911923
1924+ const timestamp = new Date ( )
1925+ . toISOString ( )
1926+ . replace ( / [ - : ] / g, "" )
1927+ . replace ( / \. .+ / , "" )
1928+ . replace ( "T" , "_" ) ;
1929+
18921930 try {
1893- const params = new URLSearchParams ( ) ;
1894- params . set ( "q" , query ) ;
1895- if ( db && ( SEARCH_DBS as readonly string [ ] ) . includes ( db ) ) {
1896- params . set ( "db" , db ) ;
1931+ // Geo/map search paginates client-side and eagerly prefetches every page,
1932+ // so the browser already holds the whole result set (filters included) —
1933+ // no server round trip needed, and no /search/structured download endpoint.
1934+ if ( isGeoSearch ) {
1935+ downloadCsv (
1936+ filteredResults . map ( ( r ) => resultToCsvRow ( r ) ) ,
1937+ DOWNLOAD_COLUMNS ,
1938+ `seqout_results_${ timestamp } .csv` ,
1939+ ) ;
1940+ return ;
18971941 }
18981942
1899- if ( timeFilter === "custom" ) {
1900- const from = parseInt ( customYearRange . from ) ;
1901- const to = parseInt ( customYearRange . to ) ;
1902- if ( from ) params . set ( "updated_year_from" , String ( from ) ) ;
1903- if ( to ) params . set ( "updated_year_to" , String ( to ) ) ;
1904- } else if ( timeFilter !== "any" ) {
1905- const years = parseInt ( timeFilter ) ;
1906- const currentYear = new Date ( ) . getFullYear ( ) ;
1907- params . set ( "updated_year_from" , String ( currentYear - years ) ) ;
1908- params . set ( "updated_year_to" , String ( currentYear ) ) ;
1943+ // Text search: the server streams every match. Send the same db + sidebar
1944+ // filters the results list was fetched with, so the CSV is the search, not
1945+ // the page. Year range rides along inside searchFilters.
1946+ let url = ` ${ SERVER_URL } /download/query?q= ${ encodeURIComponent (
1947+ // The displayed results are the corrected query's when a typo was
1948+ // auto-corrected; download what is on screen, not the typo.
1949+ correction ?. corrected_query ?? query ! ,
1950+ ) } ` ;
1951+ if ( db && ( SEARCH_DBS as readonly string [ ] ) . includes ( db ) ) {
1952+ url += `&db= ${ encodeURIComponent ( db ) } ` ;
19091953 }
1954+ url = appendFilterParams ( url , searchFilters ) ;
19101955
1911- const res = await fetch (
1912- `${ SERVER_URL } /download/query?${ params . toString ( ) } ` ,
1913- ) ;
1914-
1956+ const res = await fetch ( url ) ;
19151957 if ( ! res . ok ) {
19161958 throw new Error ( "Download failed" ) ;
19171959 }
19181960
1919- const zipBlob = await res . blob ( ) ;
1920- const url = URL . createObjectURL ( zipBlob ) ;
1961+ const csvBlob = await res . blob ( ) ;
1962+ const objectUrl = URL . createObjectURL ( csvBlob ) ;
19211963 const a = document . createElement ( "a" ) ;
1922- a . href = url ;
1923- const timestamp = new Date ( )
1924- . toISOString ( )
1925- . replace ( / [ - : ] / g, "" )
1926- . replace ( / \. .+ / , "" )
1927- . replace ( "T" , "_" ) ;
1928- a . download = `results_${ timestamp } .zip` ;
1964+ a . href = objectUrl ;
1965+ a . download = `seqout_results_${ timestamp } .csv` ;
19291966 document . body . appendChild ( a ) ;
19301967 a . click ( ) ;
19311968 a . remove ( ) ;
1932- URL . revokeObjectURL ( url ) ;
1969+ URL . revokeObjectURL ( objectUrl ) ;
19331970 } catch ( error ) {
19341971 console . error ( error ) ;
19351972 setDownloadFailed ( true ) ;
@@ -2370,7 +2407,7 @@ export default function SearchPageBody() {
23702407 content = {
23712408 downloadFailed
23722409 ? "Download failed. Please try again."
2373- : "Download search results as ZIP "
2410+ : "Download every matching dataset as CSV (one row per dataset) "
23742411 }
23752412 >
23762413 < Button
@@ -2379,7 +2416,7 @@ export default function SearchPageBody() {
23792416 aria-busy = { isDownloading }
23802417 >
23812418 { isDownloading ? < Spinner /> : < DownloadIcon /> }
2382- { isDownloading ? "Preparing ZIP ..." : "Download results" }
2419+ { isDownloading ? "Preparing CSV ..." : "Download results" }
23832420 </ Button >
23842421 </ Tooltip >
23852422 ) }
0 commit comments