@adriansteffan/reactive 0.0.44 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,21 @@ type DataObject = {
6
6
  [key: string]: string | number | boolean | null | undefined;
7
7
  };
8
8
 
9
+
10
+ const flattenerRegistry: Record<string, {
11
+ csv: string | null;
12
+ flatten?: (item: TrialData) => any[];
13
+ }[]> = {};
14
+
15
+ export function registerFlattener(
16
+ type: string,
17
+ csv: string | null,
18
+ flatten?: (item: TrialData) => any[],
19
+ ) {
20
+ if (!flattenerRegistry[type]) flattenerRegistry[type] = [];
21
+ flattenerRegistry[type].push({ csv, flatten });
22
+ }
23
+
9
24
  export function escapeCsvValue(value: any): string {
10
25
  if (value === null || value === undefined) {
11
26
  return '';
@@ -48,137 +63,109 @@ export function convertArrayOfObjectsToCSV(data: DataObject[]): string {
48
63
  return [headerRow, ...dataRows].join('\n');
49
64
  }
50
65
 
51
- export const defaultFlatteningFunctions: Record<
52
- string,
53
- (item: TrialData) => any[] | Record<string, any[]>
54
- > = {
55
- CanvasBlock: (item: TrialData) => {
56
- const responseData = item.responseData;
57
- if (Array.isArray(responseData)) {
58
- return responseData.map((i) => ({
59
- block: item.name,
60
- ...i,
61
- }));
66
+ // Extracts experiment parameter values (URL params / registered params) from the initial metadata trial.
67
+ // Each param entry has { value, defaultValue, ... } — we pick value if set, otherwise the default.
68
+ function extractParams(data: any[]): Record<string, any> {
69
+ const paramsSource = data?.[0]?.responseData?.params;
70
+ if (!paramsSource || typeof paramsSource !== 'object') return {};
71
+ const result: Record<string, any> = {};
72
+ for (const [name, details] of Object.entries(paramsSource) as [string, any][]) {
73
+ if (details && typeof details === 'object') {
74
+ result[name] = details.value ?? details.defaultValue;
62
75
  }
63
- return [];
64
- },
65
- };
66
-
67
- export const transform = ({ responseData, ...obj }: any) => ({
68
- ...obj,
69
- ...Object.entries(responseData || {}).reduce(
70
- (acc, [k, v]) => ({ ...acc, [`data_${k}`]: v }),
71
- {},
72
- ),
73
- });
74
-
75
- export type CSVBuilder = {
76
- filename?: string;
77
- trials?: string[];
78
- fun?: (row: Record<string, any>) => Record<string, any>;
79
- };
80
-
81
- export function combineTrialsToCsv(
82
- data: any[],
83
- filename: string,
84
- names: string[],
85
- flatteningFunctions: Record<string, (item: any) => any[] | Record<string, any[]>>,
86
- fun?: (obj: any) => any,
87
- ): FileUpload | FileUpload[] {
88
- // Collect all flattener results first, filtering out completely empty results
89
- const allResults: (any[] | Record<string, any[]>)[] = names
90
- .flatMap((name) => {
91
- const matchingItems = data.filter((d) => d.name === name);
92
-
93
- return matchingItems.map((item) => {
94
- const flattener = item.type && flatteningFunctions[item.type];
95
- const result = flattener ? flattener(item) : [transform(item)];
96
-
97
- if (Array.isArray(result) && result.length === 0) {
98
- return null;
99
- }
100
-
101
- if (result && typeof result === 'object' && !Array.isArray(result)) {
102
- const hasAnyData = Object.values(result).some(
103
- (val) => Array.isArray(val) && val.length > 0,
104
- );
105
- if (!hasAnyData) {
106
- return null;
107
- }
108
- }
109
-
110
- return result;
111
- });
112
- })
113
- .filter((result) => result !== null);
114
-
115
- const hasMultiTable = allResults.some(
116
- (result) =>
117
- result &&
118
- typeof result === 'object' &&
119
- !Array.isArray(result) &&
120
- Object.keys(result).some((key) => Array.isArray(result[key])),
121
- );
76
+ }
77
+ return result;
78
+ }
122
79
 
123
- if (!hasMultiTable) {
124
- const processedData = allResults
125
- .flatMap((result) => (Array.isArray(result) ? result : []))
126
- .map((x) => (fun ? fun(x) : x));
80
+ // Built-in fields on every trial data object — not user data.
81
+ // Used to distinguish user-provided metadata from framework fields when building CSVs.
82
+ const trialBuiltinKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name', 'csv', 'responseData', 'metadata']);
83
+ // Subset of builtins that represent trial identity/timing — included in non-session CSVs with trial_ prefix
84
+ const trialInfoKeys = new Set(['index', 'trialNumber', 'start', 'end', 'duration', 'type', 'name']);
85
+
86
+ // Automatically builds CSV files from trial data using the flattener registry.
87
+ // Each trial is routed to a CSV file based on: item.csv override > registry default > skipped.
88
+ // The 'session' group is special: all trials merge into a single row, with keys namespaced
89
+ // by trial name (e.g. 'nickname_value', 'devicecheck_browser') to avoid collisions.
90
+ // All other groups produce multi-row CSVs, one row per trial (or more if the flattener expands them).
91
+ function autoBuildCSVs(sessionID: string, data: any[], sessionData?: Record<string, any>): FileUpload[] {
92
+ const files: FileUpload[] = [];
127
93
 
128
- if (processedData.length === 0) {
129
- return [];
94
+ // Group trials by their target CSV file name.
95
+ // A component can register multiple targets, so one trial may appear in multiple groups.
96
+ // A per-item csv override replaces all registered targets.
97
+ const groups: Record<string, { item: any; flatten?: (item: TrialData) => any[] }[]> = {};
98
+ for (const item of data) {
99
+ if (item.index === -1) continue; // skip initial metadata entry
100
+ const csvOverride = item.csv;
101
+ const targets = csvOverride
102
+ ? (Array.isArray(csvOverride) ? csvOverride : [csvOverride]).map((csv: string) => ({ csv }))
103
+ : flattenerRegistry[item.type] ?? [];
104
+ for (const target of targets) {
105
+ if (!target.csv) continue;
106
+ if (!groups[target.csv]) groups[target.csv] = [];
107
+ groups[target.csv].push({ item, flatten: 'flatten' in target ? target.flatten : undefined });
130
108
  }
131
-
132
- return {
133
- filename,
134
- encoding: 'utf8' as const,
135
- content: convertArrayOfObjectsToCSV(processedData),
136
- };
137
109
  }
138
110
 
139
- const allTableKeys = new Set<string>();
140
- allResults.forEach((result) => {
141
- if (result && typeof result === 'object' && !Array.isArray(result)) {
142
- Object.keys(result).forEach((key) => {
143
- if (Array.isArray(result[key])) {
144
- allTableKeys.add(key);
145
- }
146
- });
147
- }
148
- });
149
-
150
- const files: FileUpload[] = [];
151
-
152
- for (const tableKey of allTableKeys) {
153
- const tableData = allResults
154
- .flatMap((result) => {
155
- if (Array.isArray(result)) {
156
- return result;
157
- } else if (result && typeof result === 'object' && result[tableKey]) {
158
- return result[tableKey];
111
+ // 'session' group: one row per participant with metadata + responseData namespaced by trial name
112
+ if (groups['session']) {
113
+ const row: Record<string, any> = {
114
+ sessionID,
115
+ userAgent: data?.[0]?.responseData?.userAgent,
116
+ ...extractParams(data),
117
+ ...sessionData,
118
+ };
119
+ for (const { item } of groups['session']) {
120
+ const prefix = item.name || item.type;
121
+ // Add user-provided metadata (non-builtin top-level keys), namespaced by trial name
122
+ for (const [key, value] of Object.entries(item)) {
123
+ if (!trialBuiltinKeys.has(key)) row[`${prefix}_${key}`] = value;
124
+ }
125
+ // Add responseData fields, namespaced by trial name
126
+ if (item.responseData && typeof item.responseData === 'object' && !Array.isArray(item.responseData)) {
127
+ for (const [key, value] of Object.entries(item.responseData)) {
128
+ row[`${prefix}_${key}`] = value;
159
129
  }
160
- return [];
161
- })
162
- .map((x) => (fun ? fun(x) : x));
163
-
164
- if (tableData.length === 0) {
165
- continue;
130
+ }
166
131
  }
167
-
168
- const baseFilename = filename.replace(/\.csv$/, '');
169
-
170
132
  files.push({
171
- filename: `${baseFilename}_${tableKey}.csv`,
133
+ filename: `session.${sessionID}.${Date.now()}.csv`,
134
+ content: convertArrayOfObjectsToCSV([row]),
172
135
  encoding: 'utf8' as const,
173
- content: convertArrayOfObjectsToCSV(tableData),
174
136
  });
137
+ delete groups['session'];
175
138
  }
176
139
 
177
- if (files.length === 0) {
178
- return [];
140
+ // All other groups: multi-row CSVs using registered flatteners (or raw responseData spread).
141
+ // Each row is prepended with standard trial fields (prefixed trial_) plus any extra
142
+ // metadata fields (unprefixed). The flattener's output overwrites these if keys collide.
143
+ for (const [csvName, entries] of Object.entries(groups)) {
144
+ const rows = entries.flatMap(({ item, flatten }) => {
145
+ const base: Record<string, any> = {};
146
+ for (const [key, value] of Object.entries(item)) {
147
+ if (trialBuiltinKeys.has(key)) {
148
+ if (trialInfoKeys.has(key)) base[`trial_${key}`] = value;
149
+ } else {
150
+ base[key] = value;
151
+ }
152
+ }
153
+ // When no flatten function is registered, spread responseData directly.
154
+ const flatRows = flatten
155
+ ? flatten(item)
156
+ : [item.responseData && typeof item.responseData === 'object' ? { ...item.responseData } : {}];
157
+ return flatRows.map((row: any) => ({ ...base, ...row }));
158
+ });
159
+ if (rows.length > 0) {
160
+ files.push({
161
+ filename: `${csvName}.${sessionID}.${Date.now()}.csv`,
162
+ content: convertArrayOfObjectsToCSV(rows),
163
+ encoding: 'utf8' as const,
164
+ });
165
+ }
179
166
  }
180
167
 
181
- return files.length === 1 ? files[0] : files;
168
+ return files;
182
169
  }
183
170
 
184
171
  export function buildUploadFiles(config: {
@@ -186,11 +173,7 @@ export function buildUploadFiles(config: {
186
173
  data: any[];
187
174
  store?: Store;
188
175
  generateFiles?: (sessionID: string, data: any[], store?: Store) => FileUpload[];
189
- sessionCSVBuilder?: CSVBuilder;
190
- trialCSVBuilder?: {
191
- flatteners: Record<string, (item: any) => any[] | Record<string, any[]>>;
192
- builders: CSVBuilder[];
193
- };
176
+ sessionData?: Record<string, any>;
194
177
  uploadRaw?: boolean;
195
178
  }): FileUpload[] {
196
179
  const {
@@ -198,8 +181,7 @@ export function buildUploadFiles(config: {
198
181
  data,
199
182
  store,
200
183
  generateFiles,
201
- sessionCSVBuilder,
202
- trialCSVBuilder,
184
+ sessionData,
203
185
  uploadRaw = true,
204
186
  } = config;
205
187
 
@@ -213,87 +195,7 @@ export function buildUploadFiles(config: {
213
195
  });
214
196
  }
215
197
 
216
- if (sessionCSVBuilder) {
217
- type ParamDetails = {
218
- value?: any;
219
- defaultValue: any;
220
- };
221
- let paramsDict: Record<string, any> = {};
222
- const paramsSource: Record<string, ParamDetails | any> | undefined =
223
- data?.[0]?.responseData?.params;
224
- if (paramsSource && typeof paramsSource === 'object' && paramsSource !== null) {
225
- paramsDict = Object.entries(paramsSource).reduce(
226
- (
227
- accumulator: Record<string, any>,
228
- [paramName, paramDetails]: [string, ParamDetails | any],
229
- ) => {
230
- if (
231
- paramDetails &&
232
- typeof paramDetails === 'object' &&
233
- 'defaultValue' in paramDetails
234
- ) {
235
- const chosenValue = paramDetails.value ?? paramDetails.defaultValue;
236
- accumulator[paramName] = chosenValue;
237
- }
238
- return accumulator;
239
- },
240
- {} as Record<string, any>,
241
- );
242
- }
243
-
244
- let content: Record<string, any> = {
245
- sessionID,
246
- userAgent: data?.[0]?.responseData?.userAgent,
247
- ...paramsDict,
248
- };
249
-
250
- if (
251
- sessionCSVBuilder.trials &&
252
- Array.isArray(sessionCSVBuilder.trials) &&
253
- sessionCSVBuilder.trials.length > 0
254
- ) {
255
- for (const trialName of sessionCSVBuilder.trials) {
256
- const matchingDataElement = data.find((element) => element.name === trialName);
257
-
258
- if (matchingDataElement?.responseData) {
259
- if (
260
- typeof matchingDataElement.responseData === 'object' &&
261
- matchingDataElement.responseData !== null
262
- ) {
263
- content = { ...content, ...matchingDataElement.responseData };
264
- }
265
- }
266
- }
267
- }
268
-
269
- files.push({
270
- content: convertArrayOfObjectsToCSV([
271
- sessionCSVBuilder.fun ? sessionCSVBuilder.fun(content) : content,
272
- ]),
273
- filename: `${sessionID}${sessionCSVBuilder.filename}.csv`,
274
- encoding: 'utf8' as const,
275
- });
276
- }
277
-
278
- if (trialCSVBuilder) {
279
- for (const builder of trialCSVBuilder.builders) {
280
- const result = combineTrialsToCsv(
281
- data,
282
- `${sessionID}${builder.filename}.csv`,
283
- builder.trials ?? [],
284
- { ...defaultFlatteningFunctions, ...trialCSVBuilder.flatteners },
285
- builder.fun,
286
- );
287
-
288
- if (Array.isArray(result)) {
289
- if (result.length > 0) {
290
- files.push(...result);
291
- }
292
- } else {
293
- files.push(result);
294
- }
295
- }
296
- }
198
+ files.push(...autoBuildCSVs(sessionID, data, sessionData));
297
199
 
298
200
  return files;
299
201
  }