Skip to content

Commit 449c2ad

Browse files
committed
chore: add zod4 process instrument csv and reformatInstrument data methods
1 parent f471b59 commit 449c2ad

1 file changed

Lines changed: 197 additions & 7 deletions

File tree

apps/web/src/utils/upload2.ts

Lines changed: 197 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
/* eslint-disable @typescript-eslint/no-namespace */
22
import { isNumberLike, isObjectLike, isPlainObject, isZodType, parseNumber } from '@douglasneuroinformatics/libjs';
33
import type { Language } from '@douglasneuroinformatics/libui/i18n';
4-
import type { AnyUnilingualFormInstrument, FormTypes } from '@opendatacapture/runtime-core';
4+
import type { AnyUnilingualFormInstrument, FormTypes, Json } from '@opendatacapture/runtime-core';
5+
import type { Group } from '@opendatacapture/schemas/group';
6+
import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument';
7+
import type { UploadInstrumentRecordsData } from '@opendatacapture/schemas/instrument-records';
8+
import { encodeScopedSubjectId } from '@opendatacapture/subject-utils';
59
import { parse, unparse } from 'papaparse';
610
import { z as z3 } from 'zod/v3';
711
import { z as z4 } from 'zod/v4';
@@ -84,7 +88,7 @@ function extractRecordArrayEntry(entry: string) {
8488
}
8589

8690
namespace Zod3 {
87-
type ZodTypeName = Extract<`${z3.ZodFirstPartyTypeKind}`, (typeof ZOD_TYPE_NAMES)[number]>;
91+
export type ZodTypeName = Extract<`${z3.ZodFirstPartyTypeKind}`, (typeof ZOD_TYPE_NAMES)[number]>;
8892

8993
export type RequiredZodTypeName = Exclude<ZodTypeName, 'ZodEffects' | 'ZodOptional'>;
9094

@@ -174,7 +178,7 @@ namespace Zod3 {
174178
};
175179
}
176180

177-
function getZodTypeName(schema: z3.ZodTypeAny, isOptional?: boolean): ZodTypeNameResult {
181+
export function getZodTypeName(schema: z3.ZodTypeAny, isOptional?: boolean): ZodTypeNameResult {
178182
const def: unknown = schema._def;
179183
if (!isZodTypeDef(def)) {
180184
console.error(`Cannot parse ZodType from schema: ${JSON.stringify(schema)}`);
@@ -322,15 +326,23 @@ namespace Zod3 {
322326
}
323327

324328
//new processInstrumentCSV code
325-
export function processInstrumentCSV(input: File, instrument: AnyUnilingualFormInstrument) {
329+
export function processInstrumentCSV(
330+
input: File,
331+
instrument: AnyUnilingualFormInstrument
332+
): Promise<UploadOperationResult<FormTypes.Data[]>> {
326333
const instrumentSchema = instrument.validationSchema as z3.AnyZodObject;
327334
let shape: { [key: string]: z3.ZodTypeAny } = {};
328335

329336
let instrumentSchemaWithInternal: z3.AnyZodObject;
330337
const instrumentSchemaDef: unknown = instrumentSchema._def;
331338
if (isZodTypeDef(instrumentSchemaDef) && isZodEffectsDef(instrumentSchemaDef)) {
332339
if (!isZodObject(instrumentSchemaDef.schema)) {
333-
return { message: { en: 'Invalid instrument schema', fr: "Schéma d'instrument invalide" }, success: false };
340+
return new Promise<UploadOperationResult<FormTypes.Data[]>>((resolve) => {
341+
return resolve({
342+
message: { en: 'Invalid instrument schema', fr: "Schéma d'instrument invalide" },
343+
success: false
344+
});
345+
});
334346
}
335347
instrumentSchemaWithInternal = instrumentSchemaDef.schema.extend({
336348
date: z3.coerce.date(),
@@ -799,8 +811,157 @@ namespace Zod4 {
799811
};
800812
}
801813
//to be filled
802-
export function processInstrumentCSV(input: File, instrument: AnyUnilingualFormInstrument) {
803-
return undefined;
814+
export async function processInstrumentCSV(
815+
input: File,
816+
instrument: AnyUnilingualFormInstrument
817+
): Promise<UploadOperationResult<FormTypes.Data[]>> {
818+
const instrumentSchema = instrument.validationSchema as z4.ZodObject;
819+
let shape: { [key: string]: z3.ZodTypeAny } = {};
820+
let instrumentSchemaWithInternal: z4.ZodObject;
821+
822+
if (instrumentSchema instanceof z4.ZodObject) {
823+
instrumentSchemaWithInternal = instrumentSchema.extend({
824+
date: z4.coerce.date(),
825+
subjectID: z4.string().regex(SUBJECT_ID_REGEX)
826+
});
827+
shape = instrumentSchemaWithInternal.shape;
828+
}
829+
830+
return new Promise<UploadOperationResult<FormTypes.Data[]>>((resolve) => {
831+
const reader = new FileReader();
832+
reader.onload = () => {
833+
const text = reader.result as string;
834+
const parseResultCsv = parse<string[]>(text, {
835+
header: false,
836+
skipEmptyLines: true
837+
});
838+
839+
const [headers, ...dataLines] = parseResultCsv.data;
840+
841+
//remove sample data if included
842+
if (dataLines[0]?.[0]?.startsWith(MONGOLIAN_VOWEL_SEPARATOR)) {
843+
dataLines.shift();
844+
}
845+
846+
if (dataLines.length === 0) {
847+
return resolve({
848+
message: { en: 'data lines is empty array', fr: 'les lignes de données sont un tableau vide' },
849+
success: false
850+
});
851+
}
852+
853+
const result: FormTypes.Data[] = [];
854+
855+
if (!headers?.length) {
856+
return resolve({
857+
message: {
858+
en: 'headers is undefined or empty array',
859+
fr: 'les en-têtes ne sont pas définis ou constituent un tableau vide'
860+
},
861+
success: false
862+
});
863+
}
864+
let rowNumber = 1;
865+
for (const elements of dataLines) {
866+
const jsonLine: { [key: string]: unknown } = {};
867+
for (let j = 0; j < headers.length; j++) {
868+
const key = headers[j]!.trim();
869+
const rawValue = elements[j]!.trim();
870+
871+
if (rawValue === '\n') {
872+
continue;
873+
}
874+
if (shape[key] === undefined) {
875+
return resolve({
876+
message: {
877+
en: `Schema value at column ${j} is not defined! Please check if Column has been edited/deleted from original template`,
878+
fr: `La valeur du schéma à la colonne ${j} n'est pas définie ! Veuillez vérifier si la colonne a été modifiée/supprimée du modèle original`
879+
},
880+
success: false
881+
});
882+
}
883+
try {
884+
const typeNameResult = Zod3.getZodTypeName(shape[key]);
885+
886+
let interpreterResult: UploadOperationResult<FormTypes.FieldValue> = {
887+
message: {
888+
en: 'Could not interpret a correct value',
889+
fr: "Impossible d'interpréter une valeur correcte"
890+
},
891+
success: false
892+
};
893+
894+
if (typeNameResult.typeName === 'ZodArray' || typeNameResult.typeName === 'ZodObject') {
895+
// eslint-disable-next-line max-depth
896+
if (typeNameResult.multiKeys && typeNameResult.multiValues) {
897+
interpreterResult = Zod3.interpretZodObjectValue(
898+
rawValue,
899+
typeNameResult.isOptional,
900+
typeNameResult.multiValues,
901+
typeNameResult.multiKeys
902+
);
903+
// TODO - what if this is not the case? Once generics are handled correctly should not be a problem
904+
// Dealt with via else statement for now
905+
} else {
906+
interpreterResult.message = {
907+
en: 'Record Array keys do not exist',
908+
fr: "Les clés du tableau d'enregistrements n'existent pas"
909+
};
910+
}
911+
} else {
912+
interpreterResult = Zod3.interpretZodValue(
913+
rawValue,
914+
typeNameResult.typeName,
915+
typeNameResult.isOptional
916+
);
917+
}
918+
// if (!interpreterResult.success) {
919+
// return resolve({
920+
// message: {
921+
// en: `${interpreterResult.message.en} at column name: '${key}' and row number ${rowNumber}`,
922+
// fr: `${interpreterResult.message.fr} au nom de colonne : '${key}' et numéro de ligne ${rowNumber}`
923+
// },
924+
// success: false
925+
// });
926+
// }
927+
if (interpreterResult.success) jsonLine[headers[j]!] = interpreterResult.value;
928+
} catch (error: unknown) {
929+
if (error instanceof UploadError) {
930+
return resolve({
931+
message: {
932+
en: `${error.description.en} at column name: '${key}' and row number '${rowNumber}'`,
933+
fr: `${error.description.fr} au nom de colonne : '${key}' et numéro de ligne '${rowNumber}`
934+
},
935+
success: false
936+
});
937+
} else {
938+
return resolve({
939+
message: {
940+
en: `Error parsing CSV`,
941+
fr: `Erreur avec la CSV`
942+
},
943+
success: false
944+
});
945+
}
946+
}
947+
}
948+
949+
const zodCheck = instrumentSchemaWithInternal.safeParse(jsonLine);
950+
if (!zodCheck.success) {
951+
console.error(zodCheck.error.issues);
952+
const zodIssues = zodCheck.error.issues.map((issue) => {
953+
return `issue message: \n ${issue.message} \n path: ${issue.path.toString()}`;
954+
});
955+
console.error(`Failed to parse data: ${JSON.stringify(jsonLine)}`);
956+
return resolve({ message: { en: zodIssues.join(), fr: zodIssues.join() }, success: false });
957+
}
958+
result.push(zodCheck.data as FormTypes.Data);
959+
rowNumber++;
960+
}
961+
resolve({ success: true, value: result });
962+
};
963+
reader.readAsText(input);
964+
});
804965
}
805966
}
806967

@@ -819,3 +980,32 @@ export function processInstrumentCSV(input: File, instrument: AnyUnilingualFormI
819980
}
820981
return Zod3.processInstrumentCSV(input, instrument);
821982
}
983+
984+
export function reformatInstrumentData({
985+
currentGroup,
986+
data,
987+
instrument
988+
}: {
989+
currentGroup: Group | null;
990+
data: FormTypes.Data[];
991+
instrument: UnilingualInstrumentInfo;
992+
}): UploadInstrumentRecordsData {
993+
const recordsList: { data: Json; date: Date; subjectId: string }[] = [];
994+
for (const dataInfo of data) {
995+
const { date: dataDate, subjectID: dataSubjectId, ...restOfData } = dataInfo; // Destructure and extract the rest of the data
996+
const createdRecord = {
997+
data: restOfData as Json,
998+
date: dataDate as Date,
999+
subjectId: encodeScopedSubjectId(dataSubjectId as string, {
1000+
groupName: currentGroup?.name ?? 'root'
1001+
})
1002+
};
1003+
recordsList.push(createdRecord);
1004+
}
1005+
const reformatForSending: UploadInstrumentRecordsData = {
1006+
groupId: currentGroup?.id,
1007+
instrumentId: instrument.id,
1008+
records: recordsList
1009+
};
1010+
return reformatForSending;
1011+
}

0 commit comments

Comments
 (0)