Show DwC occurrence export progress

This commit is contained in:
karilint
2026-04-29 10:56:11 +03:00
parent e90e24d2b8
commit 3a5e68fc86
3 changed files with 170 additions and 7 deletions
+42 -3
View File
@@ -3,14 +3,44 @@ import { pipeline } from 'stream'
import { getOccurrenceDetail, updateOccurrenceDetail } from '../controllers/occurrenceController' import { getOccurrenceDetail, updateOccurrenceDetail } from '../controllers/occurrenceController'
import { requireOneOf } from '../middlewares/authorizer' import { requireOneOf } from '../middlewares/authorizer'
import { Role } from '../../../frontend/src/shared/types' import { Role } from '../../../frontend/src/shared/types'
import { buildDwcOccurrenceArchiveZipStream } from '../services/dwcArchiveExportOccurrences' import {
buildDwcOccurrenceArchiveZipStream,
type DwcOccurrenceExportProgress,
} from '../services/dwcArchiveExportOccurrences'
import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString' import { currentDateAsString } from '../../../frontend/src/shared/currentDateAsString'
import { logger } from '../utils/logger' import { logger } from '../utils/logger'
const router = Router() const router = Router()
router.get('/export/dwc-archive', requireOneOf([Role.Admin]), async (_req, res, next) => { const occurrenceExportProgress = new Map<string, DwcOccurrenceExportProgress>()
const archive = await buildDwcOccurrenceArchiveZipStream()
const scheduleProgressCleanup = (exportId: string) => {
setTimeout(
() => {
occurrenceExportProgress.delete(exportId)
},
5 * 60 * 1000
)
}
router.get('/export/dwc-archive/progress/:exportId', requireOneOf([Role.Admin]), (req, res) => {
const progress = occurrenceExportProgress.get(req.params.exportId)
if (!progress) return res.status(404).send({ message: 'Occurrence export progress not found.' })
return res.status(200).send(progress)
})
router.get('/export/dwc-archive', requireOneOf([Role.Admin]), async (req, res, next) => {
const exportId = typeof req.query.exportId === 'string' ? req.query.exportId : undefined
const reportProgress = exportId
? (progress: DwcOccurrenceExportProgress) => {
occurrenceExportProgress.set(exportId, progress)
}
: undefined
const archive = await buildDwcOccurrenceArchiveZipStream({ reportProgress }).catch(error => {
if (exportId) scheduleProgressCleanup(exportId)
throw error
})
res.setHeader('Content-Type', 'application/zip') res.setHeader('Content-Type', 'application/zip')
res.setHeader( res.setHeader(
'Content-Disposition', 'Content-Disposition',
@@ -20,6 +50,15 @@ router.get('/export/dwc-archive', requireOneOf([Role.Admin]), async (_req, res,
archive.cleanup().catch(cleanupError => { archive.cleanup().catch(cleanupError => {
logger.error(`Failed to clean up occurrence DwC export temp files: ${String(cleanupError)}`) logger.error(`Failed to clean up occurrence DwC export temp files: ${String(cleanupError)}`)
}) })
if (exportId) {
occurrenceExportProgress.set(exportId, {
stage: 'complete',
generated: 1,
total: 1,
message: 'DwC-A ZIP export ready.',
})
scheduleProgressCleanup(exportId)
}
if (error) next(error) if (error) next(error)
}) })
}) })
@@ -140,6 +140,15 @@ type DwcOccurrenceArchiveStream = {
cleanup: () => Promise<void> cleanup: () => Promise<void>
} }
export type DwcOccurrenceExportProgress = {
stage: 'occurrences' | 'localities' | 'taxa' | 'zipping' | 'complete'
generated: number
total: number | null
message: string
}
type DwcOccurrenceExportProgressReporter = (progress: DwcOccurrenceExportProgress) => void
const scientificNameForOccurrence = (species: SpeciesForOccurrenceExport): string => { const scientificNameForOccurrence = (species: SpeciesForOccurrenceExport): string => {
const nameParts = [ const nameParts = [
toMaybeMeaningful(species.genus_name), toMaybeMeaningful(species.genus_name),
@@ -475,6 +484,11 @@ async function* iterateOccurrenceRows(): AsyncGenerator<OccurrenceForExport> {
} }
} }
const countOccurrenceRows = async (): Promise<number> => {
const { nowDb } = await import('../utils/db')
return await nowDb.now_ls.count()
}
const chunk = <T>(values: T[], size: number): T[][] => { const chunk = <T>(values: T[], size: number): T[][] => {
const chunks: T[][] = [] const chunks: T[][] = []
for (let index = 0; index < values.length; index += size) { for (let index = 0; index < values.length; index += size) {
@@ -615,14 +629,25 @@ export const buildDwcOccurrenceArchiveZipBufferFromOccurrences = async (
const writeOccurrenceAndMeasurementFiles = async ({ const writeOccurrenceAndMeasurementFiles = async ({
occurrenceFilePath, occurrenceFilePath,
measurementFilePath, measurementFilePath,
reportProgress,
}: { }: {
occurrenceFilePath: string occurrenceFilePath: string
measurementFilePath: string measurementFilePath: string
reportProgress?: DwcOccurrenceExportProgressReporter
}): Promise<{ localityIds: number[]; speciesIds: number[] }> => { }): Promise<{ localityIds: number[]; speciesIds: number[] }> => {
const occurrenceWriter = await createCsvFileWriter(occurrenceFilePath, OCCURRENCE_HEADERS) const occurrenceWriter = await createCsvFileWriter(occurrenceFilePath, OCCURRENCE_HEADERS)
const measurementWriter = await createCsvFileWriter(measurementFilePath, MEASUREMENT_HEADERS) const measurementWriter = await createCsvFileWriter(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()
let generatedOccurrences = 0
reportProgress?.({
stage: 'occurrences',
generated: generatedOccurrences,
total: totalOccurrences,
message: `Generating occurrence rows: ${generatedOccurrences}/${totalOccurrences} generated`,
})
try { try {
for await (const occurrence of iterateOccurrenceRows()) { for await (const occurrence of iterateOccurrenceRows()) {
@@ -633,6 +658,16 @@ const writeOccurrenceAndMeasurementFiles = async ({
for (const measurementRow of mapOccurrenceToMeasurementRows(occurrence)) { for (const measurementRow of mapOccurrenceToMeasurementRows(occurrence)) {
await measurementWriter.writeRow(measurementRow) await measurementWriter.writeRow(measurementRow)
} }
generatedOccurrences += 1
if (generatedOccurrences === totalOccurrences || generatedOccurrences % OCCURRENCE_EXPORT_PAGE_SIZE === 0) {
reportProgress?.({
stage: 'occurrences',
generated: generatedOccurrences,
total: totalOccurrences,
message: `Generating occurrence rows: ${generatedOccurrences}/${totalOccurrences} generated`,
})
}
} }
} finally { } finally {
await occurrenceWriter.close() await occurrenceWriter.close()
@@ -649,14 +684,24 @@ const writeLocalityLookupFiles = async ({
localityIds, localityIds,
locationFilePath, locationFilePath,
geologicalContextFilePath, geologicalContextFilePath,
reportProgress,
}: { }: {
localityIds: number[] localityIds: number[]
locationFilePath: string locationFilePath: string
geologicalContextFilePath: string geologicalContextFilePath: string
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 createCsvFileWriter(locationFilePath, LOCATION_HEADERS)
const geologicalContextWriter = await createCsvFileWriter(geologicalContextFilePath, GEOLOGICAL_CONTEXT_HEADERS) const geologicalContextWriter = await createCsvFileWriter(geologicalContextFilePath, GEOLOGICAL_CONTEXT_HEADERS)
let generatedLocalities = 0
reportProgress?.({
stage: 'localities',
generated: generatedLocalities,
total: localityIds.length,
message: `Generating location lookup rows: ${generatedLocalities}/${localityIds.length} generated`,
})
try { try {
for (const ids of chunk(localityIds, LOOKUP_EXPORT_CHUNK_SIZE)) { for (const ids of chunk(localityIds, LOOKUP_EXPORT_CHUNK_SIZE)) {
@@ -670,7 +715,15 @@ const writeLocalityLookupFiles = async ({
const localityForExport = locality as unknown as LocalityForOccurrenceExport const localityForExport = locality as unknown as LocalityForOccurrenceExport
await locationWriter.writeRow(mapLocalityToLocationRow(localityForExport)) await locationWriter.writeRow(mapLocalityToLocationRow(localityForExport))
await geologicalContextWriter.writeRow(mapLocalityToGeologicalContextRow(localityForExport)) await geologicalContextWriter.writeRow(mapLocalityToGeologicalContextRow(localityForExport))
generatedLocalities += 1
} }
reportProgress?.({
stage: 'localities',
generated: generatedLocalities,
total: localityIds.length,
message: `Generating location lookup rows: ${generatedLocalities}/${localityIds.length} generated`,
})
} }
} finally { } finally {
await locationWriter.close() await locationWriter.close()
@@ -681,12 +734,22 @@ const writeLocalityLookupFiles = async ({
const writeTaxonLookupFile = async ({ const writeTaxonLookupFile = async ({
speciesIds, speciesIds,
taxonFilePath, taxonFilePath,
reportProgress,
}: { }: {
speciesIds: number[] speciesIds: number[]
taxonFilePath: string taxonFilePath: string
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 createCsvFileWriter(taxonFilePath, TAXON_HEADERS)
let generatedTaxa = 0
reportProgress?.({
stage: 'taxa',
generated: generatedTaxa,
total: speciesIds.length,
message: `Generating taxon lookup rows: ${generatedTaxa}/${speciesIds.length} generated`,
})
try { try {
for (const ids of chunk(speciesIds, LOOKUP_EXPORT_CHUNK_SIZE)) { for (const ids of chunk(speciesIds, LOOKUP_EXPORT_CHUNK_SIZE)) {
@@ -698,14 +761,26 @@ const writeTaxonLookupFile = async ({
for (const species of speciesRows) { for (const species of speciesRows) {
await taxonWriter.writeRow(mapSpeciesToTaxonRow(species)) await taxonWriter.writeRow(mapSpeciesToTaxonRow(species))
generatedTaxa += 1
} }
reportProgress?.({
stage: 'taxa',
generated: generatedTaxa,
total: speciesIds.length,
message: `Generating taxon lookup rows: ${generatedTaxa}/${speciesIds.length} generated`,
})
} }
} finally { } finally {
await taxonWriter.close() await taxonWriter.close()
} }
} }
export const buildDwcOccurrenceArchiveZipStream = async (): Promise<DwcOccurrenceArchiveStream> => { export const buildDwcOccurrenceArchiveZipStream = async ({
reportProgress,
}: {
reportProgress?: DwcOccurrenceExportProgressReporter
} = {}): Promise<DwcOccurrenceArchiveStream> => {
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'now-dwc-occurrences-')) const tempDirectory = await mkdtemp(path.join(tmpdir(), 'now-dwc-occurrences-'))
const files = { const files = {
location: path.join(tempDirectory, 'location.csv'), location: path.join(tempDirectory, 'location.csv'),
@@ -719,13 +794,22 @@ export const buildDwcOccurrenceArchiveZipStream = async (): Promise<DwcOccurrenc
const { localityIds, speciesIds } = await writeOccurrenceAndMeasurementFiles({ const { localityIds, speciesIds } = await writeOccurrenceAndMeasurementFiles({
occurrenceFilePath: files.occurrence, occurrenceFilePath: files.occurrence,
measurementFilePath: files.measurement, measurementFilePath: files.measurement,
reportProgress,
}) })
await writeLocalityLookupFiles({ await writeLocalityLookupFiles({
localityIds, localityIds,
locationFilePath: files.location, locationFilePath: files.location,
geologicalContextFilePath: files.geologicalContext, geologicalContextFilePath: files.geologicalContext,
reportProgress,
})
await writeTaxonLookupFile({ speciesIds, taxonFilePath: files.taxon, reportProgress })
reportProgress?.({
stage: 'zipping',
generated: 0,
total: null,
message: 'Compressing DwC-A ZIP...',
}) })
await writeTaxonLookupFile({ speciesIds, taxonFilePath: files.taxon })
const zip = new JSZip() const zip = new JSZip()
zip.file('location.csv', createReadStream(files.location)) zip.file('location.csv', createReadStream(files.location))
@@ -6,6 +6,15 @@ 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'
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)
const { notify, setMessage: setNotificationMessage } = useNotify() const { notify, setMessage: setNotificationMessage } = useNotify()
@@ -20,10 +29,40 @@ export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () =
const fetchZipFile = async () => { const fetchZipFile = async () => {
setLoading(true) setLoading(true)
notify('Generating DwC-A ZIP export, please wait...', 'info', null) 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 {
const response = await fetch(`${BACKEND_URL}/occurrence/export/dwc-archive`, fetchOptions) generationProgressTimer = window.setInterval(() => {
void updateGenerationProgress()
}, 1000)
const response = await fetch(
`${BACKEND_URL}/occurrence/export/dwc-archive?${new URLSearchParams({ exportId })}`,
fetchOptions
)
stopGenerationProgress()
if (!response.ok) { if (!response.ok) {
throw new Error('Server response was not OK.') throw new Error('Server response was not OK.')
} }
@@ -68,6 +107,7 @@ export const OccurrenceDwcExportMenuItem = ({ handleClose }: { handleClose: () =
notify('Download finished.') notify('Download finished.')
} catch { } catch {
stopGenerationProgress()
notify('Downloading DwC-A export failed.', 'error') notify('Downloading DwC-A export failed.', 'error')
} finally { } finally {
setLoading(false) setLoading(false)