mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Add full Darwin Core export progress
This commit is contained in:
@@ -97,4 +97,33 @@ describe('DwC-A occurrence export (admin-only)', () => {
|
|||||||
expect(result.status).toEqual(403)
|
expect(result.status).toEqual(403)
|
||||||
expect(result.body).toEqual(noPermError)
|
expect(result.body).toEqual(noPermError)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns a full Darwin Core ZIP archive for admins', async () => {
|
||||||
|
const loginResult = await send<{ token: string }>('user/login', 'POST', { username: 'testSu', password: 'test' })
|
||||||
|
expect(loginResult.status).toEqual(200)
|
||||||
|
|
||||||
|
const result = await request(app)
|
||||||
|
.get('/occurrence/export/dwc-full-package')
|
||||||
|
.set('authorization', `bearer ${loginResult.body.token}`)
|
||||||
|
.buffer(true)
|
||||||
|
.parse(parseBinary)
|
||||||
|
|
||||||
|
expect(result.status).toEqual(200)
|
||||||
|
expect(result.headers['content-type']).toMatch(/application\/zip/i)
|
||||||
|
expect(result.headers['content-disposition']).toMatch(/attachment;\s*filename="now_dwc_full_test_export_/i)
|
||||||
|
|
||||||
|
const zip = await JSZip.loadAsync(result.body as unknown as Buffer)
|
||||||
|
expect(zip.file('README.txt')).toBeTruthy()
|
||||||
|
expect(zip.file('dwc-dp/datapackage.json')).toBeTruthy()
|
||||||
|
expect(zip.file('dwc-dp/occurrence.csv')).toBeTruthy()
|
||||||
|
expect(zip.file('dwc-a-taxa/taxon.csv')).toBeTruthy()
|
||||||
|
expect(zip.file('dwc-a-taxa/measurementorfact.csv')).toBeTruthy()
|
||||||
|
expect(zip.file('dwc-a-taxa/meta.xml')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-admin full Darwin Core requests', async () => {
|
||||||
|
const result = await request(app).get('/occurrence/export/dwc-full-package')
|
||||||
|
expect(result.status).toEqual(403)
|
||||||
|
expect(result.body).toEqual(noPermError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
buildDwcOccurrenceArchiveZipStream,
|
buildDwcOccurrenceArchiveZipStream,
|
||||||
type DwcOccurrenceExportProgress,
|
type DwcOccurrenceExportProgress,
|
||||||
} from '../services/dwcArchiveExportOccurrences'
|
} from '../services/dwcArchiveExportOccurrences'
|
||||||
import { buildDwcDataPackageZipBuffer } from '../services/dwcDataPackageExport'
|
import { buildDwcDataPackageZipBuffer, buildFullDarwinCoreExportZipBuffer } from '../services/dwcDataPackageExport'
|
||||||
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
|
||||||
import { logger } from '../utils/logger'
|
import { logger } from '../utils/logger'
|
||||||
|
|
||||||
@@ -71,6 +71,13 @@ router.get('/export/dwc-data-package', requireOneOf([Role.Admin]), async (_req,
|
|||||||
return res.status(200).send(zipBuffer)
|
return res.status(200).send(zipBuffer)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/export/dwc-full-package', requireOneOf([Role.Admin]), async (_req, res) => {
|
||||||
|
const zipBuffer = await buildFullDarwinCoreExportZipBuffer()
|
||||||
|
res.setHeader('Content-Type', 'application/zip')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="now_dwc_full_test_export_${currentDateAsString()}.zip"`)
|
||||||
|
return res.status(200).send(zipBuffer)
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/:lid/:speciesId', getOccurrenceDetail)
|
router.get('/:lid/:speciesId', getOccurrenceDetail)
|
||||||
router.put(
|
router.put(
|
||||||
'/:lid/:speciesId',
|
'/:lid/:speciesId',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Prisma from '../../prisma/generated/now_test_client'
|
import Prisma from '../../prisma/generated/now_test_client'
|
||||||
import { format } from 'fast-csv'
|
|
||||||
import { Writable } from 'stream'
|
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
|
import { toDwcCsvString, writeDwcCsvString } from './utils/dwcCsv'
|
||||||
|
|
||||||
const isMeaningfulString = (value: unknown): value is string => {
|
const isMeaningfulString = (value: unknown): value is string => {
|
||||||
if (typeof value !== 'string') return false
|
if (typeof value !== 'string') return false
|
||||||
@@ -11,51 +10,7 @@ const isMeaningfulString = (value: unknown): value is string => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDwcString = (value: unknown): string => {
|
const toDwcString = toDwcCsvString
|
||||||
if (value === null || value === undefined) return ''
|
|
||||||
if (typeof value === 'bigint') return value.toString()
|
|
||||||
if (typeof value === 'number') return Number.isFinite(value) ? value.toString() : ''
|
|
||||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value) ?? ''
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeCsvString = async (headers: string[], rows: Array<Record<string, unknown>>): Promise<string> => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let output = ''
|
|
||||||
const csvStream = format({
|
|
||||||
delimiter: ',',
|
|
||||||
headers,
|
|
||||||
quoteColumns: true,
|
|
||||||
quoteHeaders: true,
|
|
||||||
includeEndRowDelimiter: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sink = new Writable({
|
|
||||||
write(chunk: Buffer, _encoding, callback) {
|
|
||||||
output += chunk.toString('utf8')
|
|
||||||
callback()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
sink.on('finish', () => resolve(output))
|
|
||||||
sink.on('error', reject)
|
|
||||||
csvStream.on('error', reject)
|
|
||||||
|
|
||||||
csvStream.pipe(sink)
|
|
||||||
for (const row of rows) {
|
|
||||||
csvStream.write(row)
|
|
||||||
}
|
|
||||||
csvStream.end()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TAXON_HEADERS = [
|
export const TAXON_HEADERS = [
|
||||||
'taxonID',
|
'taxonID',
|
||||||
@@ -967,8 +922,8 @@ export const buildDwcArchiveZipBufferFromSpecies = async (
|
|||||||
const taxonRows = speciesRows.map(mapSpeciesToTaxonRow)
|
const taxonRows = speciesRows.map(mapSpeciesToTaxonRow)
|
||||||
const measurementRows = speciesRows.flatMap(mapSpeciesToMeasurementRows)
|
const measurementRows = speciesRows.flatMap(mapSpeciesToMeasurementRows)
|
||||||
|
|
||||||
const taxonCsv = await writeCsvString([...TAXON_HEADERS], taxonRows)
|
const taxonCsv = writeDwcCsvString(TAXON_HEADERS, taxonRows)
|
||||||
const measurementCsv = await writeCsvString([...MEASUREMENT_HEADERS], measurementRows)
|
const measurementCsv = writeDwcCsvString(MEASUREMENT_HEADERS, measurementRows)
|
||||||
const metaXml = buildMetaXml()
|
const metaXml = buildMetaXml()
|
||||||
const publicationDateIso = new Date().toISOString().slice(0, 10)
|
const publicationDateIso = new Date().toISOString().slice(0, 10)
|
||||||
const emlXml = buildEmlXml(publicationDateIso)
|
const emlXml = buildEmlXml(publicationDateIso)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { now_loc, now_time_unit } from '../../prisma/generated/now_test_client'
|
import type { now_loc, now_time_unit } from '../../prisma/generated/now_test_client'
|
||||||
import { format } from 'fast-csv'
|
|
||||||
import { Writable } from 'stream'
|
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { getContinentByCountry } from '../../../frontend/src/shared/validators/countryContinents'
|
import { getContinentByCountry } from '../../../frontend/src/shared/validators/countryContinents'
|
||||||
|
import { toDwcCsvString, writeDwcCsvString } from './utils/dwcCsv'
|
||||||
|
|
||||||
const isMeaningfulString = (value: unknown): value is string => {
|
const isMeaningfulString = (value: unknown): value is string => {
|
||||||
if (typeof value !== 'string') return false
|
if (typeof value !== 'string') return false
|
||||||
@@ -12,60 +11,7 @@ const isMeaningfulString = (value: unknown): value is string => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDwcString = (value: unknown): string => {
|
const toDwcString = toDwcCsvString
|
||||||
if (value === null || value === undefined) return ''
|
|
||||||
if (typeof value === 'bigint') return value.toString()
|
|
||||||
if (typeof value === 'number') return Number.isFinite(value) ? value.toString() : ''
|
|
||||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
if (value instanceof Date) return value.toISOString()
|
|
||||||
if (typeof (value as { toString?: unknown }).toString === 'function') {
|
|
||||||
const asString = (value as { toString: () => string }).toString()
|
|
||||||
if (asString && asString !== '[object Object]') return asString
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value) ?? ''
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const writeCsvString = async (headers: string[], rows: Array<Record<string, unknown>>): Promise<string> => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let output = ''
|
|
||||||
const csvStream = format({
|
|
||||||
delimiter: ',',
|
|
||||||
headers,
|
|
||||||
quoteColumns: true,
|
|
||||||
quoteHeaders: true,
|
|
||||||
includeEndRowDelimiter: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sink = new Writable({
|
|
||||||
write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
|
||||||
if (typeof chunk === 'string') {
|
|
||||||
output += chunk
|
|
||||||
} else {
|
|
||||||
output += chunk.toString('utf8')
|
|
||||||
}
|
|
||||||
callback()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
sink.on('finish', () => resolve(output))
|
|
||||||
sink.on('error', reject)
|
|
||||||
csvStream.on('error', reject)
|
|
||||||
|
|
||||||
csvStream.pipe(sink)
|
|
||||||
for (const row of rows) {
|
|
||||||
csvStream.write(row)
|
|
||||||
}
|
|
||||||
csvStream.end()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LOCATION_HEADERS = [
|
export const LOCATION_HEADERS = [
|
||||||
'locationID',
|
'locationID',
|
||||||
@@ -1381,9 +1327,9 @@ export const buildDwcLocalityArchiveZipBufferFromLocalities = async (
|
|||||||
const geologicalContextRows = localities.map(mapLocalityToGeologicalContextRow)
|
const geologicalContextRows = localities.map(mapLocalityToGeologicalContextRow)
|
||||||
const measurementRows = localities.flatMap(mapLocalityToMeasurementRows)
|
const measurementRows = localities.flatMap(mapLocalityToMeasurementRows)
|
||||||
|
|
||||||
const locationCsv = await writeCsvString([...LOCATION_HEADERS], locationRows)
|
const locationCsv = writeDwcCsvString(LOCATION_HEADERS, locationRows)
|
||||||
const geologyCsv = await writeCsvString([...GEOLOGICAL_CONTEXT_HEADERS], geologicalContextRows)
|
const geologyCsv = writeDwcCsvString(GEOLOGICAL_CONTEXT_HEADERS, geologicalContextRows)
|
||||||
const measurementCsv = await writeCsvString([...LOCALITY_MEASUREMENT_HEADERS], measurementRows)
|
const measurementCsv = writeDwcCsvString(LOCALITY_MEASUREMENT_HEADERS, measurementRows)
|
||||||
const metaXml = buildLocalityMetaXml()
|
const metaXml = buildLocalityMetaXml()
|
||||||
|
|
||||||
const publicationDateIso = new Date().toISOString().slice(0, 10)
|
const publicationDateIso = new Date().toISOString().slice(0, 10)
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import Prisma from '../../prisma/generated/now_test_client'
|
import Prisma from '../../prisma/generated/now_test_client'
|
||||||
import { format } from 'fast-csv'
|
import { createReadStream } from 'fs'
|
||||||
import { createReadStream, createWriteStream } from 'fs'
|
|
||||||
import { mkdtemp, rm } from 'fs/promises'
|
import { mkdtemp, rm } from 'fs/promises'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { once } from 'events'
|
|
||||||
import { Writable } from 'stream'
|
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import {
|
import {
|
||||||
GEOLOGICAL_CONTEXT_HEADERS,
|
GEOLOGICAL_CONTEXT_HEADERS,
|
||||||
@@ -14,40 +11,7 @@ import {
|
|||||||
mapLocalityToLocationRow,
|
mapLocalityToLocationRow,
|
||||||
} from './dwcArchiveExportLocalities'
|
} from './dwcArchiveExportLocalities'
|
||||||
import { MEASUREMENT_HEADERS, TAXON_HEADERS, mapSpeciesToTaxonRow, type MeasurementCsvRow } from './dwcArchiveExport'
|
import { MEASUREMENT_HEADERS, TAXON_HEADERS, mapSpeciesToTaxonRow, type MeasurementCsvRow } from './dwcArchiveExport'
|
||||||
|
import { createDwcCsvFileWriter, toDwcCsvString, writeDwcCsvString } from './utils/dwcCsv'
|
||||||
const writeCsvString = async (headers: string[], rows: Array<Record<string, unknown>>): Promise<string> => {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return `${headers.map(header => `"${header.replace(/"/g, '""')}"`).join(',')}\n`
|
|
||||||
}
|
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let output = ''
|
|
||||||
const csvStream = format({
|
|
||||||
delimiter: ',',
|
|
||||||
headers,
|
|
||||||
quoteColumns: true,
|
|
||||||
quoteHeaders: true,
|
|
||||||
includeEndRowDelimiter: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sink = new Writable({
|
|
||||||
write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
|
||||||
output += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
||||||
callback()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
sink.on('finish', () => resolve(output))
|
|
||||||
sink.on('error', reject)
|
|
||||||
csvStream.on('error', reject)
|
|
||||||
|
|
||||||
csvStream.pipe(sink)
|
|
||||||
for (const row of rows) {
|
|
||||||
csvStream.write(row)
|
|
||||||
}
|
|
||||||
csvStream.end()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMeaningfulString = (value: unknown): value is string => {
|
const isMeaningfulString = (value: unknown): value is string => {
|
||||||
if (typeof value !== 'string') return false
|
if (typeof value !== 'string') return false
|
||||||
@@ -57,18 +21,7 @@ const isMeaningfulString = (value: unknown): value is string => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDwcString = (value: unknown): string => {
|
const toDwcString = toDwcCsvString
|
||||||
if (value === null || value === undefined) return ''
|
|
||||||
if (typeof value === 'bigint') return value.toString()
|
|
||||||
if (typeof value === 'number') return Number.isFinite(value) ? value.toString() : ''
|
|
||||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
||||||
if (typeof value === 'string') return value
|
|
||||||
if (typeof value === 'object' && typeof (value as { toString?: unknown }).toString === 'function') {
|
|
||||||
const asString = (value as { toString: () => string }).toString()
|
|
||||||
return asString === '[object Object]' ? '' : asString
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMaybeMeaningful = (value: string | null | undefined): string => (isMeaningfulString(value) ? value.trim() : '')
|
const toMaybeMeaningful = (value: string | null | undefined): string => (isMeaningfulString(value) ? value.trim() : '')
|
||||||
|
|
||||||
@@ -432,35 +385,6 @@ const localityLookupSelect = {
|
|||||||
|
|
||||||
const speciesLookupSelect = occurrenceSelect.com_species.select
|
const speciesLookupSelect = occurrenceSelect.com_species.select
|
||||||
|
|
||||||
const csvCell = (value: unknown): string => `"${toDwcString(value).replace(/"/g, '""')}"`
|
|
||||||
|
|
||||||
const csvLine = (headers: readonly string[], row: Record<string, unknown>): string =>
|
|
||||||
`${headers.map(header => csvCell(row[header])).join(',')}\n`
|
|
||||||
|
|
||||||
const createCsvFileWriter = async (filePath: string, headers: readonly string[]) => {
|
|
||||||
const stream = createWriteStream(filePath, { encoding: 'utf8' })
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
stream.once('open', () => resolve())
|
|
||||||
stream.once('error', reject)
|
|
||||||
})
|
|
||||||
|
|
||||||
const write = async (line: string): Promise<void> => {
|
|
||||||
if (!stream.write(line)) await once(stream, 'drain')
|
|
||||||
}
|
|
||||||
|
|
||||||
await write(`${headers.map(csvCell).join(',')}\n`)
|
|
||||||
|
|
||||||
return {
|
|
||||||
writeRow: async (row: Record<string, unknown>): Promise<void> => {
|
|
||||||
await write(csvLine(headers, row))
|
|
||||||
},
|
|
||||||
close: async (): Promise<void> => {
|
|
||||||
stream.end()
|
|
||||||
await once(stream, 'finish')
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function* iterateOccurrenceRows(): AsyncGenerator<OccurrenceForExport> {
|
async function* iterateOccurrenceRows(): AsyncGenerator<OccurrenceForExport> {
|
||||||
const { nowDb } = await import('../utils/db')
|
const { nowDb } = await import('../utils/db')
|
||||||
let cursor: { lid: number; species_id: number } | undefined
|
let cursor: { lid: number; species_id: number } | undefined
|
||||||
@@ -600,17 +524,14 @@ export const buildDwcOccurrenceArchiveZipBufferFromOccurrences = async (
|
|||||||
species => species.species_id.toString()
|
species => species.species_id.toString()
|
||||||
)
|
)
|
||||||
|
|
||||||
const locationCsv = await writeCsvString([...LOCATION_HEADERS], localities.map(mapLocalityToLocationRow))
|
const locationCsv = writeDwcCsvString(LOCATION_HEADERS, localities.map(mapLocalityToLocationRow))
|
||||||
const geologicalContextCsv = await writeCsvString(
|
const geologicalContextCsv = writeDwcCsvString(
|
||||||
[...GEOLOGICAL_CONTEXT_HEADERS],
|
GEOLOGICAL_CONTEXT_HEADERS,
|
||||||
localities.map(mapLocalityToGeologicalContextRow)
|
localities.map(mapLocalityToGeologicalContextRow)
|
||||||
)
|
)
|
||||||
const taxonCsv = await writeCsvString([...TAXON_HEADERS], speciesRows.map(mapSpeciesToTaxonRow))
|
const taxonCsv = writeDwcCsvString(TAXON_HEADERS, speciesRows.map(mapSpeciesToTaxonRow))
|
||||||
const occurrenceCsv = await writeCsvString([...OCCURRENCE_HEADERS], occurrences.map(mapOccurrenceToOccurrenceRow))
|
const occurrenceCsv = writeDwcCsvString(OCCURRENCE_HEADERS, occurrences.map(mapOccurrenceToOccurrenceRow))
|
||||||
const measurementCsv = await writeCsvString(
|
const measurementCsv = writeDwcCsvString(MEASUREMENT_HEADERS, occurrences.flatMap(mapOccurrenceToMeasurementRows))
|
||||||
[...MEASUREMENT_HEADERS],
|
|
||||||
occurrences.flatMap(mapOccurrenceToMeasurementRows)
|
|
||||||
)
|
|
||||||
const metaXml = buildOccurrenceMetaXml()
|
const metaXml = buildOccurrenceMetaXml()
|
||||||
const emlXml = buildOccurrenceEmlXml(new Date().toISOString().slice(0, 10))
|
const emlXml = buildOccurrenceEmlXml(new Date().toISOString().slice(0, 10))
|
||||||
|
|
||||||
@@ -635,8 +556,8 @@ const writeOccurrenceAndMeasurementFiles = async ({
|
|||||||
measurementFilePath: string
|
measurementFilePath: string
|
||||||
reportProgress?: DwcOccurrenceExportProgressReporter
|
reportProgress?: DwcOccurrenceExportProgressReporter
|
||||||
}): Promise<{ localityIds: number[]; speciesIds: number[] }> => {
|
}): Promise<{ localityIds: number[]; speciesIds: number[] }> => {
|
||||||
const occurrenceWriter = await createCsvFileWriter(occurrenceFilePath, OCCURRENCE_HEADERS)
|
const occurrenceWriter = await createDwcCsvFileWriter(occurrenceFilePath, OCCURRENCE_HEADERS)
|
||||||
const measurementWriter = await createCsvFileWriter(measurementFilePath, MEASUREMENT_HEADERS)
|
const measurementWriter = await createDwcCsvFileWriter(measurementFilePath, MEASUREMENT_HEADERS)
|
||||||
const localityIds = new Set<number>()
|
const localityIds = new Set<number>()
|
||||||
const speciesIds = new Set<number>()
|
const speciesIds = new Set<number>()
|
||||||
const totalOccurrences = await countOccurrenceRows()
|
const totalOccurrences = await countOccurrenceRows()
|
||||||
@@ -692,8 +613,8 @@ const writeLocalityLookupFiles = async ({
|
|||||||
reportProgress?: DwcOccurrenceExportProgressReporter
|
reportProgress?: DwcOccurrenceExportProgressReporter
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { nowDb } = await import('../utils/db')
|
const { nowDb } = await import('../utils/db')
|
||||||
const locationWriter = await createCsvFileWriter(locationFilePath, LOCATION_HEADERS)
|
const locationWriter = await createDwcCsvFileWriter(locationFilePath, LOCATION_HEADERS)
|
||||||
const geologicalContextWriter = await createCsvFileWriter(geologicalContextFilePath, GEOLOGICAL_CONTEXT_HEADERS)
|
const geologicalContextWriter = await createDwcCsvFileWriter(geologicalContextFilePath, GEOLOGICAL_CONTEXT_HEADERS)
|
||||||
let generatedLocalities = 0
|
let generatedLocalities = 0
|
||||||
|
|
||||||
reportProgress?.({
|
reportProgress?.({
|
||||||
@@ -741,7 +662,7 @@ const writeTaxonLookupFile = async ({
|
|||||||
reportProgress?: DwcOccurrenceExportProgressReporter
|
reportProgress?: DwcOccurrenceExportProgressReporter
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
const { nowDb } = await import('../utils/db')
|
const { nowDb } = await import('../utils/db')
|
||||||
const taxonWriter = await createCsvFileWriter(taxonFilePath, TAXON_HEADERS)
|
const taxonWriter = await createDwcCsvFileWriter(taxonFilePath, TAXON_HEADERS)
|
||||||
let generatedTaxa = 0
|
let generatedTaxa = 0
|
||||||
|
|
||||||
reportProgress?.({
|
reportProgress?.({
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { format } from 'fast-csv'
|
|
||||||
import { Writable } from 'stream'
|
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { getContinentByCountry } from '../../../frontend/src/shared/validators/countryContinents'
|
import { getContinentByCountry } from '../../../frontend/src/shared/validators/countryContinents'
|
||||||
import {
|
import {
|
||||||
@@ -12,37 +10,8 @@ import {
|
|||||||
mapOccurrenceToOccurrenceRow,
|
mapOccurrenceToOccurrenceRow,
|
||||||
type OccurrenceCsvRow,
|
type OccurrenceCsvRow,
|
||||||
} from './dwcArchiveExportOccurrences'
|
} from './dwcArchiveExportOccurrences'
|
||||||
import type { MeasurementCsvRow } from './dwcArchiveExport'
|
import { buildDwcArchiveZipBuffer, type MeasurementCsvRow } from './dwcArchiveExport'
|
||||||
|
import { writeDwcCsvString } from './utils/dwcCsv'
|
||||||
const writeCsvString = async (headers: string[], rows: Array<Record<string, unknown>>): Promise<string> => {
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
let output = ''
|
|
||||||
const csvStream = format({
|
|
||||||
delimiter: ',',
|
|
||||||
headers,
|
|
||||||
quoteColumns: true,
|
|
||||||
quoteHeaders: true,
|
|
||||||
includeEndRowDelimiter: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const sink = new Writable({
|
|
||||||
write(chunk: Buffer | string, _encoding: BufferEncoding, callback: (error?: Error | null) => void) {
|
|
||||||
output += typeof chunk === 'string' ? chunk : chunk.toString('utf8')
|
|
||||||
callback()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
sink.on('finish', () => resolve(output))
|
|
||||||
sink.on('error', reject)
|
|
||||||
csvStream.on('error', reject)
|
|
||||||
|
|
||||||
csvStream.pipe(sink)
|
|
||||||
for (const row of rows) {
|
|
||||||
csvStream.write(row)
|
|
||||||
}
|
|
||||||
csvStream.end()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMeaningfulString = (value: unknown): value is string => {
|
const isMeaningfulString = (value: unknown): value is string => {
|
||||||
if (typeof value !== 'string') return false
|
if (typeof value !== 'string') return false
|
||||||
@@ -642,16 +611,13 @@ export const buildDwcDataPackageZipBufferFromRows = async ({
|
|||||||
const occurrenceAssertionRows = occurrences.flatMap(mapOccurrenceToDwcDpOccurrenceAssertionRows)
|
const occurrenceAssertionRows = occurrences.flatMap(mapOccurrenceToDwcDpOccurrenceAssertionRows)
|
||||||
|
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
zip.file(DWC_DP_TABLES.event, await writeCsvString([...DWC_DP_EVENT_HEADERS], eventRows))
|
zip.file(DWC_DP_TABLES.event, writeDwcCsvString(DWC_DP_EVENT_HEADERS, eventRows))
|
||||||
zip.file(
|
zip.file(DWC_DP_TABLES.geologicalContext, writeDwcCsvString(DWC_DP_GEOLOGICAL_CONTEXT_HEADERS, geologicalContextRows))
|
||||||
DWC_DP_TABLES.geologicalContext,
|
zip.file(DWC_DP_TABLES.occurrence, writeDwcCsvString(DWC_DP_OCCURRENCE_HEADERS, occurrenceRows))
|
||||||
await writeCsvString([...DWC_DP_GEOLOGICAL_CONTEXT_HEADERS], geologicalContextRows)
|
zip.file(DWC_DP_TABLES.eventAssertion, writeDwcCsvString(DWC_DP_EVENT_ASSERTION_HEADERS, eventAssertionRows))
|
||||||
)
|
|
||||||
zip.file(DWC_DP_TABLES.occurrence, await writeCsvString([...DWC_DP_OCCURRENCE_HEADERS], occurrenceRows))
|
|
||||||
zip.file(DWC_DP_TABLES.eventAssertion, await writeCsvString([...DWC_DP_EVENT_ASSERTION_HEADERS], eventAssertionRows))
|
|
||||||
zip.file(
|
zip.file(
|
||||||
DWC_DP_TABLES.occurrenceAssertion,
|
DWC_DP_TABLES.occurrenceAssertion,
|
||||||
await writeCsvString([...DWC_DP_OCCURRENCE_ASSERTION_HEADERS], occurrenceAssertionRows)
|
writeDwcCsvString(DWC_DP_OCCURRENCE_ASSERTION_HEADERS, occurrenceAssertionRows)
|
||||||
)
|
)
|
||||||
zip.file(DWC_DP_TABLES.dataPackage, buildDwcDataPackageJson(publicationDateIso))
|
zip.file(DWC_DP_TABLES.dataPackage, buildDwcDataPackageJson(publicationDateIso))
|
||||||
zip.file(DWC_DP_TABLES.eml, buildDwcDataPackageEmlXml(publicationDateIso))
|
zip.file(DWC_DP_TABLES.eml, buildDwcDataPackageEmlXml(publicationDateIso))
|
||||||
@@ -677,5 +643,83 @@ export const buildDwcDataPackageZipBuffer = async (): Promise<Buffer> => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addZipEntriesUnderPrefix = async ({
|
||||||
|
sourceZipBuffer,
|
||||||
|
targetZip,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
sourceZipBuffer: Buffer
|
||||||
|
targetZip: JSZip
|
||||||
|
prefix: string
|
||||||
|
}): Promise<void> => {
|
||||||
|
const sourceZip = await JSZip.loadAsync(sourceZipBuffer)
|
||||||
|
const files = Object.values(sourceZip.files).filter(file => !file.dir)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async file => {
|
||||||
|
targetZip.file(`${prefix}/${file.name}`, await file.async('nodebuffer'))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildFullDarwinCoreReadme = (): string => `NOW database Darwin Core full test export
|
||||||
|
|
||||||
|
This ZIP is a convenience bundle. It contains two separate standards-based export
|
||||||
|
artifacts in their own folders:
|
||||||
|
|
||||||
|
- dwc-dp/
|
||||||
|
Darwin Core Data Package test export for relational event, geological context,
|
||||||
|
occurrence, event assertion, and occurrence assertion data.
|
||||||
|
|
||||||
|
- dwc-a-taxa/
|
||||||
|
Darwin Core Archive test export for taxon records and synthesized taxon-level
|
||||||
|
traits. The traits remain in DwC-A Taxon + MeasurementOrFact form because they
|
||||||
|
are not currently linked to source specimen/material records.
|
||||||
|
|
||||||
|
Join key:
|
||||||
|
|
||||||
|
- dwc-dp/occurrence.csv taxonID uses NOW:<species_id>
|
||||||
|
- dwc-a-taxa/taxon.csv taxonID uses the same NOW:<species_id>
|
||||||
|
- dwc-a-taxa/measurementorfact.csv links taxon traits by that same taxonID
|
||||||
|
|
||||||
|
The outer ZIP is not itself a single DwC-DP or DwC-A artifact. Each subfolder is
|
||||||
|
intended to remain internally understandable as its own export format.
|
||||||
|
`
|
||||||
|
|
||||||
|
export const buildFullDarwinCoreExportZipBufferFromArchives = async ({
|
||||||
|
dwcDataPackageZipBuffer,
|
||||||
|
dwcTaxonArchiveZipBuffer,
|
||||||
|
}: {
|
||||||
|
dwcDataPackageZipBuffer: Buffer
|
||||||
|
dwcTaxonArchiveZipBuffer: Buffer
|
||||||
|
}): Promise<Buffer> => {
|
||||||
|
const zip = new JSZip()
|
||||||
|
zip.file('README.txt', buildFullDarwinCoreReadme())
|
||||||
|
await addZipEntriesUnderPrefix({
|
||||||
|
sourceZipBuffer: dwcDataPackageZipBuffer,
|
||||||
|
targetZip: zip,
|
||||||
|
prefix: 'dwc-dp',
|
||||||
|
})
|
||||||
|
await addZipEntriesUnderPrefix({
|
||||||
|
sourceZipBuffer: dwcTaxonArchiveZipBuffer,
|
||||||
|
targetZip: zip,
|
||||||
|
prefix: 'dwc-a-taxa',
|
||||||
|
})
|
||||||
|
|
||||||
|
return await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 6 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildFullDarwinCoreExportZipBuffer = async (): Promise<Buffer> => {
|
||||||
|
const [dwcDataPackageZipBuffer, dwcTaxonArchiveZipBuffer] = await Promise.all([
|
||||||
|
buildDwcDataPackageZipBuffer(),
|
||||||
|
buildDwcArchiveZipBuffer(),
|
||||||
|
])
|
||||||
|
|
||||||
|
return await buildFullDarwinCoreExportZipBufferFromArchives({
|
||||||
|
dwcDataPackageZipBuffer,
|
||||||
|
dwcTaxonArchiveZipBuffer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export type DwcDpLocalityFixture = LocalityForDwcDpExport
|
export type DwcDpLocalityFixture = LocalityForDwcDpExport
|
||||||
export type DwcDpOccurrenceFixture = OccurrenceForDwcDpExport
|
export type DwcDpOccurrenceFixture = OccurrenceForDwcDpExport
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createWriteStream } from 'fs'
|
||||||
|
import { once } from 'events'
|
||||||
|
|
||||||
|
export const normalizeDwcCsvValue = (value: string): string => value.replace(/\r\n|\r|\n/g, ' ').replace(/[ \t]+/g, ' ')
|
||||||
|
|
||||||
|
export const toDwcCsvString = (value: unknown): string => {
|
||||||
|
if (value === null || value === undefined) return ''
|
||||||
|
if (typeof value === 'bigint') return value.toString()
|
||||||
|
if (typeof value === 'number') return Number.isFinite(value) ? value.toString() : ''
|
||||||
|
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||||
|
if (typeof value === 'string') return normalizeDwcCsvValue(value)
|
||||||
|
if (value instanceof Date) return value.toISOString()
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (typeof (value as { toString?: unknown }).toString === 'function') {
|
||||||
|
const asString = (value as { toString: () => string }).toString()
|
||||||
|
if (asString && asString !== '[object Object]') return normalizeDwcCsvValue(asString)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return normalizeDwcCsvValue(JSON.stringify(value) ?? '')
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dwcCsvCell = (value: unknown): string => `"${toDwcCsvString(value).replace(/"/g, '""')}"`
|
||||||
|
|
||||||
|
export const dwcCsvLine = (headers: readonly string[], row: Record<string, unknown>): string =>
|
||||||
|
`${headers.map(header => dwcCsvCell(row[header])).join(',')}\n`
|
||||||
|
|
||||||
|
export const writeDwcCsvString = (headers: readonly string[], rows: Array<Record<string, unknown>>): string => {
|
||||||
|
const lines = [`${headers.map(dwcCsvCell).join(',')}\n`]
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(dwcCsvLine(headers, row))
|
||||||
|
}
|
||||||
|
return lines.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createDwcCsvFileWriter = async (filePath: string, headers: readonly string[]) => {
|
||||||
|
const stream = createWriteStream(filePath, { encoding: 'utf8' })
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stream.once('open', () => resolve())
|
||||||
|
stream.once('error', reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = async (line: string): Promise<void> => {
|
||||||
|
if (!stream.write(line)) await once(stream, 'drain')
|
||||||
|
}
|
||||||
|
|
||||||
|
await write(`${headers.map(dwcCsvCell).join(',')}\n`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
writeRow: async (row: Record<string, unknown>): Promise<void> => {
|
||||||
|
await write(dwcCsvLine(headers, row))
|
||||||
|
},
|
||||||
|
close: async (): Promise<void> => {
|
||||||
|
stream.end()
|
||||||
|
await once(stream, 'finish')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, expect, it } from '@jest/globals'
|
||||||
|
import { dwcCsvCell, normalizeDwcCsvValue, writeDwcCsvString } from '../services/utils/dwcCsv'
|
||||||
|
|
||||||
|
describe('DwC CSV writer', () => {
|
||||||
|
it('normalizes embedded line feeds before quoting cells', () => {
|
||||||
|
expect(normalizeDwcCsvValue('first\nsecond\r\nthird\rfourth')).toEqual('first second third fourth')
|
||||||
|
expect(dwcCsvCell('first\n"second"')).toEqual('"first ""second"""')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes one physical line per row even when source values contain line breaks', () => {
|
||||||
|
const csv = writeDwcCsvString(
|
||||||
|
['id', 'remarks'],
|
||||||
|
[
|
||||||
|
{ id: 'one', remarks: 'alpha\nbeta' },
|
||||||
|
{ id: 'two', remarks: 'gamma\r\ndelta' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(csv).toEqual('"id","remarks"\n"one","alpha beta"\n"two","gamma delta"\n')
|
||||||
|
expect(csv.split('\n')).toHaveLength(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from '@jest/globals'
|
|||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import {
|
import {
|
||||||
DWC_DP_TABLES,
|
DWC_DP_TABLES,
|
||||||
|
buildFullDarwinCoreExportZipBufferFromArchives,
|
||||||
buildDwcDataPackageZipBufferFromRows,
|
buildDwcDataPackageZipBufferFromRows,
|
||||||
mapLocalityToDwcDpEventAssertionRows,
|
mapLocalityToDwcDpEventAssertionRows,
|
||||||
mapLocalityToDwcDpEventRow,
|
mapLocalityToDwcDpEventRow,
|
||||||
@@ -161,8 +162,12 @@ describe('DwC-DP export mapping', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('generates a ZIP archive with DwC-DP files and relational metadata', async () => {
|
it('generates a ZIP archive with DwC-DP files and relational metadata', async () => {
|
||||||
|
const multilineLocality = {
|
||||||
|
...locality,
|
||||||
|
loc_detail: 'Some notes\nwith a line feed',
|
||||||
|
} as DwcDpLocalityFixture
|
||||||
const zipBuffer = await buildDwcDataPackageZipBufferFromRows({
|
const zipBuffer = await buildDwcDataPackageZipBufferFromRows({
|
||||||
localities: [locality],
|
localities: [multilineLocality],
|
||||||
occurrences: [occurrence],
|
occurrences: [occurrence],
|
||||||
publicationDateIso: '2026-05-13',
|
publicationDateIso: '2026-05-13',
|
||||||
})
|
})
|
||||||
@@ -176,6 +181,10 @@ describe('DwC-DP export mapping', () => {
|
|||||||
expect(zip.file(DWC_DP_TABLES.dataPackage)).toBeTruthy()
|
expect(zip.file(DWC_DP_TABLES.dataPackage)).toBeTruthy()
|
||||||
expect(zip.file(DWC_DP_TABLES.eml)).toBeTruthy()
|
expect(zip.file(DWC_DP_TABLES.eml)).toBeTruthy()
|
||||||
|
|
||||||
|
const eventCsv = await zip.file(DWC_DP_TABLES.event)!.async('string')
|
||||||
|
expect(eventCsv).toContain('Some notes with a line feed')
|
||||||
|
expect(eventCsv.trimEnd().split('\n')).toHaveLength(2)
|
||||||
|
|
||||||
const dataPackageJson = JSON.parse(await zip.file(DWC_DP_TABLES.dataPackage)!.async('string')) as {
|
const dataPackageJson = JSON.parse(await zip.file(DWC_DP_TABLES.dataPackage)!.async('string')) as {
|
||||||
resources: Array<{ name: string; schema: { foreignKeys?: unknown[] } }>
|
resources: Array<{ name: string; schema: { foreignKeys?: unknown[] } }>
|
||||||
}
|
}
|
||||||
@@ -193,4 +202,34 @@ describe('DwC-DP export mapping', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('combines DwC-DP and DwC-A taxon archives into a namespaced convenience bundle', async () => {
|
||||||
|
const dwcDataPackageZip = new JSZip()
|
||||||
|
dwcDataPackageZip.file('datapackage.json', '{}\n')
|
||||||
|
dwcDataPackageZip.file('event.csv', '"eventID"\n')
|
||||||
|
dwcDataPackageZip.file('occurrence.csv', '"occurrenceID","taxonID"\n')
|
||||||
|
|
||||||
|
const dwcTaxonArchiveZip = new JSZip()
|
||||||
|
dwcTaxonArchiveZip.file('taxon.csv', '"taxonID"\n')
|
||||||
|
dwcTaxonArchiveZip.file('measurementorfact.csv', '"taxonID","measurementID"\n')
|
||||||
|
dwcTaxonArchiveZip.file('meta.xml', '<archive />\n')
|
||||||
|
|
||||||
|
const fullZipBuffer = await buildFullDarwinCoreExportZipBufferFromArchives({
|
||||||
|
dwcDataPackageZipBuffer: await dwcDataPackageZip.generateAsync({ type: 'nodebuffer' }),
|
||||||
|
dwcTaxonArchiveZipBuffer: await dwcTaxonArchiveZip.generateAsync({ type: 'nodebuffer' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const fullZip = await JSZip.loadAsync(fullZipBuffer)
|
||||||
|
expect(fullZip.file('README.txt')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-dp/datapackage.json')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-dp/event.csv')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-dp/occurrence.csv')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-a-taxa/taxon.csv')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-a-taxa/measurementorfact.csv')).toBeTruthy()
|
||||||
|
expect(fullZip.file('dwc-a-taxa/meta.xml')).toBeTruthy()
|
||||||
|
|
||||||
|
const readme = await fullZip.file('README.txt')!.async('string')
|
||||||
|
expect(readme).toContain('dwc-dp/occurrence.csv taxonID')
|
||||||
|
expect(readme).toContain('dwc-a-taxa/taxon.csv taxonID')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ DwC-DP is intentionally published as a separate format from the existing DwC-A e
|
|||||||
## Access
|
## Access
|
||||||
|
|
||||||
- Backend route: `GET /occurrence/export/dwc-data-package` (**Role.Admin only**)
|
- Backend route: `GET /occurrence/export/dwc-data-package` (**Role.Admin only**)
|
||||||
- The frontend exposes this as an export option on the cross-search occurrence table for administrators.
|
- Backend route: `GET /occurrence/export/dwc-full-package` (**Role.Admin only**)
|
||||||
|
- The frontend exposes both as export options on the cross-search occurrence table for administrators.
|
||||||
|
|
||||||
## Output
|
## Output
|
||||||
|
|
||||||
@@ -23,6 +24,30 @@ The downloaded ZIP contains:
|
|||||||
- `occurrence-assertion.csv` (occurrence-level facts formerly expressed as occurrence `MeasurementOrFact`)
|
- `occurrence-assertion.csv` (occurrence-level facts formerly expressed as occurrence `MeasurementOrFact`)
|
||||||
- `eml.xml` (minimal placeholder EML metadata)
|
- `eml.xml` (minimal placeholder EML metadata)
|
||||||
|
|
||||||
|
## Full Darwin Core Convenience Bundle
|
||||||
|
|
||||||
|
The full export route downloads one outer ZIP for users who need both occurrence/event data and taxon traits.
|
||||||
|
|
||||||
|
The outer ZIP contains:
|
||||||
|
|
||||||
|
- `README.txt`
|
||||||
|
- `dwc-dp/` with the DwC-DP event + occurrence package described above
|
||||||
|
- `dwc-a-taxa/` with the existing DwC-A taxon trait archive:
|
||||||
|
- `taxon.csv`
|
||||||
|
- `measurementorfact.csv`
|
||||||
|
- `meta.xml`
|
||||||
|
- `eml.xml`
|
||||||
|
|
||||||
|
The outer ZIP is a NOW convenience bundle, not a single hybrid standard artifact. Each subfolder remains its own internally described export format.
|
||||||
|
|
||||||
|
For spreadsheet compatibility, embedded line breaks in exported CSV cell values are normalized to spaces. This keeps each exported record on one physical CSV line.
|
||||||
|
|
||||||
|
Join key:
|
||||||
|
|
||||||
|
- `dwc-dp/occurrence.csv` uses `taxonID = NOW:<species_id>`
|
||||||
|
- `dwc-a-taxa/taxon.csv` uses the same `taxonID`
|
||||||
|
- `dwc-a-taxa/measurementorfact.csv` links synthesized taxon traits by the same `taxonID`
|
||||||
|
|
||||||
## Model
|
## Model
|
||||||
|
|
||||||
### `event.csv`
|
### `event.csv`
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Issue: `nowcommunity/nowdatabase#1150`
|
|||||||
|
|
||||||
This repository includes an **admin-only** Darwin Core Archive (DwC-A) export intended for initial testing.
|
This repository includes an **admin-only** Darwin Core Archive (DwC-A) export intended for initial testing.
|
||||||
|
|
||||||
|
For spreadsheet compatibility, embedded line breaks in exported CSV cell values are normalized to spaces. This keeps each exported record on one physical CSV line.
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
|
|
||||||
- Backend route: `GET /species/export/dwc-archive` (**Role.Admin only**)
|
- Backend route: `GET /species/export/dwc-archive` (**Role.Admin only**)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This document describes the admin-only Darwin Core Archive (DwC-A) test export for localities.
|
This document describes the admin-only Darwin Core Archive (DwC-A) test export for localities.
|
||||||
|
|
||||||
|
For spreadsheet compatibility, embedded line breaks in exported CSV cell values are normalized to spaces. This keeps each exported record on one physical CSV line.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
The export ZIP contains:
|
The export ZIP contains:
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
This document describes the admin-only Darwin Core Archive (DwC-A) test export for occurrence records (`now_ls`).
|
This document describes the admin-only Darwin Core Archive (DwC-A) test export for occurrence records (`now_ls`).
|
||||||
|
|
||||||
|
For spreadsheet compatibility, embedded line breaks in exported CSV cell values are normalized to spaces. This keeps each exported record on one physical CSV line.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
The export ZIP contains:
|
The export ZIP contains:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MenuItem } from '@mui/material'
|
|||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
import { usePageContext } from '../Page'
|
import { usePageContext } from '../Page'
|
||||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
|
|
||||||
export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -21,46 +22,21 @@ export const CrossSearchExportMenuItem = ({ handleClose }: { handleClose: () =>
|
|||||||
const URIColumnFilters = encodeURIComponent(JSON.stringify(sqlColumnFilters))
|
const URIColumnFilters = encodeURIComponent(JSON.stringify(sqlColumnFilters))
|
||||||
const URIOrderBy = encodeURIComponent(JSON.stringify(sqlOrderBy))
|
const URIOrderBy = encodeURIComponent(JSON.stringify(sqlOrderBy))
|
||||||
|
|
||||||
notify('Generating CSV file, please wait...', 'info', null)
|
|
||||||
const response = await fetch(`${BACKEND_URL}/crosssearch/export/${URIColumnFilters}/${URIOrderBy}`, fetchOptions)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Server response was not OK.')
|
|
||||||
}
|
|
||||||
const reader = response.body!.getReader()
|
|
||||||
const file: Uint8Array[] = []
|
|
||||||
let bytes = 0
|
|
||||||
let closed = false
|
|
||||||
|
|
||||||
const showDownloadProgress = () => {
|
|
||||||
if (!closed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationMessage(`Downloading CSV file, ${Math.round((bytes / 1000000) * 10) / 10} MB`)
|
|
||||||
showDownloadProgress()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notify(`Downloading CSV file...`, 'info', null)
|
|
||||||
showDownloadProgress()
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
bytes = bytes + value.length
|
|
||||||
file.push(value)
|
|
||||||
}
|
|
||||||
closed = true
|
|
||||||
try {
|
try {
|
||||||
const blobUrl = window.URL.createObjectURL(new Blob(file))
|
await downloadExportFileWithProgress({
|
||||||
const downloadLink = document.createElement('a')
|
url: `${BACKEND_URL}/crosssearch/export/${URIColumnFilters}/${URIOrderBy}`,
|
||||||
downloadLink.href = blobUrl
|
filename,
|
||||||
downloadLink.download = filename
|
fetchOptions,
|
||||||
document.body.appendChild(downloadLink)
|
notify,
|
||||||
downloadLink.click()
|
setNotificationMessage,
|
||||||
downloadLink.remove()
|
startMessage: 'Generating cross-search CSV export...',
|
||||||
window.URL.revokeObjectURL(blobUrl)
|
waitingMessage: 'Waiting for cross-search rows',
|
||||||
notify('Download finished.')
|
downloadMessage: 'Downloading CSV file',
|
||||||
|
failureMessage: 'Downloading cross-search export failed.',
|
||||||
|
contentType: 'text/csv',
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
notify('Downloading cross-search export failed.', 'error')
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { formatWithMaxThreeDecimals } from '@/util/numberFormatting'
|
|||||||
import { occurrenceLabels } from '@/constants/occurrenceLabels'
|
import { occurrenceLabels } from '@/constants/occurrenceLabels'
|
||||||
import { OccurrenceDwcExportMenuItem } from '@/components/Occurrence/OccurrenceDwcExportMenuItem'
|
import { OccurrenceDwcExportMenuItem } from '@/components/Occurrence/OccurrenceDwcExportMenuItem'
|
||||||
import { OccurrenceDwcDpExportMenuItem } from '@/components/Occurrence/OccurrenceDwcDpExportMenuItem'
|
import { OccurrenceDwcDpExportMenuItem } from '@/components/Occurrence/OccurrenceDwcDpExportMenuItem'
|
||||||
|
import { OccurrenceFullDarwinCoreExportMenuItem } from '@/components/Occurrence/OccurrenceFullDarwinCoreExportMenuItem'
|
||||||
import { matchesCountryOrContinent } from '@/shared/validators/countryContinents'
|
import { matchesCountryOrContinent } from '@/shared/validators/countryContinents'
|
||||||
|
|
||||||
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
|
export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: CrossSearch) => void }) => {
|
||||||
@@ -1095,6 +1096,7 @@ export const CrossSearchTable = ({ selectorFn }: { selectorFn?: (newObject: Cros
|
|||||||
<>
|
<>
|
||||||
<OccurrenceDwcExportMenuItem handleClose={handleClose} />
|
<OccurrenceDwcExportMenuItem handleClose={handleClose} />
|
||||||
<OccurrenceDwcDpExportMenuItem handleClose={handleClose} />
|
<OccurrenceDwcDpExportMenuItem handleClose={handleClose} />
|
||||||
|
<OccurrenceFullDarwinCoreExportMenuItem handleClose={handleClose} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BACKEND_URL } from '@/util/config'
|
|||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
import { Role } from '@/shared/types'
|
import { Role } from '@/shared/types'
|
||||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
|
|
||||||
export const LocalityDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
export const LocalityDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -20,55 +21,22 @@ export const LocalityDwcExportMenuItem = ({ handleClose }: { handleClose: () =>
|
|||||||
|
|
||||||
const fetchZipFile = async () => {
|
const fetchZipFile = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
notify('Generating DwC-A ZIP export, please wait...', 'info', null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BACKEND_URL}/locality/export/dwc-archive`, fetchOptions)
|
await downloadExportFileWithProgress({
|
||||||
if (!response.ok) {
|
url: `${BACKEND_URL}/locality/export/dwc-archive`,
|
||||||
throw new Error('Server response was not OK.')
|
filename,
|
||||||
}
|
fetchOptions,
|
||||||
|
notify,
|
||||||
const reader = response.body?.getReader()
|
setNotificationMessage,
|
||||||
if (!reader) {
|
startMessage: 'Generating DwC-A locality ZIP export...',
|
||||||
throw new Error('Missing response stream.')
|
waitingMessage: 'Waiting for locality export rows',
|
||||||
}
|
downloadMessage: 'Downloading DwC-A locality ZIP',
|
||||||
|
failureMessage: 'Downloading DwC-A export failed.',
|
||||||
const file: Uint8Array[] = []
|
contentType: 'application/zip',
|
||||||
let bytes = 0
|
})
|
||||||
let closed = false
|
|
||||||
|
|
||||||
const showDownloadProgress = () => {
|
|
||||||
if (!closed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationMessage(`Downloading DwC-A ZIP, ${Math.round((bytes / 1000000) * 10) / 10} MB`)
|
|
||||||
showDownloadProgress()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify('Downloading DwC-A ZIP...', 'info', null)
|
|
||||||
showDownloadProgress()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
bytes = bytes + value.length
|
|
||||||
file.push(value)
|
|
||||||
}
|
|
||||||
closed = true
|
|
||||||
|
|
||||||
const blobUrl = window.URL.createObjectURL(new Blob(file, { type: 'application/zip' }))
|
|
||||||
const downloadLink = document.createElement('a')
|
|
||||||
downloadLink.href = blobUrl
|
|
||||||
downloadLink.download = filename
|
|
||||||
document.body.appendChild(downloadLink)
|
|
||||||
downloadLink.click()
|
|
||||||
downloadLink.remove()
|
|
||||||
window.URL.revokeObjectURL(blobUrl)
|
|
||||||
|
|
||||||
notify('Download finished.')
|
|
||||||
} catch {
|
} catch {
|
||||||
notify('Downloading DwC-A export failed.', 'error')
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -485,18 +485,16 @@ export const LocalityTable = ({ selectorFn }: { selectorFn?: (newObject: Localit
|
|||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
const svgExport = <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
const svgExport = async <T extends MRT_RowData>(table: MRT_TableInstance<T>) => {
|
||||||
void (async () => {
|
const rowData: Locality[] = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as Locality)
|
||||||
const rowData: Locality[] = table.getPrePaginationRowModel().rows.map(row => row.original as unknown as Locality)
|
const { generateSvg } = await import('../Map/generateSvg')
|
||||||
const { generateSvg } = await import('../Map/generateSvg')
|
const dataString = generateSvg(rowData)
|
||||||
const dataString = generateSvg(rowData)
|
const blob = new Blob([dataString], { type: 'image/svg+xml' })
|
||||||
const blob = new Blob([dataString], { type: 'image/svg+xml' })
|
const url = URL.createObjectURL(blob)
|
||||||
const url = URL.createObjectURL(blob)
|
const a = document.createElement('a')
|
||||||
const a = document.createElement('a')
|
a.href = url
|
||||||
a.href = url
|
a.download = `localities-map-${currentDateAsString()}.svg`
|
||||||
a.download = `localities-map-${currentDateAsString()}.svg`
|
a.click()
|
||||||
a.click()
|
|
||||||
})()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkRowRestriction = (row: Locality) => {
|
const checkRowRestriction = (row: Locality) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BACKEND_URL } from '@/util/config'
|
|||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
import { Role } from '@/shared/types'
|
import { Role } from '@/shared/types'
|
||||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
|
|
||||||
export const OccurrenceDwcDpExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
export const OccurrenceDwcDpExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -20,55 +21,22 @@ export const OccurrenceDwcDpExportMenuItem = ({ handleClose }: { handleClose: ()
|
|||||||
|
|
||||||
const fetchZipFile = async () => {
|
const fetchZipFile = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
notify('Generating DwC-DP ZIP export, please wait...', 'info', null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BACKEND_URL}/occurrence/export/dwc-data-package`, fetchOptions)
|
await downloadExportFileWithProgress({
|
||||||
if (!response.ok) {
|
url: `${BACKEND_URL}/occurrence/export/dwc-data-package`,
|
||||||
throw new Error('Server response was not OK.')
|
filename,
|
||||||
}
|
fetchOptions,
|
||||||
|
notify,
|
||||||
const reader = response.body?.getReader()
|
setNotificationMessage,
|
||||||
if (!reader) {
|
startMessage: 'Generating DwC-DP ZIP export...',
|
||||||
throw new Error('Missing response stream.')
|
waitingMessage: 'Waiting for DwC-DP event and occurrence rows',
|
||||||
}
|
downloadMessage: 'Downloading DwC-DP ZIP',
|
||||||
|
failureMessage: 'Downloading DwC-DP export failed.',
|
||||||
const file: Uint8Array[] = []
|
contentType: 'application/zip',
|
||||||
let bytes = 0
|
})
|
||||||
let closed = false
|
|
||||||
|
|
||||||
const showDownloadProgress = () => {
|
|
||||||
if (!closed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationMessage(`Downloading DwC-DP ZIP, ${Math.round((bytes / 1000000) * 10) / 10} MB`)
|
|
||||||
showDownloadProgress()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify('Downloading DwC-DP ZIP...', 'info', null)
|
|
||||||
showDownloadProgress()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
bytes = bytes + value.length
|
|
||||||
file.push(value)
|
|
||||||
}
|
|
||||||
closed = true
|
|
||||||
|
|
||||||
const blobUrl = window.URL.createObjectURL(new Blob(file, { type: 'application/zip' }))
|
|
||||||
const downloadLink = document.createElement('a')
|
|
||||||
downloadLink.href = blobUrl
|
|
||||||
downloadLink.download = filename
|
|
||||||
document.body.appendChild(downloadLink)
|
|
||||||
downloadLink.click()
|
|
||||||
downloadLink.remove()
|
|
||||||
window.URL.revokeObjectURL(blobUrl)
|
|
||||||
|
|
||||||
notify('Download finished.')
|
|
||||||
} catch {
|
} catch {
|
||||||
notify('Downloading DwC-DP export failed.', 'error')
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,7 @@ import { BACKEND_URL } from '@/util/config'
|
|||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
import { Role } from '@/shared/types'
|
import { Role } from '@/shared/types'
|
||||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { createExportId, downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
type OccurrenceExportProgress = {
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const createExportId = () => {
|
|
||||||
if (window.crypto?.randomUUID) return window.crypto.randomUUID()
|
|
||||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -30,85 +22,22 @@ export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () =
|
|||||||
const fetchZipFile = async () => {
|
const fetchZipFile = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const exportId = createExportId()
|
const exportId = createExportId()
|
||||||
let generationProgressTimer: number | undefined
|
|
||||||
notify('Generating DwC-A ZIP export...', 'info', null)
|
|
||||||
|
|
||||||
const updateGenerationProgress = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${BACKEND_URL}/occurrence/export/dwc-archive/progress/${exportId}`, fetchOptions)
|
|
||||||
if (!response.ok) return
|
|
||||||
|
|
||||||
const progress = (await response.json()) as OccurrenceExportProgress
|
|
||||||
setNotificationMessage(progress.message)
|
|
||||||
} catch {
|
|
||||||
// The download request owns the final success/failure notification.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopGenerationProgress = () => {
|
|
||||||
if (generationProgressTimer !== undefined) {
|
|
||||||
window.clearInterval(generationProgressTimer)
|
|
||||||
generationProgressTimer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
generationProgressTimer = window.setInterval(() => {
|
await downloadExportFileWithProgress({
|
||||||
void updateGenerationProgress()
|
url: `${BACKEND_URL}/occurrence/export/dwc-archive?${new URLSearchParams({ exportId })}`,
|
||||||
}, 1000)
|
progressUrl: `${BACKEND_URL}/occurrence/export/dwc-archive/progress/${exportId}`,
|
||||||
|
filename,
|
||||||
const response = await fetch(
|
fetchOptions,
|
||||||
`${BACKEND_URL}/occurrence/export/dwc-archive?${new URLSearchParams({ exportId })}`,
|
notify,
|
||||||
fetchOptions
|
setNotificationMessage,
|
||||||
)
|
startMessage: 'Generating DwC-A occurrence ZIP export...',
|
||||||
stopGenerationProgress()
|
waitingMessage: 'Waiting for occurrence export rows',
|
||||||
|
downloadMessage: 'Downloading DwC-A occurrence ZIP',
|
||||||
if (!response.ok) {
|
failureMessage: 'Downloading DwC-A export failed.',
|
||||||
throw new Error('Server response was not OK.')
|
contentType: 'application/zip',
|
||||||
}
|
})
|
||||||
|
|
||||||
const reader = response.body?.getReader()
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error('Missing response stream.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const file: Uint8Array[] = []
|
|
||||||
let bytes = 0
|
|
||||||
let closed = false
|
|
||||||
|
|
||||||
const showDownloadProgress = () => {
|
|
||||||
if (!closed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationMessage(`Downloading DwC-A ZIP, ${Math.round((bytes / 1000000) * 10) / 10} MB`)
|
|
||||||
showDownloadProgress()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify('Downloading DwC-A ZIP...', 'info', null)
|
|
||||||
showDownloadProgress()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
bytes = bytes + value.length
|
|
||||||
file.push(value)
|
|
||||||
}
|
|
||||||
closed = true
|
|
||||||
|
|
||||||
const blobUrl = window.URL.createObjectURL(new Blob(file, { type: 'application/zip' }))
|
|
||||||
const downloadLink = document.createElement('a')
|
|
||||||
downloadLink.href = blobUrl
|
|
||||||
downloadLink.download = filename
|
|
||||||
document.body.appendChild(downloadLink)
|
|
||||||
downloadLink.click()
|
|
||||||
downloadLink.remove()
|
|
||||||
window.URL.revokeObjectURL(blobUrl)
|
|
||||||
|
|
||||||
notify('Download finished.')
|
|
||||||
} catch {
|
} catch {
|
||||||
stopGenerationProgress()
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
notify('Downloading DwC-A export failed.', 'error')
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { MenuItem } from '@mui/material'
|
||||||
|
import { useNotify } from '@/hooks/notification'
|
||||||
|
import { BACKEND_URL } from '@/util/config'
|
||||||
|
import { useUser } from '@/hooks/user'
|
||||||
|
import { Role } from '@/shared/types'
|
||||||
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
|
|
||||||
|
export const OccurrenceFullDarwinCoreExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const { notify, setMessage: setNotificationMessage } = useNotify()
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
if (user.role !== Role.Admin) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOptions = user.token ? { headers: { Authorization: `Bearer ${user.token}` } } : {}
|
||||||
|
const filename = `now_dwc_full_test_export_${currentDateAsString()}.zip`
|
||||||
|
|
||||||
|
const fetchZipFile = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadExportFileWithProgress({
|
||||||
|
url: `${BACKEND_URL}/occurrence/export/dwc-full-package`,
|
||||||
|
filename,
|
||||||
|
fetchOptions,
|
||||||
|
notify,
|
||||||
|
setNotificationMessage,
|
||||||
|
startMessage: 'Generating full Darwin Core ZIP export...',
|
||||||
|
waitingMessage: 'Waiting for DwC-DP and taxon trait archive files',
|
||||||
|
downloadMessage: 'Downloading full Darwin Core ZIP',
|
||||||
|
failureMessage: 'Downloading full Darwin Core export failed.',
|
||||||
|
contentType: 'application/zip',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
void fetchZipFile()
|
||||||
|
handleClose()
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Export full Darwin Core package
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { BACKEND_URL } from '@/util/config'
|
|||||||
import { useUser } from '@/hooks/user'
|
import { useUser } from '@/hooks/user'
|
||||||
import { Role } from '@/shared/types'
|
import { Role } from '@/shared/types'
|
||||||
import { currentDateAsString } from '@/shared/currentDateAsString'
|
import { currentDateAsString } from '@/shared/currentDateAsString'
|
||||||
|
import { downloadExportFileWithProgress } from '@/util/exportProgress'
|
||||||
|
|
||||||
export const SpeciesDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
export const SpeciesDwcExportMenuItem = ({ handleClose }: { handleClose: () => void }) => {
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -20,55 +21,22 @@ export const SpeciesDwcExportMenuItem = ({ handleClose }: { handleClose: () => v
|
|||||||
|
|
||||||
const fetchZipFile = async () => {
|
const fetchZipFile = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
notify('Generating DwC-A ZIP export, please wait...', 'info', null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BACKEND_URL}/species/export/dwc-archive`, fetchOptions)
|
await downloadExportFileWithProgress({
|
||||||
if (!response.ok) {
|
url: `${BACKEND_URL}/species/export/dwc-archive`,
|
||||||
throw new Error('Server response was not OK.')
|
filename,
|
||||||
}
|
fetchOptions,
|
||||||
|
notify,
|
||||||
const reader = response.body?.getReader()
|
setNotificationMessage,
|
||||||
if (!reader) {
|
startMessage: 'Generating DwC-A taxon ZIP export...',
|
||||||
throw new Error('Missing response stream.')
|
waitingMessage: 'Waiting for taxon export rows',
|
||||||
}
|
downloadMessage: 'Downloading DwC-A taxon ZIP',
|
||||||
|
failureMessage: 'Downloading DwC-A export failed.',
|
||||||
const file: Uint8Array[] = []
|
contentType: 'application/zip',
|
||||||
let bytes = 0
|
})
|
||||||
let closed = false
|
|
||||||
|
|
||||||
const showDownloadProgress = () => {
|
|
||||||
if (!closed) {
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotificationMessage(`Downloading DwC-A ZIP, ${Math.round((bytes / 1000000) * 10) / 10} MB`)
|
|
||||||
showDownloadProgress()
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify('Downloading DwC-A ZIP...', 'info', null)
|
|
||||||
showDownloadProgress()
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
if (done) break
|
|
||||||
bytes = bytes + value.length
|
|
||||||
file.push(value)
|
|
||||||
}
|
|
||||||
closed = true
|
|
||||||
|
|
||||||
const blobUrl = window.URL.createObjectURL(new Blob(file, { type: 'application/zip' }))
|
|
||||||
const downloadLink = document.createElement('a')
|
|
||||||
downloadLink.href = blobUrl
|
|
||||||
downloadLink.download = filename
|
|
||||||
document.body.appendChild(downloadLink)
|
|
||||||
downloadLink.click()
|
|
||||||
downloadLink.remove()
|
|
||||||
window.URL.revokeObjectURL(blobUrl)
|
|
||||||
|
|
||||||
notify('Download finished.')
|
|
||||||
} catch {
|
} catch {
|
||||||
notify('Downloading DwC-A export failed.', 'error')
|
// downloadExportFileWithProgress owns the failure notification.
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Link, useLocation } from 'react-router-dom'
|
|||||||
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
import AddCircleIcon from '@mui/icons-material/AddCircle'
|
||||||
import { CrossSearchExportMenuItem } from '../CrossSearch/CrossSearchExportMenuItem'
|
import { CrossSearchExportMenuItem } from '../CrossSearch/CrossSearchExportMenuItem'
|
||||||
import { usePageContext } from '../Page'
|
import { usePageContext } from '../Page'
|
||||||
|
import { useNotify } from '@/hooks/notification'
|
||||||
|
|
||||||
export type ColumnVisibilityGroup = {
|
export type ColumnVisibilityGroup = {
|
||||||
id: string
|
id: string
|
||||||
@@ -42,8 +43,8 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
}: {
|
}: {
|
||||||
table: MRT_TableInstance<T>
|
table: MRT_TableInstance<T>
|
||||||
tableName: string
|
tableName: string
|
||||||
kmlExport?: (table: MRT_TableInstance<T>) => void
|
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
svgExport?: (table: MRT_TableInstance<T>) => void
|
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
isCrossSearchTable?: boolean
|
isCrossSearchTable?: boolean
|
||||||
selectorFn?: (id: T) => void
|
selectorFn?: (id: T) => void
|
||||||
showNewButton?: boolean
|
showNewButton?: boolean
|
||||||
@@ -52,6 +53,7 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
renderExtraExportMenuItems?: (handleClose: () => void) => ReactNode
|
renderExtraExportMenuItems?: (handleClose: () => void) => ReactNode
|
||||||
}) => {
|
}) => {
|
||||||
const { previousTableUrls, setPreviousTableUrls } = usePageContext<T>()
|
const { previousTableUrls, setPreviousTableUrls } = usePageContext<T>()
|
||||||
|
const { notify, setMessage: setNotificationMessage } = useNotify()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
|
||||||
const open = Boolean(anchorEl)
|
const open = Boolean(anchorEl)
|
||||||
@@ -183,6 +185,22 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
column.toggleVisibility?.(!column.getIsVisible?.())
|
column.toggleVisibility?.(!column.getIsVisible?.())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const exportVisibleTableRows = () => {
|
||||||
|
notify(`Preparing ${tableName} table CSV export...`, 'info', null)
|
||||||
|
const rowCount = exportRows(table, tableName)
|
||||||
|
setNotificationMessage(`Generated ${tableName} table CSV with ${rowCount} rows. Saving file...`)
|
||||||
|
notify('Download finished.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const runMapExport = async (label: string, exportFn: (table: MRT_TableInstance<T>) => void | Promise<void>) => {
|
||||||
|
notify(`Preparing ${label} export...`, 'info', null)
|
||||||
|
const rowCount = table.getPrePaginationRowModel().rows.length
|
||||||
|
setNotificationMessage(`Generating ${label} export from ${rowCount} rows...`)
|
||||||
|
await exportFn(table)
|
||||||
|
setNotificationMessage(`Saving ${label} export...`)
|
||||||
|
notify('Download finished.')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="table-tool-bar">
|
<div className="table-tool-bar">
|
||||||
{!selectorFn && !hideLeftButtons && (
|
{!selectorFn && !hideLeftButtons && (
|
||||||
@@ -291,7 +309,7 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
) : (
|
) : (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
exportRows(table, tableName)
|
exportVisibleTableRows()
|
||||||
handleClose()
|
handleClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -304,7 +322,7 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
{kmlExport && (
|
{kmlExport && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
kmlExport(table)
|
void runMapExport('KML', kmlExport)
|
||||||
handleClose()
|
handleClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -315,7 +333,7 @@ export const TableToolBar = <T extends MRT_RowData>({
|
|||||||
{svgExport && (
|
{svgExport && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
svgExport(table)
|
void runMapExport('SVG map', svgExport)
|
||||||
handleClose()
|
handleClose()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ export const TableView = <T extends MRT_RowData>({
|
|||||||
getDetailPath?: (row: T) => string
|
getDetailPath?: (row: T) => string
|
||||||
url?: string
|
url?: string
|
||||||
title: string
|
title: string
|
||||||
kmlExport?: (table: MRT_TableInstance<T>) => void
|
kmlExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
svgExport?: (table: MRT_TableInstance<T>) => void
|
svgExport?: (table: MRT_TableInstance<T>) => void | Promise<void>
|
||||||
isCrossSearchTable?: boolean
|
isCrossSearchTable?: boolean
|
||||||
clickableRows?: boolean
|
clickableRows?: boolean
|
||||||
enableColumnFilterModes?: boolean
|
enableColumnFilterModes?: boolean
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ export const exportRows = <T extends MRT_RowData>(table: MRT_TableInstance<T>, t
|
|||||||
})
|
})
|
||||||
const csv = generateCsv(csvConfig)(rowData)
|
const csv = generateCsv(csvConfig)(rowData)
|
||||||
download(csvConfig)(csv)
|
download(csvConfig)(csv)
|
||||||
|
return rowData.length
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Severity } from '@/components/Notification'
|
||||||
|
|
||||||
|
type Notify = (msg: string, severity?: Severity, timeoutValue?: number | null) => void
|
||||||
|
type ExportProgress = {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadExportParams = {
|
||||||
|
url: string
|
||||||
|
filename: string
|
||||||
|
fetchOptions?: RequestInit
|
||||||
|
notify: Notify
|
||||||
|
setNotificationMessage: (message: string) => void
|
||||||
|
startMessage: string
|
||||||
|
waitingMessage: string
|
||||||
|
downloadMessage: string
|
||||||
|
failureMessage: string
|
||||||
|
contentType?: string
|
||||||
|
progressUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createExportId = () => {
|
||||||
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID()
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMb = (bytes: number) => Math.round((bytes / 1000000) * 10) / 10
|
||||||
|
|
||||||
|
export const downloadExportFileWithProgress = async ({
|
||||||
|
url,
|
||||||
|
filename,
|
||||||
|
fetchOptions = {},
|
||||||
|
notify,
|
||||||
|
setNotificationMessage,
|
||||||
|
startMessage,
|
||||||
|
waitingMessage,
|
||||||
|
downloadMessage,
|
||||||
|
failureMessage,
|
||||||
|
contentType,
|
||||||
|
progressUrl,
|
||||||
|
}: DownloadExportParams) => {
|
||||||
|
let progressTimer: number | undefined
|
||||||
|
let waitingSeconds = 0
|
||||||
|
|
||||||
|
const stopProgress = () => {
|
||||||
|
if (progressTimer !== undefined) {
|
||||||
|
window.clearInterval(progressTimer)
|
||||||
|
progressTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateServerProgress = async () => {
|
||||||
|
if (!progressUrl) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(progressUrl, fetchOptions)
|
||||||
|
if (!response.ok) return false
|
||||||
|
|
||||||
|
const progress = (await response.json()) as ExportProgress
|
||||||
|
setNotificationMessage(progress.message)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(startMessage, 'info', null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
progressTimer = window.setInterval(() => {
|
||||||
|
waitingSeconds += 1
|
||||||
|
void (async () => {
|
||||||
|
const hasServerProgress = await updateServerProgress()
|
||||||
|
if (!hasServerProgress) {
|
||||||
|
setNotificationMessage(`${waitingMessage} (${waitingSeconds}s)`)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
const response = await fetch(url, fetchOptions)
|
||||||
|
stopProgress()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Server response was not OK.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error('Missing response stream.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const file: Uint8Array[] = []
|
||||||
|
let bytes = 0
|
||||||
|
let closed = false
|
||||||
|
|
||||||
|
const showDownloadProgress = () => {
|
||||||
|
if (!closed) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotificationMessage(`${downloadMessage}, ${formatMb(bytes)} MB`)
|
||||||
|
showDownloadProgress()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(`${downloadMessage}...`, 'info', null)
|
||||||
|
showDownloadProgress()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
bytes = bytes + value.length
|
||||||
|
file.push(value)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
setNotificationMessage(`Saving ${filename} (${formatMb(bytes)} MB)`)
|
||||||
|
|
||||||
|
const blobUrl = window.URL.createObjectURL(new Blob(file, contentType ? { type: contentType } : undefined))
|
||||||
|
const downloadLink = document.createElement('a')
|
||||||
|
downloadLink.href = blobUrl
|
||||||
|
downloadLink.download = filename
|
||||||
|
document.body.appendChild(downloadLink)
|
||||||
|
downloadLink.click()
|
||||||
|
downloadLink.remove()
|
||||||
|
window.URL.revokeObjectURL(blobUrl)
|
||||||
|
|
||||||
|
notify('Download finished.')
|
||||||
|
} catch (error) {
|
||||||
|
stopProgress()
|
||||||
|
notify(failureMessage, 'error')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,19 +23,29 @@ jest.mock('../../src/components/Page', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
jest.mock('../../src/hooks/notification', () => ({
|
||||||
|
useNotify: () => ({
|
||||||
|
notify: jest.fn(),
|
||||||
|
setMessage: jest.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
jest.mock('material-react-table', () => ({
|
jest.mock('material-react-table', () => ({
|
||||||
MRT_ShowHideColumnsButton: () => <button type="button">Show/Hide</button>,
|
MRT_ShowHideColumnsButton: () => <button type="button">Show/Hide</button>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('TableToolBar export menu', () => {
|
describe('TableToolBar export menu', () => {
|
||||||
it('omits the species export option while keeping other exports available', async () => {
|
it('omits the species export option while keeping other exports available', async () => {
|
||||||
|
const kmlExport: (table: MRT_TableInstance<MRT_RowData>) => void = jest.fn(() => undefined)
|
||||||
|
const svgExport: (table: MRT_TableInstance<MRT_RowData>) => void = jest.fn(() => undefined)
|
||||||
|
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<TableToolBar
|
<TableToolBar
|
||||||
table={{} as MRT_TableInstance<MRT_RowData>}
|
table={{} as MRT_TableInstance<MRT_RowData>}
|
||||||
tableName="Localities"
|
tableName="Localities"
|
||||||
kmlExport={jest.fn()}
|
kmlExport={kmlExport}
|
||||||
svgExport={jest.fn()}
|
svgExport={svgExport}
|
||||||
/>
|
/>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user