diff --git a/common/model/db/tables/dataNodeDef/columnNodeDef.js b/common/model/db/tables/dataNodeDef/columnNodeDef.js index a4d8c4b6d6..e46ee7451c 100644 --- a/common/model/db/tables/dataNodeDef/columnNodeDef.js +++ b/common/model/db/tables/dataNodeDef/columnNodeDef.js @@ -9,6 +9,8 @@ import * as SQL from '../../sql' const { nodeDefType } = NodeDef const columnSuffixCodeLabel = '_label' +const columnSuffixFileUuid = '_file_uuid' +const columnSuffixFileName = '_file_name' const columnSuffixTaxonScientificName = '_scientific_name' const columnSuffixTaxonVernacularName = '_vernacular_name' @@ -21,7 +23,7 @@ const columnNamesSuffixGetterByType = { return suffixes }, [nodeDefType.taxon]: () => ['', columnSuffixTaxonScientificName, columnSuffixTaxonVernacularName], - [nodeDefType.file]: () => ['_file_uuid', '_file_name'], + [nodeDefType.file]: () => [columnSuffixFileUuid, columnSuffixFileName], } const colTypesGetterByType = { @@ -109,16 +111,24 @@ export default class ColumnNodeDef { } get codeLabelColumn() { - if (!NodeDef.isCode(this.nodeDef)) return null - return `${NodeDef.getName(this.nodeDef)}${columnSuffixCodeLabel}` + return NodeDef.isCode(this.nodeDef) ? `${NodeDef.getName(this.nodeDef)}${columnSuffixCodeLabel}` : null + } + + get fileNameColumn() { + return NodeDef.isFile(this.nodeDef) ? `${NodeDef.getName(this.nodeDef)}${columnSuffixFileName}` : null } } ColumnNodeDef.columnSuffixCodeLabel = columnSuffixCodeLabel +ColumnNodeDef.columnSuffixFileUuid = columnSuffixFileUuid +ColumnNodeDef.columnSuffixFileName = columnSuffixFileName ColumnNodeDef.columnSuffixTaxonScientificName = columnSuffixTaxonScientificName ColumnNodeDef.columnSuffixTaxonVernacularName = columnSuffixTaxonVernacularName -ColumnNodeDef.getCodeLabelColumnName = (nodeDef) => `${NodeDef.getName(nodeDef)}${columnSuffixCodeLabel}` +const getColumnNameWithSuffix = (suffix) => (nodeDef) => `${NodeDef.getName(nodeDef)}${suffix}` +ColumnNodeDef.getCodeLabelColumnName = getColumnNameWithSuffix(columnSuffixCodeLabel) +ColumnNodeDef.getFileNameColumnName = getColumnNameWithSuffix(columnSuffixFileName) +ColumnNodeDef.getFileUuidColumnName = getColumnNameWithSuffix(columnSuffixFileUuid) ColumnNodeDef.getColumnNames = getColumnNames ColumnNodeDef.getColumnName = R.pipe(ColumnNodeDef.getColumnNames, R.head) diff --git a/common/model/db/tables/dataNodeDef/dataColProps.js b/common/model/db/tables/dataNodeDef/dataColProps.js index 58c563b915..cf317deb7d 100644 --- a/common/model/db/tables/dataNodeDef/dataColProps.js +++ b/common/model/db/tables/dataNodeDef/dataColProps.js @@ -1,29 +1,21 @@ import { Objects, PointFactory, Points, Strings } from '@openforis/arena-core' import * as A from '@core/arena' +import * as DateTimeUtils from '@core/dateUtils' import * as NumberUtils from '@core/numberUtils' -import * as Survey from '@core/survey/survey' +import * as Node from '@core/record/node' +import * as NodeRefData from '@core/record/nodeRefData' import * as CategoryItem from '@core/survey/categoryItem' import * as NodeDef from '@core/survey/nodeDef' +import * as Survey from '@core/survey/survey' import * as Taxon from '@core/survey/taxon' -import * as Node from '@core/record/node' -import * as NodeRefData from '@core/record/nodeRefData' -import * as DateTimeUtils from '@core/dateUtils' -import * as NodeDefTable from '@common/surveyRdb/nodeDefTable' import ColumnNodeDef from './columnNodeDef' const { nodeDefType } = NodeDef const colValueProcessor = 'colValueProcessor' -const getValueFromItem = (nodeDefCol, columnName, item = {}, isInProps = false) => { - // Remove nodeDefName from col name - const prop = A.camelize(NodeDefTable.extractColumnName(nodeDefCol, columnName)) - - return isInProps ? NodeDef.getProp(prop)(item) : A.propOr(null, prop, item) -} - const _extractCategoryItem = ({ survey, node }) => { let item = NodeRefData.getCategoryItem(node) if (item) return item @@ -38,13 +30,6 @@ const _extractTaxon = ({ survey, node }) => { return NodeRefData.getTaxon(node) ?? Survey.getTaxonByUuid(taxonUuid)(survey) } -const nodeValuePropProcessor = - ({ nodeDefCol }) => - (node, columnName) => { - const nodeValue = Node.getValue(node) - return getValueFromItem(nodeDefCol, columnName, nodeValue) - } - /** * Convert an input value to RDB compatible output value. * The contract is such that the value output value must always be compatible with RDB. @@ -161,15 +146,24 @@ const props = { }, [nodeDefType.file]: { - [colValueProcessor]: nodeValuePropProcessor, + [colValueProcessor]: ({ nodeDefCol }) => { + const fileNameExpression = NodeDef.getFileNameExpression(nodeDefCol) + return (node, columnName) => { + if (columnName.endsWith(ColumnNodeDef.columnSuffixFileName)) { + return fileNameExpression ? Node.getFileNameCalculated(node) : Node.getFileName(node) + } + if (columnName.endsWith(ColumnNodeDef.columnSuffixFileUuid)) { + return Node.getFileUuid(node) + } + return null + } + }, }, } export const getColValueProcessor = (nodeDef) => A.propOr( - () => (node) => { - return Node.isValueBlank(node) ? null : Node.getValue(node) - }, + () => (node) => (Node.isValueBlank(node) ? null : Node.getValue(node)), colValueProcessor, props[NodeDef.getType(nodeDef)] ) diff --git a/core/i18n/resources/en/common.js b/core/i18n/resources/en/common.js index f92bc468f8..ca6ce5103f 100644 --- a/core/i18n/resources/en/common.js +++ b/core/i18n/resources/en/common.js @@ -1282,8 +1282,7 @@ E.g. this.region = region_attribute_name maxNumberDecimalDigits: 'Max number of decimal digits', }, fileProps: { - numberOfFiles: 'Go to Validations to change the Min. and Max. number of files.', - maxFileSize: 'Max. file size (Mb)', + fileNameExpression: 'File name expression', fileType: 'File type', fileTypes: { image: 'Image', @@ -1291,6 +1290,8 @@ E.g. this.region = region_attribute_name audio: 'Audio', other: 'Other', }, + maxFileSize: 'Max. file size (Mb)', + numberOfFiles: 'Go to Validations to change the Min. and Max. number of files.', }, mobileProps: { title: 'Mobile App', diff --git a/core/record/node.js b/core/record/node.js index d26306cc69..e168111547 100644 --- a/core/record/node.js +++ b/core/record/node.js @@ -246,6 +246,7 @@ export const getDateModified = R.prop(keys.dateModified) // File export const getFileName = _getValuePropRaw(valuePropsFile.fileName, '') +export const getFileNameCalculated = _getValuePropRaw(valuePropsFile.fileNameCalculated, '') export const getFileUuid = _getValuePropRaw(valuePropsFile.fileUuid) export const newNodeValueFile = ({ fileUuid, fileName }) => ({ [valuePropsFile.fileUuid]: fileUuid, diff --git a/core/record/recordFile.js b/core/record/recordFile.js index 15fa702b76..ae632e93e4 100644 --- a/core/record/recordFile.js +++ b/core/record/recordFile.js @@ -1,7 +1,9 @@ import * as R from 'ramda' -import { truncate } from '@core/stringUtils' +import { FileNames } from '@openforis/arena-core' + import * as ObjectUtils from '@core/objectUtils' +import { truncate } from '@core/stringUtils' import { uuidv4 } from '@core/uuid' import * as Node from './node' @@ -45,11 +47,9 @@ export const createFileFromNode = ({ node, size = null, content = null }) => content, }) -const getExtensionFromFileName = (fileName) => R.pipe(R.split('.'), R.tail)(fileName) - export const truncateFileName = (fileName, maxLength = 10) => { if (fileName && !R.isEmpty(fileName)) { - const extension = getExtensionFromFileName(fileName) + const extension = FileNames.getExtension(fileName) return R.pipe(R.dropLast(extension.length + 1), truncate(maxLength), (name) => `${name}.${extension}`)(fileName) } @@ -65,7 +65,7 @@ export const getSize = ObjectUtils.getProp(propKeys.size) export const getNodeUuid = ObjectUtils.getProp(propKeys.nodeUuid) export const getRecordUuid = ObjectUtils.getProp(propKeys.recordUuid) export const getContent = R.prop(keys.content) -export const getExtension = R.pipe(getName, getExtensionFromFileName) +export const getExtension = R.pipe(getName, FileNames.getExtension) // UPDATE export const assocContent = R.assoc(keys.content) diff --git a/core/survey/_surveyValidator/nodeDefValidator.js b/core/survey/_surveyValidator/nodeDefValidator.js index 7d6a413fff..f727a15fae 100644 --- a/core/survey/_surveyValidator/nodeDefValidator.js +++ b/core/survey/_surveyValidator/nodeDefValidator.js @@ -13,7 +13,7 @@ import * as NodeDefLayout from '../nodeDefLayout' import * as NodeDefExpressionsValidator from './nodeDefExpressionsValidator' import * as NodeDefValidationsValidator from './nodeDefValidationsValidator' -const { keys, propKeys } = NodeDef +const { keys, keysPropsAdvanced, propKeys } = NodeDef const keysValidationFields = { children: 'children', @@ -104,20 +104,32 @@ const validateVirtualEntityFormula = (survey, nodeDef) => : null const validateItemsFilterExpression = (survey, nodeDef) => { - if (R.isEmpty(NodeDef.getItemsFilter(nodeDef))) return null + const expression = NodeDef.getItemsFilter(nodeDef) + if (R.isEmpty(expression)) return null const { validationResult } = nodeDefExpressionValidator.validate({ survey, nodeDefCurrent: nodeDef, - expression: NodeDef.getItemsFilter(nodeDef), + expression, isContextParent: true, selfReferenceAllowed: true, itemsFilter: true, }) - if (validationResult && !validationResult.valid) { - return Validation.newInstance(false, {}, [validationResult]) - } - return null + return validationResult && !validationResult.valid ? Validation.newInstance(false, {}, [validationResult]) : null +} + +const validateFileNameExpression = (survey, nodeDef) => { + const expression = NodeDef.getFileNameExpression(nodeDef) + if (R.isEmpty(expression)) return null + + const { validationResult } = nodeDefExpressionValidator.validate({ + survey, + nodeDefCurrent: nodeDef, + expression, + isContextParent: true, + selfReferenceAllowed: false, + }) + return validationResult && !validationResult.valid ? Validation.newInstance(false, {}, [validationResult]) : null } const validateColumnWidth = @@ -192,36 +204,31 @@ const propsValidations = (survey) => ({ }) const validateAdvancedProps = async (survey, nodeDef) => { - const [ - validationDefaultValues, - validationApplicable, - validationValidations, - validationVirtualEntityFormula, - validationItemsFilter, - ] = await Promise.all([ - NodeDefExpressionsValidator.validate(survey, nodeDef, Survey.dependencyTypes.defaultValues), - NodeDefExpressionsValidator.validate(survey, nodeDef, Survey.dependencyTypes.applicable), - NodeDefValidationsValidator.validate(survey, nodeDef), - validateVirtualEntityFormula(survey, nodeDef), - validateItemsFilterExpression(survey, nodeDef), - ]) - - return Validation.newInstance( - R.all(Validation.isValid, [ - validationDefaultValues, - validationApplicable, - validationValidations, - validationVirtualEntityFormula, - validationItemsFilter, - ]), - R.reject(Validation.isValid, { - [NodeDef.keysPropsAdvanced.defaultValues]: validationDefaultValues, - [NodeDef.keysPropsAdvanced.applicable]: validationApplicable, - [NodeDef.keysPropsAdvanced.validations]: validationValidations, - [NodeDef.keysPropsAdvanced.formula]: validationVirtualEntityFormula, - [NodeDef.keysPropsAdvanced.itemsFilter]: validationItemsFilter, - }) - ) + const validatorsByProp = { + [keysPropsAdvanced.defaultValues]: NodeDefExpressionsValidator.validate( + survey, + nodeDef, + Survey.dependencyTypes.defaultValues + ), + [keysPropsAdvanced.applicable]: NodeDefExpressionsValidator.validate( + survey, + nodeDef, + Survey.dependencyTypes.applicable + ), + [keysPropsAdvanced.validations]: NodeDefValidationsValidator.validate(survey, nodeDef), + [keysPropsAdvanced.formula]: validateVirtualEntityFormula(survey, nodeDef), + [keysPropsAdvanced.itemsFilter]: validateItemsFilterExpression(survey, nodeDef), + [keysPropsAdvanced.fileNameExpression]: validateFileNameExpression(survey, nodeDef), + } + const validationResultsArray = await Promise.all(Object.values(validatorsByProp)) + const validationResultsByProp = Object.keys(validatorsByProp).reduce((acc, prop, index) => { + acc[prop] = validationResultsArray[index] + return acc + }, {}) + const valid = R.all(Validation.isValid, validationResultsArray) + const notValidValidationsByProp = R.reject(Validation.isValid, validationResultsByProp) + + return Validation.newInstance(valid, notValidValidationsByProp) } export const validateNodeDef = async (survey, nodeDef) => { diff --git a/core/survey/nodeDef.js b/core/survey/nodeDef.js index 9d746837e7..8e4bd17067 100644 --- a/core/survey/nodeDef.js +++ b/core/survey/nodeDef.js @@ -31,6 +31,7 @@ export const keys = { draftAdvanced: 'draftAdvanced', draftAdvancedApplicable: 'draftAdvancedApplicable', draftAdvancedDefaultValues: 'draftAdvancedDefaultValues', + draftAdvancedFileNameExpression: 'draftAdvancedFileNameExpression', draftAdvancedValidations: 'draftAdvancedValidations', type: 'type', deleted: 'deleted', @@ -154,6 +155,8 @@ export const keysPropsAdvanced = { // code and taxon itemsFilter: 'itemsFilter', + // file + fileNameExpression: 'fileNameExpression', } const commonAttributePropsAdvancedKeys = [ @@ -367,6 +370,8 @@ export const getAllPropsAndAllPropsDraft = export const hasAdvancedPropsDraft = (nodeDef) => R.prop(keys.draftAdvanced, nodeDef) === true export const hasAdvancedPropsApplicableDraft = (nodeDef) => R.prop(keys.draftAdvancedApplicable, nodeDef) === true export const hasAdvancedPropsDefaultValuesDraft = (nodeDef) => R.prop(keys.draftAdvancedDefaultValues, nodeDef) === true +export const hasAdvancedPropsFileNameExpressionDraft = (nodeDef) => + R.prop(keys.draftAdvancedFileNameExpression, nodeDef) === true export const hasAdvancedPropsValidationsDraft = (nodeDef) => R.prop(keys.draftAdvancedValidations, nodeDef) === true const isPropAdvanced = (key) => Object.keys(keysPropsAdvanced).includes(key) @@ -391,6 +396,7 @@ export const getAllExpressions = (nodeDef) => { return acc }, []) ArrayUtils.addIfNotEmpty(getItemsFilter(nodeDef))(expressions) + ArrayUtils.addIfNotEmpty(getFileNameExpression(nodeDef))(expressions) return expressions } @@ -398,6 +404,8 @@ export const isExcludedInClone = getPropAdvanced(keysPropsAdvanced.excludedInClo // code and taxon export const getItemsFilter = getPropAdvanced(keysPropsAdvanced.itemsFilter, '') +// file +export const getFileNameExpression = getPropAdvanced(keysPropsAdvanced.fileNameExpression, '') // Advanced props - Analysis export const getFormula = getPropAdvanced(keysPropsAdvanced.formula, []) diff --git a/core/survey/nodeValueProps.js b/core/survey/nodeValueProps.js index af64b8a8a9..6f0e3a4472 100644 --- a/core/survey/nodeValueProps.js +++ b/core/survey/nodeValueProps.js @@ -24,6 +24,7 @@ export const valuePropsDate = { export const valuePropsFile = { fileUuid: 'fileUuid', fileName: 'fileName', + fileNameCalculated: 'fileNameCalculated', fileSize: 'fileSize', } diff --git a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/nodeDefsImportJob.js b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/nodeDefsImportJob.js index 6bfbf8590e..312e4bca1b 100644 --- a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/nodeDefsImportJob.js +++ b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/nodeDefsImportJob.js @@ -381,7 +381,12 @@ export default class NodeDefsImportJob extends Job { ] } } - + if (type === NodeDef.nodeDefType.file) { + const fileNameExpression = CollectSurvey.getCollectAttribute('fileNameExpression')(collectNodeDef) + if (fileNameExpression) { + propsAdvanced[NodeDef.keysPropsAdvanced.fileNameExpression] = fileNameExpression + } + } return propsAdvanced } diff --git a/server/modules/collectImport/service/collectImport/model/collectSurvey.js b/server/modules/collectImport/service/collectImport/model/collectSurvey.js index d04df2cb67..bac40d7050 100644 --- a/server/modules/collectImport/service/collectImport/model/collectSurvey.js +++ b/server/modules/collectImport/service/collectImport/model/collectSurvey.js @@ -142,7 +142,8 @@ export const getUiAttribute = export const getCollectAttribute = (name, defaultValue = null) => (collectXmlElement) => { - const value = getAttribute(`collect:${name}`, defaultValue)(collectXmlElement) + const value = + getAttribute(`collect:${name}`)(collectXmlElement) ?? getAttribute(`n0:${name}`, defaultValue)(collectXmlElement) return _transformValue(value) } diff --git a/server/modules/dataExport/service/DataExportJob/jobs/CSVDataExtractionJob.js b/server/modules/dataExport/service/DataExportJob/jobs/CSVDataExtractionJob.js index 8470dd996d..7f4b0a7230 100644 --- a/server/modules/dataExport/service/DataExportJob/jobs/CSVDataExtractionJob.js +++ b/server/modules/dataExport/service/DataExportJob/jobs/CSVDataExtractionJob.js @@ -10,7 +10,7 @@ export default class CSVDataExtractionJob extends Job { async execute() { const { survey, cycle, recordUuids, search, options, outputDir } = this.context - await SurveyRdbService.fetchEntitiesDataToCsvFiles( + const { fileNamesByFileUuid } = await SurveyRdbService.fetchEntitiesDataToCsvFiles( { user: this.user, survey, @@ -26,5 +26,6 @@ export default class CSVDataExtractionJob extends Job { }, this.tx ) + this.setContext({ fileNamesByFileUuid }) } } diff --git a/server/modules/dataExport/service/DataExportJob/jobs/FilesExportJob.js b/server/modules/dataExport/service/DataExportJob/jobs/FilesExportJob.js index de36390ee4..f08582d244 100644 --- a/server/modules/dataExport/service/DataExportJob/jobs/FilesExportJob.js +++ b/server/modules/dataExport/service/DataExportJob/jobs/FilesExportJob.js @@ -1,13 +1,12 @@ -import * as PromiseUtils from '@core/promiseUtils' import * as Survey from '@core/survey/survey' -import * as RecordFile from '@core/record/recordFile' import Job from '@server/job/job' import * as FileUtils from '@server/utils/file/fileUtils' import * as SurveyRdbService from '@server/modules/surveyRdb/service/surveyRdbService' import * as FileService from '@server/modules/record/service/fileService' -import { Objects } from '@openforis/arena-core' + +const filesOutputDir = 'files' export default class FilesExportJob extends Job { constructor(params) { @@ -15,7 +14,7 @@ export default class FilesExportJob extends Job { } async execute() { - const { survey, cycle, includeDataFromAllCycles, outputDir, recordUuids } = this.context + const { survey, cycle, includeDataFromAllCycles, recordUuids } = this.context const { fileUuidsByCycle, total } = await SurveyRdbService.fetchEntitiesFileUuidsByCycle( { @@ -30,16 +29,16 @@ export default class FilesExportJob extends Job { this.total = total // write the files in subfolders by cycle - await PromiseUtils.each(Object.entries(fileUuidsByCycle), async ([cycle, fileUuids]) => { - await PromiseUtils.each(fileUuids, async (fileUuid) => { - await this.writeFile({ fileUuid, outputDir, cycle }) + for await (const [cycle, fileUuids] of Object.entries(fileUuidsByCycle)) { + for await (const fileUuid of fileUuids) { + await this.writeFile({ fileUuid, cycle }) this.incrementProcessedItems() - }) - }) + } + } } - async writeFile({ fileUuid, outputDir, cycle }) { - const { survey } = this.context + async writeFile({ fileUuid, cycle }) { + const { survey, outputDir, fileNamesByFileUuid } = this.context const surveyId = Survey.getId(survey) const fileSummary = await FileService.fetchFileSummaryByUuid(surveyId, fileUuid, this.tx) @@ -52,13 +51,12 @@ export default class FilesExportJob extends Job { this.logWarn(`File content for file with UUID ${fileUuid} not found`) return false } - const cycleFilesPath = FileUtils.join(outputDir, 'files', cycle) - if (!FileUtils.exists(cycleFilesPath)) { - await FileUtils.mkdir(cycleFilesPath) + const cycleFilesOutputDirPath = FileUtils.join(outputDir, filesOutputDir, cycle) + if (!FileUtils.exists(cycleFilesOutputDirPath)) { + await FileUtils.mkdir(cycleFilesOutputDirPath) } - const extension = RecordFile.getExtension(fileSummary) - const exportedFileName = Objects.isEmpty(extension) ? fileUuid : `${fileUuid}.${extension}` - const tempFilePath = FileUtils.join(cycleFilesPath, exportedFileName) + const exportedFileName = fileNamesByFileUuid[fileUuid] + const tempFilePath = FileUtils.join(cycleFilesOutputDirPath, exportedFileName) await FileUtils.writeFile(tempFilePath, recordFileContent) return true diff --git a/server/modules/nodeDef/repository/nodeDefRepository.js b/server/modules/nodeDef/repository/nodeDefRepository.js index 808bd85380..8ce4f7e151 100644 --- a/server/modules/nodeDef/repository/nodeDefRepository.js +++ b/server/modules/nodeDef/repository/nodeDefRepository.js @@ -3,30 +3,42 @@ import * as R from 'ramda' import { Objects } from '@openforis/arena-core' import { DB, BaseProtocol, TableNodeDef, Schemata } from '@openforis/arena-server' +import * as A from '@core/arena' import * as NodeDef from '@core/survey/nodeDef' import * as ServerDB from '@server/db' import * as DbUtils from '@server/db/dbUtils' const { getSchemaSurvey } = Schemata +// advanced properties to track as draft (to be used when publishing record) +const advancedPropKeysDraftToTrack = [ + NodeDef.keysPropsAdvanced.applicable, + NodeDef.keysPropsAdvanced.defaultValues, + NodeDef.keysPropsAdvanced.fileNameExpression, + NodeDef.keysPropsAdvanced.validations, +] + +const rowPropertyByAdvancedPropKeys = { + [NodeDef.keysPropsAdvanced.applicable]: A.camelize(NodeDef.keys.draftAdvancedApplicable), + [NodeDef.keysPropsAdvanced.defaultValues]: A.camelize(NodeDef.keys.draftAdvancedDefaultValues), + [NodeDef.keysPropsAdvanced.fileNameExpression]: A.camelize(NodeDef.keys.draftAdvancedFileNameExpression), + [NodeDef.keysPropsAdvanced.validations]: A.camelize(NodeDef.keys.draftAdvancedValidations), +} + const dbTransformCallback = ({ row, draft, advanced = false, backup = false }) => { const rowUpdated = { ...row } if (advanced || backup) { if (!R.isEmpty(rowUpdated.props_advanced_draft)) { // there are draft advanced props to merge with "published" advanced props - rowUpdated.draft_advanced = true + rowUpdated[A.camelize(NodeDef.keys.draftAdvanced)] = true // set updated props flags - if (rowUpdated.props_advanced_draft[NodeDef.keysPropsAdvanced.validations]) { - rowUpdated.draft_advanced_validations = true - } - if (rowUpdated.props_advanced_draft[NodeDef.keysPropsAdvanced.applicable]) { - rowUpdated.draft_advanced_applicable = true - } - if (rowUpdated.props_advanced_draft[NodeDef.keysPropsAdvanced.defaultValues]) { - rowUpdated.draft_advanced_default_values = true - } + advancedPropKeysDraftToTrack.forEach((advancedPropKey) => { + if (rowUpdated.props_advanced_draft[advancedPropKey]) { + rowUpdated[rowPropertyByAdvancedPropKeys[advancedPropKey]] = true + } + }) if (draft && !backup) { // merge props_advanced and props_advanced_draft into props_advanced diff --git a/server/modules/record/manager/_recordManager/nodeUpdateManager.js b/server/modules/record/manager/_recordManager/nodeUpdateManager.js index b126ec3b37..024221f511 100644 --- a/server/modules/record/manager/_recordManager/nodeUpdateManager.js +++ b/server/modules/record/manager/_recordManager/nodeUpdateManager.js @@ -94,6 +94,7 @@ export const updateNode = async ({ user, survey, record, node, system = false, u await ActivityLogRepository.insert(user, surveyId, ActivityLog.type.nodeValueUpdate, logContent, system, t) } + let value = Node.getValue(node) if (NodeDef.isFile(nodeDef)) { // mark old file as deleted if changed const nodePrev = await NodeRepository.fetchNodeByUuid(surveyId, Node.getUuid(node), t) @@ -107,7 +108,7 @@ export const updateNode = async ({ user, survey, record, node, system = false, u { surveyId, nodeUuid: Node.getUuid(node), - value: Node.getValue(node), + value, meta, draft: Record.isPreview(record), reloadNode: updateDependents, diff --git a/server/modules/record/service/recordService.js b/server/modules/record/service/recordService.js index 3fa761458b..8033231d64 100644 --- a/server/modules/record/service/recordService.js +++ b/server/modules/record/service/recordService.js @@ -1,5 +1,7 @@ import * as fs from 'fs' +import { NodeValues, Objects } from '@openforis/arena-core' + import * as Log from '@server/log/log' import * as ActivityLog from '@common/activityLog/activityLog' @@ -392,7 +394,18 @@ export const generateNodeFileNameForDownload = async ({ surveyId, nodeUuid, file const survey = await SurveyManager.fetchSurveyAndNodeDefsAndRefDataBySurveyId({ surveyId, draft: !Survey.isPublished(surveySummary), + advanced: true, + includeBigCategories: false, + includeBigTaxonomies: false, }) + // return calculatd name (if any) + const nodeDef = Survey.getNodeDefByUuid(Node.getNodeDefUuid(node))(survey) + if (NodeDef.getFileNameExpression(nodeDef)) { + const calculatedName = NodeValues.getFileNameCalculated(node) + if (Objects.isNotEmpty(calculatedName)) { + return calculatedName + } + } const surveyName = Survey.getName(Survey.getSurveyInfo(survey)) const fileNameParts = [] diff --git a/server/modules/survey/service/recordCheckJob.js b/server/modules/survey/service/recordCheckJob.js index 29f6143e5a..bc906ace12 100644 --- a/server/modules/survey/service/recordCheckJob.js +++ b/server/modules/survey/service/recordCheckJob.js @@ -78,6 +78,7 @@ export default class RecordCheckJob extends Job { NodeDef.hasAdvancedPropsDraft(def) && (NodeDef.hasAdvancedPropsApplicableDraft(def) || NodeDef.hasAdvancedPropsDefaultValuesDraft(def) || + NodeDef.hasAdvancedPropsFileNameExpressionDraft(def) || NodeDef.hasAdvancedPropsValidationsDraft(def)) ) { // Already existing node def but applicable or default values or validations have been updated @@ -95,6 +96,7 @@ export default class RecordCheckJob extends Job { ) this.logDebug('survey fetched') } + surveyAndNodeDefs = { survey, nodeDefAddedUuids, diff --git a/server/modules/surveyRdb/manager/UniqueFileNamesGenerator.js b/server/modules/surveyRdb/manager/UniqueFileNamesGenerator.js new file mode 100644 index 0000000000..17790b1c14 --- /dev/null +++ b/server/modules/surveyRdb/manager/UniqueFileNamesGenerator.js @@ -0,0 +1,51 @@ +import { FileNames } from '@openforis/arena-core' + +const nameWithIndexRegEx = /^(.*)\s\((\d+)\)$/ // file name like "example (1).txt" + +export class UniqueFileNamesGenerator { + constructor() { + this._fileNamesByKey = {} + this._keysByFileName = {} + } + + generateUniqueFileName(inputFileName, key) { + if (this._fileNamesByKey[key]) { + throw new Error('Cannot generate a unique file name associated to the same key') + } + if (!this._keysByFileName[inputFileName]) { + this._keysByFileName[inputFileName] = key + this._fileNamesByKey[key] = inputFileName + return inputFileName + } + let generatedFileName = this._generateNextFileName(inputFileName) + while (this._keysByFileName[generatedFileName]) { + generatedFileName = this._generateNextFileName(generatedFileName) + } + this._keysByFileName[generatedFileName] = key + this._fileNamesByKey[key] = generatedFileName + return generatedFileName + } + + _generateNextFileName(inputFileName) { + const name = FileNames.getName(inputFileName) + const extension = FileNames.getExtension(inputFileName) + const matchRes = name.match(nameWithIndexRegEx) + let nameUpdated + if (matchRes) { + const [, nameWithoutIndex, index] = matchRes + const nextIndex = Number(index) + 1 + nameUpdated = `${nameWithoutIndex} (${nextIndex})` + } else { + nameUpdated = `${name} (1)` + } + return extension ? `${nameUpdated}.${extension}` : nameUpdated + } + + get fileNamesByKey() { + return this._fileNamesByKey + } + + get keysByFileName() { + return this._keysByFileName + } +} diff --git a/server/modules/surveyRdb/manager/surveyRdbCsvExport.js b/server/modules/surveyRdb/manager/surveyRdbCsvExport.js index 7e1a3c9538..26d3a05225 100644 --- a/server/modules/surveyRdb/manager/surveyRdbCsvExport.js +++ b/server/modules/surveyRdb/manager/surveyRdbCsvExport.js @@ -1,24 +1,15 @@ import * as A from '@core/arena' -import * as Survey from '@core/survey/survey' -import * as NodeDef from '@core/survey/nodeDef' import * as CategoryItem from '@core/survey/categoryItem' +import * as NodeDef from '@core/survey/nodeDef' +import * as Survey from '@core/survey/survey' import { ArrayUtils } from '@core/arrayUtils' -import { Query } from '@common/model/query' import { CsvDataExportModel } from '@common/model/csvExport' import { ColumnNodeDef, ViewDataNodeDef } from '@common/model/db' +import { Query } from '@common/model/query' const maxExpandedCategoryItems = 20 -const csvObjectTransformerNullsToEmpty = (obj) => { - Object.entries(obj).forEach(([key, value]) => { - if (A.isNull(value)) { - obj[key] = '' - } - }) - return obj -} - const visitCategoryItems = ({ survey, nodeDef, itemVisitor }) => { const items = Survey.getNodeDefCategoryItems(nodeDef)(survey) if (items.length <= maxExpandedCategoryItems) { @@ -162,15 +153,56 @@ const getCsvObjectTransformerExpandCategoryItems = ({ survey, query }) => { } } -const getCsvObjectTransformer = ({ survey, query, expandCategoryItems, nullsToEmpty = false }) => { +const getCsvObjectTransformerUniqueFileNames = ({ survey, query, uniqueFileNamesGenerator }) => { + const nodeDefUuidCols = Query.getAttributeDefUuids(query) + const nodeDefCols = Survey.getNodeDefsByUuids(nodeDefUuidCols)(survey) + const nodeDefFileCols = nodeDefCols.filter(NodeDef.isFile) + const transformer = (obj) => { + nodeDefFileCols.forEach((nodeDef) => { + const fileNameField = ColumnNodeDef.getFileNameColumnName(nodeDef) + const fileName = obj[fileNameField] + const fileUuid = obj[ColumnNodeDef.getFileUuidColumnName(nodeDef)] + const uniqueFileName = uniqueFileNamesGenerator.generateUniqueFileName(fileName, fileUuid) + obj[fileNameField] = uniqueFileName + }) + return obj + } + return { transformer } +} + +const getCsvObjectTransformerNullsToEmpty = () => (obj) => { + Object.entries(obj).forEach(([key, value]) => { + if (A.isNull(value)) { + obj[key] = '' + } + }) + return obj +} + +const getCsvObjectTransformer = ({ + survey, + query, + expandCategoryItems, + nullsToEmpty = false, + keepFileNamesUnique = false, // if true, uniqueFileNamesGenerator must be specified + uniqueFileNamesGenerator = null, // required if keepFileNamesUnique is true +}) => { const transformers = [] if (expandCategoryItems) { transformers.push(getCsvObjectTransformerExpandCategoryItems({ survey, query })) } if (nullsToEmpty) { - transformers.push(csvObjectTransformerNullsToEmpty) + transformers.push(getCsvObjectTransformerNullsToEmpty) + } + if (keepFileNamesUnique) { + const { transformer } = getCsvObjectTransformerUniqueFileNames({ + survey, + query, + uniqueFileNamesGenerator, + }) + transformers.push(transformer) } - return transformers.length === 0 ? null : A.pipe(...transformers) + return { transformers } } export const SurveyRdbCsvExport = { diff --git a/server/modules/surveyRdb/manager/surveyRdbManager.js b/server/modules/surveyRdb/manager/surveyRdbManager.js index 6113df9b59..a0951bde2b 100644 --- a/server/modules/surveyRdb/manager/surveyRdbManager.js +++ b/server/modules/surveyRdb/manager/surveyRdbManager.js @@ -2,6 +2,7 @@ import pgPromise from 'pg-promise' import { Dates, Objects, SystemError } from '@openforis/arena-core' +import * as A from '@core/arena' import * as Survey from '@core/survey/survey' import * as NodeDef from '@core/survey/nodeDef' import * as Record from '@core/record/record' @@ -24,6 +25,7 @@ import * as DataTableReadRepository from '../repository/dataTableReadRepository' import * as DataTableRepository from '../repository/dataTable' import * as DataViewRepository from '../repository/dataView' import { SurveyRdbCsvExport } from './surveyRdbCsvExport' +import { UniqueFileNamesGenerator } from './UniqueFileNamesGenerator' // ==== DDL @@ -81,6 +83,7 @@ export const fetchViewData = async (params, client = db) => { includeInternalUuids = false, includeDateCreated = false, nullsToEmpty = false, + uniqueFileNamesGenerator = null, } = params // Fetch data @@ -113,16 +116,20 @@ export const fetchViewData = async (params, client = db) => { includeInternalUuids, includeDateCreated, }) + await db.stream(result, (dbStream) => { + const { transformers } = SurveyRdbCsvExport.getCsvObjectTransformer({ + survey, + query, + expandCategoryItems, + nullsToEmpty, + keepFileNamesUnique: true, + uniqueFileNamesGenerator, + }) const csvTransform = CSVWriter.transformJsonToCsv({ fields, options: { - objectTransformer: SurveyRdbCsvExport.getCsvObjectTransformer({ - survey, - query, - expandCategoryItems, - nullsToEmpty, - }), + objectTransformer: Objects.isEmpty(transformers) ? undefined : A.pipe(...transformers), }, }) dbStream.pipe(csvTransform).pipe(streamOutput) @@ -249,6 +256,8 @@ export const fetchEntitiesDataToCsvFiles = async ( cycle, })(survey) + const uniqueFileNamesGenerator = new UniqueFileNamesGenerator() + await PromiseUtils.each(nodeDefs, async (nodeDefContext, idx) => { const entityDefUuid = NodeDef.getUuid(nodeDefContext) const outputFilePrefix = StringUtils.padStart(2, '0')(String(idx + 1)) @@ -293,12 +302,14 @@ export const fetchEntitiesDataToCsvFiles = async ( addCycle, includeCategoryItemsLabels, expandCategoryItems, + uniqueFileNamesGenerator, includeInternalUuids, includeDateCreated, }, client ) }) + return { fileNamesByFileUuid: uniqueFileNamesGenerator.fileNamesByKey } } export const fetchEntitiesFileUuidsByCycle = async ( diff --git a/server/modules/surveyRdb/service/surveyRdbCreationJob/surveyRdbDataTablesAndViewsCreationJob.js b/server/modules/surveyRdb/service/surveyRdbCreationJob/surveyRdbDataTablesAndViewsCreationJob.js index 178b81d74f..07eba65a57 100644 --- a/server/modules/surveyRdb/service/surveyRdbCreationJob/surveyRdbDataTablesAndViewsCreationJob.js +++ b/server/modules/surveyRdb/service/surveyRdbCreationJob/surveyRdbDataTablesAndViewsCreationJob.js @@ -96,7 +96,7 @@ export default class SurveyRdbDataTablesAndViewsCreationJob extends Job { const fetchDraft = Survey.isFromCollect(surveyInfo) && !Survey.isPublished(surveyInfo) return SurveyManager.fetchSurveyAndNodeDefsAndRefDataBySurveyId( - { surveyId, draft: fetchDraft, includeBigCategories: false }, + { surveyId, draft: fetchDraft, advanced: true, includeBigCategories: false, includeBigTaxonomies: false }, tx ) } diff --git a/webapp/components/survey/NodeDefDetails/ExpressionsProp/NodeDefSingleExpressionProp.js b/webapp/components/survey/NodeDefDetails/ExpressionsProp/NodeDefSingleExpressionProp.js new file mode 100644 index 0000000000..f82c8634af --- /dev/null +++ b/webapp/components/survey/NodeDefDetails/ExpressionsProp/NodeDefSingleExpressionProp.js @@ -0,0 +1,53 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import * as NodeDef from '@core/survey/nodeDef' + +import ExpressionEditor from '@webapp/components/expression/expressionEditor' +import ValidationTooltip from '@webapp/components/validationTooltip' + +export const NodeDefSingleExpressionProp = (props) => { + const { + canBeConstant = false, + excludeCurrentNodeDef = false, + isBoolean = false, + nodeDef, + onChange, + qualifier, + query, + validation = null, + } = props + return ( +
+ +
+ { + onChange({ query }) + callback() + }} + qualifier={qualifier} + query={query} + /> +
+
+
+ ) +} + +NodeDefSingleExpressionProp.propTypes = { + canBeConstant: PropTypes.bool, + excludeCurrentNodeDef: PropTypes.bool, + isBoolean: PropTypes.bool, + nodeDef: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + qualifier: PropTypes.string.isRequired, + query: PropTypes.string, // String representing the expression + validation: PropTypes.object, +} diff --git a/webapp/components/survey/NodeDefDetails/FileProps.js b/webapp/components/survey/NodeDefDetails/FileProps.js index 06da121786..d9fe712f48 100644 --- a/webapp/components/survey/NodeDefDetails/FileProps.js +++ b/webapp/components/survey/NodeDefDetails/FileProps.js @@ -2,15 +2,17 @@ import React, { useCallback, useEffect } from 'react' import PropTypes from 'prop-types' import * as A from '@core/arena' +import * as NodeDef from '@core/survey/nodeDef' +import * as Validation from '@core/validation/validation' +import * as ProcessUtils from '@core/processUtils' import { useI18n } from '@webapp/store/system' + import { FormItem, Input } from '@webapp/components/form/Input' import ButtonGroup from '@webapp/components/form/buttonGroup' -import * as NodeDef from '@core/survey/nodeDef' -import * as Validation from '@core/validation/validation' - import { State } from './store' +import { NodeDefSingleExpressionProp } from './ExpressionsProp/NodeDefSingleExpressionProp' const fileTypes = Object.keys(NodeDef.fileTypeValues).map((key) => ({ key, @@ -29,14 +31,14 @@ const FileProps = (props) => { (value) => { Actions.setProp({ state, key: NodeDef.propKeys.fileType, value }) }, - [state] + [Actions, state] ) useEffect(() => { if (A.isEmpty(NodeDef.getFileType(nodeDef))) { selectFileType(NodeDef.fileTypeValues.other) } - }, []) + }, [nodeDef, selectFileType]) return ( <> @@ -45,9 +47,9 @@ const FileProps = (props) => {

{i18n.t('nodeDefEdit.fileProps.numberOfFiles')}

)} - { validation={Validation.getFieldValidation(NodeDef.propKeys.maxFileSize)(validation)} /> + {ProcessUtils.ENV.experimentalFeatures && ( + + { + Actions.setProp({ state, key: NodeDef.keysPropsAdvanced.fileNameExpression, value }) + }} + qualifier="fileNameExpression" + query={NodeDef.getFileNameExpression(nodeDef)} + validation={Validation.getFieldValidation(NodeDef.keysPropsAdvanced.fileNameExpression)(validation)} + /> + + )} diff --git a/webapp/components/survey/NodeDefDetails/NodeDefDetails.scss b/webapp/components/survey/NodeDefDetails/NodeDefDetails.scss index 7afc366823..8331758cda 100644 --- a/webapp/components/survey/NodeDefDetails/NodeDefDetails.scss +++ b/webapp/components/survey/NodeDefDetails/NodeDefDetails.scss @@ -33,6 +33,10 @@ margin-left: 20%; } } + + .max-file-size { + width: 6rem; + } } .node-def-edit__container { diff --git a/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefFile.js b/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefFile.js index be59386865..f58f4ad185 100644 --- a/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefFile.js +++ b/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefFile.js @@ -18,8 +18,9 @@ const FileInput = (props) => { const { surveyInfo, nodeDef, node, readOnly, edit, canEditRecord, updateNode, removeNode } = props const [fileUploaded, setFileUploaded] = useState(null) - const fileName = Node.getFileName(node) - const fileReady = !edit && fileName + const originalFileName = Node.getFileName(node) + const fileName = Node.getFileNameCalculated(node) ?? originalFileName + const fileReady = !edit && originalFileName const fileUrl = `/api/survey/${surveyInfo.id}/record/${Node.getRecordUuid(node)}/nodes/${Node.getUuid(node)}/file` const handleFileChange = (file) => { diff --git a/webapp/components/tooltip.scss b/webapp/components/tooltip.scss index b6877abb42..a97f151f20 100644 --- a/webapp/components/tooltip.scss +++ b/webapp/components/tooltip.scss @@ -54,15 +54,10 @@ } .MuiTextField-root fieldset, + .MuiTextField-root:hover fieldset, .MuiTextField-root .Mui-focused fieldset { - border-width: 1px; - border-style: solid; border-color: red; } - - .MuiTextField-root .Mui-focused fieldset { - border-width: 2px; - } } .tooltip__message-error,