@communecter/cocolight-api-client 1.0.132 → 1.0.133
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.
- package/dist/cocolight-api-client.browser.js +1 -1
- package/dist/cocolight-api-client.cjs +1 -1
- package/dist/cocolight-api-client.mjs.js +1 -1
- package/dist/cocolight-api-client.vite.mjs.js +1 -1
- package/dist/cocolight-api-client.vite.mjs.js.map +1 -1
- package/package.json +1 -1
- package/src/Api.ts +5 -5
- package/src/api/Answer.ts +842 -5
- package/src/api/BaseEntity.ts +94 -4
- package/src/api/Form.ts +38 -0
- package/src/api/Project.ts +6 -5
- package/types/api/Answer.d.ts +295 -2
- package/types/api/BaseEntity.d.ts +56 -3
- package/types/api/Form.d.ts +24 -0
- package/types/api/Project.d.ts +5 -5
package/src/api/Answer.ts
CHANGED
|
@@ -1,22 +1,117 @@
|
|
|
1
1
|
import { BaseEntity } from "./BaseEntity.js";
|
|
2
2
|
import { ApiError } from "../error.js";
|
|
3
3
|
|
|
4
|
+
import type { CoformUploadAnswerFileData, DeleteElementData, SaveCoformAnswerData } from "./EndpointApi.types.js";
|
|
4
5
|
import type { AnswerItemNormalized } from "./serverDataType/Answer.js";
|
|
6
|
+
import type { FormItemNormalized } from "./serverDataType/Form.js";
|
|
7
|
+
|
|
8
|
+
type UploadInput = File | Blob | Buffer | import("stream").Readable;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Format des données utilisateur d'un CoForm — `{ subFormId: { inputId: value } }`.
|
|
12
|
+
* Générique : la lib ne fait aucune supposition sur le type des valeurs.
|
|
13
|
+
*/
|
|
14
|
+
export type AllStepsData = Record<string, Record<string, unknown>>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Valeur d'un upload en attente — objet `{ name?, data: "data:...;base64,..." }`
|
|
18
|
+
* inséré par les inputs uploader avant le pré-upload backend.
|
|
19
|
+
*/
|
|
20
|
+
export interface PendingUploadValue {
|
|
21
|
+
name?: string;
|
|
22
|
+
data: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Un pending upload détecté dans la structure `allData` — chemin imbriqué +
|
|
27
|
+
* type d'input dérivé du schéma (`formData.inputs[subFormId].inputs[inputId].type`).
|
|
28
|
+
*/
|
|
29
|
+
export interface PendingUpload {
|
|
30
|
+
value: PendingUploadValue;
|
|
31
|
+
path: (string | number)[];
|
|
32
|
+
inputType?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options de `Answer.processUploads()`.
|
|
37
|
+
*/
|
|
38
|
+
export interface ProcessUploadsOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Schéma du Form (pour déduire `inputType` des champs uploader/simpleTable).
|
|
41
|
+
* Auto-récupéré via `this.parent.serverData` si parent est un Form.
|
|
42
|
+
*/
|
|
43
|
+
formData?: FormItemNormalized;
|
|
44
|
+
/** Nombre d'uploads parallèles par batch. Défaut : 4. */
|
|
45
|
+
batchSize?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Options pour `Answer.uploadFile()`. Tous les champs sont optionnels — les
|
|
50
|
+
* défauts reproduisent les valeurs backend (`docType: "image"`, `contentKey: "slider"`).
|
|
51
|
+
*/
|
|
52
|
+
export interface UploadAnswerFileOptions {
|
|
53
|
+
/** Type de document : `image` (défaut) ou `file` (PDF, DOCX, etc.). */
|
|
54
|
+
docType?: "image" | "file";
|
|
55
|
+
/** Clé de contenu côté backend. `"slider"` (défaut) pour images, `"presentation"` pour inputs uploader. */
|
|
56
|
+
contentKey?: string;
|
|
57
|
+
/** Sous-clé optionnelle (ex: `"subFormId.inputId"` pour les inputs `*.uploader`). */
|
|
58
|
+
subKey?: string;
|
|
59
|
+
/** UUID client (compat fine-uploader). Auto-généré si absent. */
|
|
60
|
+
qquuid?: string;
|
|
61
|
+
/** Nom du fichier. Auto-déduit (File.name, stream.path, ou fallback) si absent. */
|
|
62
|
+
qqfilename?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Taille du fichier en octets. Auto-déduite pour `File` (`.size`), `Blob`
|
|
65
|
+
* (`.size`), `Buffer` (`.length`), ou `Readable` annoté (`.size`).
|
|
66
|
+
*
|
|
67
|
+
* **À fournir explicitement pour un `Readable` non annoté** (ex:
|
|
68
|
+
* `fs.createReadStream(path)` → faire `fs.statSync(path).size` au préalable),
|
|
69
|
+
* sinon `0` est envoyé et le backend peut rejeter le multipart.
|
|
70
|
+
*/
|
|
71
|
+
qqtotalfilesize?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Retour normalisé de `Answer.uploadFile()`.
|
|
76
|
+
*/
|
|
77
|
+
export interface UploadAnswerFileResult {
|
|
78
|
+
/** ID du document MongoDB (à stocker dans `{docId, docPath}` pour les inputs uploader). */
|
|
79
|
+
docId: string;
|
|
80
|
+
/** Chemin du document. URL absolue côté backend, à nettoyer en chemin relatif si besoin (cf. `cleanUploaderUrls` site-json). */
|
|
81
|
+
docPath: string;
|
|
82
|
+
/** ID de l'Answer (créée par cet upload si premier, sinon `= this.id`). */
|
|
83
|
+
answerId: string;
|
|
84
|
+
}
|
|
5
85
|
|
|
6
86
|
export class Answer extends BaseEntity<AnswerItemNormalized> {
|
|
7
87
|
static override entityType = "answers";
|
|
8
88
|
|
|
9
89
|
static override entityTag = "Answer";
|
|
10
90
|
static override SCHEMA_CONSTANTS: string[] = [
|
|
91
|
+
"SAVE_COFORM_ANSWER"
|
|
11
92
|
];
|
|
12
93
|
|
|
13
|
-
static ADD_BLOCKS
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
94
|
+
static ADD_BLOCKS = new Map([
|
|
95
|
+
["SAVE_COFORM_ANSWER", "saveCoformAnswer"]
|
|
96
|
+
] as const);
|
|
97
|
+
|
|
98
|
+
static UPDATE_BLOCKS = new Map([
|
|
99
|
+
["SAVE_COFORM_ANSWER", "saveCoformAnswer"]
|
|
100
|
+
] as const);
|
|
101
|
+
|
|
17
102
|
override defaultFields: Record<string, any> = {
|
|
18
103
|
};
|
|
19
|
-
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Champs `serverData` à ne pas renvoyer au serveur dans le payload de save.
|
|
107
|
+
* `answers`, `addedOptions`, `links`, `form` sont conservés (envoyés au serveur).
|
|
108
|
+
* Les autres sont calculés/posés par le backend.
|
|
109
|
+
*/
|
|
110
|
+
override removeFields: string[] = [
|
|
111
|
+
"_id", "collection", "user", "created", "updated",
|
|
112
|
+
"modified", "draft", "finished", "context",
|
|
113
|
+
"voteCount", "vote", "project"
|
|
114
|
+
];
|
|
20
115
|
|
|
21
116
|
/**
|
|
22
117
|
* Transforme les champs imbriqués (user, context etc.) en instances d'entités.
|
|
@@ -69,4 +164,746 @@ export class Answer extends BaseEntity<AnswerItemNormalized> {
|
|
|
69
164
|
override async form(): Promise<never> {
|
|
70
165
|
throw new ApiError(`form n'existe pas dans ${this.constructor.name}`, 501);
|
|
71
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Supprime cette Answer côté serveur via `DELETE_ELEMENT`
|
|
170
|
+
* (`type=answers`, `id=this.id`).
|
|
171
|
+
*
|
|
172
|
+
* Après succès :
|
|
173
|
+
* - `serverData` et `draftData` sont vidés (sans casser la réactivité)
|
|
174
|
+
* - `_isDeleted = true` → toute mutation ultérieure (`save`, `get`, etc.) throw
|
|
175
|
+
*
|
|
176
|
+
* @param reason - Raison libre transmise au backend (audit). Défaut : `"delete coform answer"`.
|
|
177
|
+
* @throws {ApiError} 400 si l'Answer n'a pas d'id (jamais sauvegardée).
|
|
178
|
+
*
|
|
179
|
+
* @example
|
|
180
|
+
* const answer = await form.answer({ id: "..." });
|
|
181
|
+
* await answer.delete();
|
|
182
|
+
* answer._isDeleted; // true
|
|
183
|
+
*/
|
|
184
|
+
async delete(reason: string = "delete coform answer"): Promise<void> {
|
|
185
|
+
if (!this.id) {
|
|
186
|
+
throw new ApiError("Vous devez fournir un id pour supprimer une Answer.", 400);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const data: DeleteElementData = {
|
|
190
|
+
reason,
|
|
191
|
+
pathParams: { type: "answers", id: this.id },
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
await this.callIsConnected(() => this.endpointApi.deleteElement(data));
|
|
195
|
+
|
|
196
|
+
// Vider les objets réactifs sans casser la réactivité (cf. Comment.delete)
|
|
197
|
+
Object.keys(this._draftData).forEach(key => delete this._draftData[key]);
|
|
198
|
+
Object.keys(this._serverData).forEach(key => delete (this._serverData as any)[key]);
|
|
199
|
+
|
|
200
|
+
this._isDeleted = true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Upload un fichier rattaché à cette Answer via `COFORM_UPLOAD_ANSWER_FILE`.
|
|
205
|
+
*
|
|
206
|
+
* **Cas premier upload (Answer sans id)** : le backend crée l'Answer et
|
|
207
|
+
* renvoie `answerId`. La méthode pose alors `this._draftData.id = answerId`
|
|
208
|
+
* — les uploads suivants utiliseront cet id, et un `save()` ultérieur passera
|
|
209
|
+
* en update.
|
|
210
|
+
*
|
|
211
|
+
* **Cas uploads suivants (Answer avec id)** : `answerId: this.id` est envoyé.
|
|
212
|
+
*
|
|
213
|
+
* Compatible browser (File/Blob) et Node (Buffer/Readable) via
|
|
214
|
+
* `BaseEntity._prepareUploadFile()` — le multipart encoder du package
|
|
215
|
+
* `form-data` (utilisé en Node) ignore les Buffer bruts, donc conversion en
|
|
216
|
+
* stream annoté.
|
|
217
|
+
*
|
|
218
|
+
* @param file - Fichier à uploader (File/Blob côté browser, Buffer/Readable côté Node)
|
|
219
|
+
* @param opts - `docType`, `contentKey`, `subKey`, `qquuid`, `qqfilename`
|
|
220
|
+
* @returns `{ docId, docPath, answerId }` — toujours fournis par le backend.
|
|
221
|
+
* @throws {ApiError} si `formId` introuvable, ou réponse invalide.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* // Browser (depuis un <input type="file">)
|
|
225
|
+
* const form = await org.form({ id: formId });
|
|
226
|
+
* const answer = await form.answer(); // draft
|
|
227
|
+
* const { docId, docPath } = await answer.uploadFile(htmlFile, {
|
|
228
|
+
* contentKey: "presentation",
|
|
229
|
+
* subKey: `${subFormId}.${inputId}`,
|
|
230
|
+
* });
|
|
231
|
+
* // answer.id désormais défini → uploads suivants utilisent cet id.
|
|
232
|
+
*
|
|
233
|
+
* @example
|
|
234
|
+
* // Node (depuis un Buffer)
|
|
235
|
+
* const buffer = await fs.promises.readFile("./photo.jpg");
|
|
236
|
+
* const result = await answer.uploadFile(buffer, { docType: "image" });
|
|
237
|
+
*/
|
|
238
|
+
async uploadFile(
|
|
239
|
+
file: UploadInput,
|
|
240
|
+
opts: UploadAnswerFileOptions = {},
|
|
241
|
+
): Promise<UploadAnswerFileResult> {
|
|
242
|
+
const formId = this._resolveFormId({});
|
|
243
|
+
|
|
244
|
+
const fallbackName = opts.qqfilename ?? `upload-${Date.now()}`;
|
|
245
|
+
const { qqfile, qqfilename, qqtotalfilesize } =
|
|
246
|
+
await this._prepareUploadFile(file, fallbackName);
|
|
247
|
+
|
|
248
|
+
const payload: CoformUploadAnswerFileData = {
|
|
249
|
+
formId,
|
|
250
|
+
docType: opts.docType ?? "image",
|
|
251
|
+
contentKey: opts.contentKey ?? "slider",
|
|
252
|
+
qquuid: opts.qquuid ?? this._generateUuid(),
|
|
253
|
+
qqfilename: opts.qqfilename ?? qqfilename,
|
|
254
|
+
// Priorité : override caller > taille déduite (File/Blob/Buffer/Readable.size)
|
|
255
|
+
qqtotalfilesize: opts.qqtotalfilesize ?? qqtotalfilesize,
|
|
256
|
+
qqfile: qqfile as unknown as Record<string, unknown>,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (opts.subKey) payload.subKey = opts.subKey;
|
|
260
|
+
if (this.id) payload.answerId = this.id;
|
|
261
|
+
|
|
262
|
+
const response = await this.callIsConnected(() =>
|
|
263
|
+
this.endpointApi.coformUploadAnswerFile(payload)
|
|
264
|
+
) as {
|
|
265
|
+
result?: boolean;
|
|
266
|
+
success?: boolean;
|
|
267
|
+
msg?: string;
|
|
268
|
+
answerId?: string;
|
|
269
|
+
docPath?: string;
|
|
270
|
+
id?: string;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Forme observée (cf. upload-answer-shape.test.ts) :
|
|
274
|
+
// { result, success, answerId, docPath, id, name, size, msg, _id }
|
|
275
|
+
if (response?.result !== true && response?.success !== true) {
|
|
276
|
+
throw new ApiError(response?.msg || "Échec de l'upload coform.", 500);
|
|
277
|
+
}
|
|
278
|
+
if (!response.answerId || !response.docPath) {
|
|
279
|
+
throw new ApiError("Réponse d'upload incomplète (answerId ou docPath manquant).", 500);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Pose this.id si premier upload (Answer auto-créée par le backend)
|
|
283
|
+
if (!this.id) {
|
|
284
|
+
this._draftData.id = response.answerId;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
docId: response.id ?? "",
|
|
289
|
+
docPath: response.docPath,
|
|
290
|
+
answerId: response.answerId,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Orchestre l'upload de tous les fichiers pendants (`data:URI`) trouvés dans
|
|
296
|
+
* `allData`, puis renvoie la structure transformée prête pour `answer.save()`.
|
|
297
|
+
*
|
|
298
|
+
* **Pipeline complet** :
|
|
299
|
+
* 1. Walk récursif de `allData` → collecte les `data:URI` avec leur path + inputType
|
|
300
|
+
* 2. Premier upload (si Answer sans id) → backend crée l'Answer + pose `this.id`
|
|
301
|
+
* 3. Uploads restants en batches parallèles (défaut 4)
|
|
302
|
+
* 4. Remplacement dans la structure :
|
|
303
|
+
* - inputs `*.uploader` → `{ docId, docPath }`
|
|
304
|
+
* - autres → `docPath` string
|
|
305
|
+
* 5. Normalisation legacy uploader : `Array<{docId, docPath}>` → `{ updateDate, files }`
|
|
306
|
+
* 6. Nettoyage URLs absolues → chemins relatifs (uploader/simpleTable uniquement)
|
|
307
|
+
*
|
|
308
|
+
* Ne touche PAS `addedOptions` ni `links` — caller les pose séparément avant `save()`.
|
|
309
|
+
*
|
|
310
|
+
* @param allData - Données utilisateur `{ subFormId: { inputId: value } }`
|
|
311
|
+
* @param options - `formData` (auto via parent Form), `batchSize` (défaut 4)
|
|
312
|
+
* @returns `allData` transformé, prêt à être affecté à `answer.data.answers`
|
|
313
|
+
*
|
|
314
|
+
* @example
|
|
315
|
+
* const form = await org.form({ id: formId });
|
|
316
|
+
* const answer = await form.answer(); // ou form.answer({ id }) en édition
|
|
317
|
+
*
|
|
318
|
+
* const prepared = await answer.processUploads(allStepsDataFromForm);
|
|
319
|
+
* answer.data.answers = prepared;
|
|
320
|
+
* answer.data.addedOptions = addedOptions; // optionnel
|
|
321
|
+
* answer.data.links = finderLinks; // optionnel
|
|
322
|
+
* await answer.save();
|
|
323
|
+
*/
|
|
324
|
+
async processUploads(
|
|
325
|
+
allData: AllStepsData,
|
|
326
|
+
options: ProcessUploadsOptions = {},
|
|
327
|
+
): Promise<AllStepsData> {
|
|
328
|
+
// Résoudre formData : argument > parent Form
|
|
329
|
+
const formData = options.formData ?? this._resolveFormData();
|
|
330
|
+
|
|
331
|
+
// 1. Collecter tous les pending uploads
|
|
332
|
+
const pending = this._collectPendingUploads(allData, [], formData);
|
|
333
|
+
|
|
334
|
+
if (pending.length === 0) {
|
|
335
|
+
// Pas de fichiers : on nettoie quand même les URLs absolues côté uploader
|
|
336
|
+
return this._cleanUploaderUrls(allData, formData);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let prepared = allData;
|
|
340
|
+
let startIndex = 0;
|
|
341
|
+
|
|
342
|
+
// 2. Premier upload (séparé) si pas d'id — pour récupérer answerId
|
|
343
|
+
if (!this.id) {
|
|
344
|
+
const first = pending[0];
|
|
345
|
+
const replaced = await this._uploadAndReplace(first, prepared, 1);
|
|
346
|
+
prepared = replaced;
|
|
347
|
+
startIndex = 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 3. Uploads restants en batches parallèles
|
|
351
|
+
if (startIndex < pending.length) {
|
|
352
|
+
const remaining = pending.slice(startIndex);
|
|
353
|
+
const batchSize = options.batchSize ?? 4;
|
|
354
|
+
|
|
355
|
+
const replacements = await this._uploadInBatches(
|
|
356
|
+
remaining,
|
|
357
|
+
(item, idx) => this._uploadPendingItem(item, startIndex + idx + 1),
|
|
358
|
+
batchSize,
|
|
359
|
+
);
|
|
360
|
+
|
|
361
|
+
remaining.forEach((item, idx) => {
|
|
362
|
+
prepared = this._setValueAtPath(prepared, item.path, replacements[idx]) as AllStepsData;
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 4. Normalisation legacy uploader (Array → {updateDate, files})
|
|
367
|
+
const uploaderFieldPaths = new Map<string, (string | number)[]>();
|
|
368
|
+
for (const item of pending) {
|
|
369
|
+
if (item.inputType?.endsWith(".uploader") && item.path.length >= 2) {
|
|
370
|
+
const key = JSON.stringify(item.path.slice(0, 2));
|
|
371
|
+
if (!uploaderFieldPaths.has(key)) {
|
|
372
|
+
uploaderFieldPaths.set(key, item.path.slice(0, 2));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
for (const fieldPath of uploaderFieldPaths.values()) {
|
|
377
|
+
const value = this._getValueAtPath(prepared, fieldPath);
|
|
378
|
+
prepared = this._setValueAtPath(
|
|
379
|
+
prepared,
|
|
380
|
+
fieldPath,
|
|
381
|
+
this._normalizeUploaderValue(value),
|
|
382
|
+
) as AllStepsData;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 5. Clean URLs absolues → chemins relatifs (uploader/simpleTable)
|
|
386
|
+
return this._cleanUploaderUrls(prepared, formData);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* UUID v4 compatible browser + Node.
|
|
391
|
+
* Utilise `crypto.randomUUID()` si dispo, sinon fallback Math.random.
|
|
392
|
+
* @private
|
|
393
|
+
*/
|
|
394
|
+
private _generateUuid(): string {
|
|
395
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
396
|
+
return crypto.randomUUID();
|
|
397
|
+
}
|
|
398
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
|
|
399
|
+
const r = Math.random() * 16 | 0;
|
|
400
|
+
const v = c === "x" ? r : (r & 0x3 | 0x8);
|
|
401
|
+
return v.toString(16);
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sauvegarde une nouvelle Answer (sans `id`) via `SAVE_COFORM_ANSWER`.
|
|
407
|
+
*
|
|
408
|
+
* Le payload caller-side attendu (dans `draftData`) :
|
|
409
|
+
* - `answers` (requis) — objet `{ subFormId: { inputId: value } }`
|
|
410
|
+
* - `form` ou parent Form (requis indirectement) — pour résoudre `formId`
|
|
411
|
+
* - `addedOptions` (optionnel) — multiCheckboxPlus dynamiques
|
|
412
|
+
* - `links` (optionnel) — sélections Finder à attacher à l'answer
|
|
413
|
+
*
|
|
414
|
+
* Sérialise automatiquement `answers`/`addedOptions`/`links` en JSON
|
|
415
|
+
* (le wire-format `application/x-www-form-urlencoded` exige des strings).
|
|
416
|
+
*
|
|
417
|
+
* Après succès, l'ID retourné par le serveur est posé dans `_draftData.id`
|
|
418
|
+
* → le `refresh()` de `BaseEntity.save()` peut alors récupérer l'Answer canonique.
|
|
419
|
+
*
|
|
420
|
+
* @protected — utiliser `answer.save()` (BaseEntity dispatch automatiquement).
|
|
421
|
+
*/
|
|
422
|
+
override _add = async (payload: Record<string, any>): Promise<void> => {
|
|
423
|
+
if (!this._calledFromSave) {
|
|
424
|
+
throw new Error("utilisation invalide de _add, utilisez save");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const formId = this._resolveFormId(payload);
|
|
428
|
+
const requestData = this._buildSavePayload(formId, payload, /* withAnswerId */ false);
|
|
429
|
+
|
|
430
|
+
const response = await this.callIsConnected(() =>
|
|
431
|
+
this.endpointApi.saveCoformAnswer(requestData)
|
|
432
|
+
) as { result?: boolean; msg?: string; data?: { id?: string } };
|
|
433
|
+
|
|
434
|
+
// Forme observée du backend (cf. save-answer-shape.test.ts) :
|
|
435
|
+
// { result: true, msg, data: { id: "<24hex>", _id: {_str}, collection, ... } }
|
|
436
|
+
const newId = response?.data?.id;
|
|
437
|
+
if (!this.id && typeof newId === "string" && newId.length === 24) {
|
|
438
|
+
this._draftData.id = newId;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Met à jour une Answer existante via `SAVE_COFORM_ANSWER` (avec `answerId`).
|
|
444
|
+
*
|
|
445
|
+
* Si `payload.answers` est `undefined` (le caller a modifié uniquement
|
|
446
|
+
* `addedOptions` ou `links`), on retombe sur `serverData.answers` pour ne
|
|
447
|
+
* pas envoyer un payload vide qui écraserait les réponses existantes.
|
|
448
|
+
*
|
|
449
|
+
* @protected — utiliser `answer.save()`.
|
|
450
|
+
*/
|
|
451
|
+
override _update = async (payload: Record<string, any>): Promise<boolean> => {
|
|
452
|
+
if (!this._calledFromSave) {
|
|
453
|
+
throw new Error("utilisation invalide de _update, utilisez save");
|
|
454
|
+
}
|
|
455
|
+
if (!this.id) {
|
|
456
|
+
throw new ApiError("Answer sans id, impossible de mettre à jour.", 400);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const formId = this._resolveFormId(payload);
|
|
460
|
+
const requestData = this._buildSavePayload(formId, payload, /* withAnswerId */ true);
|
|
461
|
+
|
|
462
|
+
await this.callIsConnected(() => this.endpointApi.saveCoformAnswer(requestData));
|
|
463
|
+
return true;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Résout le `formId` à envoyer à `saveCoformAnswer`.
|
|
468
|
+
*
|
|
469
|
+
* Priorité :
|
|
470
|
+
* 1. `payload.form` (posé via `draftData.form` par `form.answer()`)
|
|
471
|
+
* 2. `serverData.form` (Answer chargée via `get()`)
|
|
472
|
+
* 3. `this.parent.id` si le parent est un `Form`
|
|
473
|
+
*
|
|
474
|
+
* @throws {ApiError} 400 si aucune source ne fournit un formId valide.
|
|
475
|
+
* @private
|
|
476
|
+
*/
|
|
477
|
+
private _resolveFormId(payload: Record<string, any>): string {
|
|
478
|
+
const fromDraftOrPayload = typeof payload.form === "string" ? payload.form : undefined;
|
|
479
|
+
const fromServer = typeof this._serverData.form === "string" ? this._serverData.form : undefined;
|
|
480
|
+
const parent = this.parent;
|
|
481
|
+
const fromParent =
|
|
482
|
+
parent && typeof (parent as any).getEntityType === "function"
|
|
483
|
+
&& (parent as any).getEntityType() === "forms"
|
|
484
|
+
&& typeof (parent as any).id === "string"
|
|
485
|
+
? (parent as any).id as string
|
|
486
|
+
: undefined;
|
|
487
|
+
|
|
488
|
+
const formId = fromDraftOrPayload ?? fromServer ?? fromParent;
|
|
489
|
+
if (typeof formId !== "string" || formId.length === 0) {
|
|
490
|
+
throw new ApiError(
|
|
491
|
+
"formId introuvable pour sauvegarder l'Answer. "
|
|
492
|
+
+ "Créez l'Answer via `form.answer()` ou définissez `answer.data.form` manuellement.",
|
|
493
|
+
400
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
return formId;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Construit le payload `SaveCoformAnswerData` en sérialisant les champs JSON
|
|
501
|
+
* requis par le wire-format `application/x-www-form-urlencoded`.
|
|
502
|
+
*
|
|
503
|
+
* Si `payload.answers` est absent en update, retombe sur `serverData.answers`
|
|
504
|
+
* (cf. design point 6.5 — indulgent envers le caller).
|
|
505
|
+
*
|
|
506
|
+
* @private
|
|
507
|
+
*/
|
|
508
|
+
private _buildSavePayload(
|
|
509
|
+
formId: string,
|
|
510
|
+
payload: Record<string, any>,
|
|
511
|
+
withAnswerId: boolean
|
|
512
|
+
): SaveCoformAnswerData {
|
|
513
|
+
const answersValue = payload.answers ?? (withAnswerId ? this._serverData.answers : undefined);
|
|
514
|
+
if (answersValue === undefined || answersValue === null) {
|
|
515
|
+
throw new ApiError(
|
|
516
|
+
"answer.data.answers est requis (objet `{ subFormId: { inputId: value } }`).",
|
|
517
|
+
400
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const data: SaveCoformAnswerData = {
|
|
522
|
+
formId,
|
|
523
|
+
answers: JSON.stringify(answersValue),
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
if (withAnswerId) {
|
|
527
|
+
data.answerId = this.id!;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (payload.addedOptions && typeof payload.addedOptions === "object"
|
|
531
|
+
&& Object.keys(payload.addedOptions).length > 0) {
|
|
532
|
+
data.addedOptions = JSON.stringify(payload.addedOptions);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (payload.links && typeof payload.links === "object"
|
|
536
|
+
&& Object.keys(payload.links).length > 0) {
|
|
537
|
+
data.links = JSON.stringify(payload.links);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return data;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ===========================================================================
|
|
544
|
+
// Helpers internes pour processUploads — équivalents portés depuis
|
|
545
|
+
// site-json/coform/actions/mutations/uploadHelpers.ts (Sprint 3).
|
|
546
|
+
// ===========================================================================
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Récupère `formData` (schéma du Form) depuis le parent si c'est un Form.
|
|
550
|
+
* Renvoie `undefined` si pas exploitable — `processUploads` continuera mais
|
|
551
|
+
* sans détection automatique d'`inputType` (pas de normalisation uploader,
|
|
552
|
+
* pas de clean URLs).
|
|
553
|
+
* @private
|
|
554
|
+
*/
|
|
555
|
+
private _resolveFormData(): FormItemNormalized | undefined {
|
|
556
|
+
const parent = this.parent;
|
|
557
|
+
if (parent && typeof (parent as any).getEntityType === "function"
|
|
558
|
+
&& (parent as any).getEntityType() === "forms") {
|
|
559
|
+
return (parent as any).serverData as FormItemNormalized;
|
|
560
|
+
}
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private _isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
565
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private _isDataUri(value: string): boolean {
|
|
569
|
+
return /^data:[^;]+;base64,/.test(value);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private _isPendingUploadValue(value: unknown): value is PendingUploadValue {
|
|
573
|
+
if (!this._isObjectRecord(value)) return false;
|
|
574
|
+
return typeof value.data === "string" && this._isDataUri(value.data);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private _parseMimeType(dataUri: string): string {
|
|
578
|
+
const match = dataUri.match(/^data:([^;]+);base64,/i);
|
|
579
|
+
return match?.[1]?.toLowerCase() ?? "application/octet-stream";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private _inferExtensionFromMimeType(mimeType: string): string {
|
|
583
|
+
const map: Record<string, string> = {
|
|
584
|
+
"image/jpeg": "jpg",
|
|
585
|
+
"image/jpg": "jpg",
|
|
586
|
+
"image/png": "png",
|
|
587
|
+
"image/gif": "gif",
|
|
588
|
+
"image/webp": "webp",
|
|
589
|
+
"image/svg+xml": "svg",
|
|
590
|
+
"application/pdf": "pdf",
|
|
591
|
+
"text/plain": "txt",
|
|
592
|
+
"text/csv": "csv",
|
|
593
|
+
"application/msword": "doc",
|
|
594
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
595
|
+
"application/vnd.ms-excel": "xls",
|
|
596
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
597
|
+
"application/vnd.ms-powerpoint": "ppt",
|
|
598
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
599
|
+
"application/zip": "zip",
|
|
600
|
+
"application/x-rar-compressed": "rar",
|
|
601
|
+
};
|
|
602
|
+
return map[mimeType] ?? "bin";
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private _sanitizeBaseName(fileName: string): string {
|
|
606
|
+
const raw = fileName.replace(/\.[^.]+$/, "");
|
|
607
|
+
const cleaned = raw.replace(/[^a-zA-Z0-9_. -]+/g, "-").trim();
|
|
608
|
+
return cleaned || "upload";
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Convertit un `PendingUploadValue` (data:URI) en `{file, docType, filename}`
|
|
613
|
+
* exploitable par `uploadFile()`. Compatible browser + Node :
|
|
614
|
+
* - Browser → `File` natif
|
|
615
|
+
* - Node → `Buffer` (sera converti en stream par `_prepareUploadFile()`)
|
|
616
|
+
* @private
|
|
617
|
+
*/
|
|
618
|
+
private async _dataUriToFile(
|
|
619
|
+
value: PendingUploadValue,
|
|
620
|
+
fallbackName: string,
|
|
621
|
+
): Promise<{ file: UploadInput; docType: "image" | "file"; filename: string }> {
|
|
622
|
+
const mime = this._parseMimeType(value.data);
|
|
623
|
+
const docType: "image" | "file" = mime.startsWith("image/") ? "image" : "file";
|
|
624
|
+
|
|
625
|
+
const extension = this._inferExtensionFromMimeType(mime);
|
|
626
|
+
const sourceName = value.name && value.name.trim() !== "" ? value.name : fallbackName;
|
|
627
|
+
const hasExtension = /\.[a-zA-Z0-9]+$/.test(sourceName);
|
|
628
|
+
const filename = hasExtension ? sourceName : `${this._sanitizeBaseName(sourceName)}.${extension}`;
|
|
629
|
+
|
|
630
|
+
const isBrowser = typeof window !== "undefined" && typeof File !== "undefined";
|
|
631
|
+
|
|
632
|
+
if (isBrowser) {
|
|
633
|
+
const blob = await (await fetch(value.data)).blob();
|
|
634
|
+
return { file: new File([blob], filename, { type: mime }), docType, filename };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Node : Buffer (sera converti en stream par uploadFile → _prepareUploadFile)
|
|
638
|
+
const base64Match = value.data.match(/^data:[^;]+;base64,(.*)$/);
|
|
639
|
+
if (!base64Match) {
|
|
640
|
+
throw new ApiError("data:URI invalide (base64 attendu).", 400);
|
|
641
|
+
}
|
|
642
|
+
const buffer = Buffer.from(base64Match[1], "base64");
|
|
643
|
+
return { file: buffer, docType, filename };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Parcourt récursivement `allData` pour trouver tous les `data:URI` à uploader.
|
|
648
|
+
* Renvoie chaque pending avec son `path` (chemin imbriqué) et `inputType`
|
|
649
|
+
* (déduit du schéma `formData` si fourni).
|
|
650
|
+
* @private
|
|
651
|
+
*/
|
|
652
|
+
private _collectPendingUploads(
|
|
653
|
+
node: unknown,
|
|
654
|
+
path: (string | number)[] = [],
|
|
655
|
+
formData?: FormItemNormalized,
|
|
656
|
+
): PendingUpload[] {
|
|
657
|
+
const uploads: PendingUpload[] = [];
|
|
658
|
+
|
|
659
|
+
const getInputType = (): string | undefined => {
|
|
660
|
+
if (!formData || path.length < 2) return undefined;
|
|
661
|
+
const [subFormId, inputId] = path;
|
|
662
|
+
if (typeof subFormId !== "string" || typeof inputId !== "string") return undefined;
|
|
663
|
+
const subForm = formData.inputs?.[subFormId];
|
|
664
|
+
if (!subForm) return undefined;
|
|
665
|
+
const input = subForm.inputs?.[inputId];
|
|
666
|
+
return input && typeof input === "object" && "type" in input
|
|
667
|
+
? (input.type as string | undefined)
|
|
668
|
+
: undefined;
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
if (typeof node === "string") {
|
|
672
|
+
if (this._isDataUri(node)) {
|
|
673
|
+
uploads.push({ value: { data: node }, path, inputType: getInputType() });
|
|
674
|
+
}
|
|
675
|
+
return uploads;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (this._isPendingUploadValue(node)) {
|
|
679
|
+
uploads.push({ value: node, path, inputType: getInputType() });
|
|
680
|
+
return uploads;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if (Array.isArray(node)) {
|
|
684
|
+
node.forEach((item, index) => {
|
|
685
|
+
uploads.push(...this._collectPendingUploads(item, [...path, index], formData));
|
|
686
|
+
});
|
|
687
|
+
return uploads;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (this._isObjectRecord(node)) {
|
|
691
|
+
Object.entries(node).forEach(([key, value]) => {
|
|
692
|
+
uploads.push(...this._collectPendingUploads(value, [...path, key], formData));
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return uploads;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* Détermine `contentKey` et `subKey` pour un upload selon le type d'input :
|
|
701
|
+
* - `*.uploader` → `presentation` + subKey `"subFormId.inputId"`
|
|
702
|
+
* - autres → `slider` sans subKey
|
|
703
|
+
* @private
|
|
704
|
+
*/
|
|
705
|
+
private _getUploadKeys(
|
|
706
|
+
inputType: string | undefined,
|
|
707
|
+
path: (string | number)[],
|
|
708
|
+
): { contentKey: string; subKey?: string } {
|
|
709
|
+
if (inputType && (inputType === "uploader" || inputType.endsWith(".uploader"))) {
|
|
710
|
+
const subKey = path
|
|
711
|
+
.slice(0, 2)
|
|
712
|
+
.filter(p => typeof p === "string")
|
|
713
|
+
.join(".");
|
|
714
|
+
return { contentKey: "presentation", subKey: subKey || undefined };
|
|
715
|
+
}
|
|
716
|
+
return { contentKey: "slider" };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
private _getValueAtPath(obj: unknown, path: (string | number)[]): unknown {
|
|
720
|
+
if (path.length === 0) return obj;
|
|
721
|
+
const [first, ...rest] = path;
|
|
722
|
+
if (Array.isArray(obj)) return this._getValueAtPath(obj[first as number], rest);
|
|
723
|
+
if (this._isObjectRecord(obj)) return this._getValueAtPath(obj[first as string], rest);
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private _setValueAtPath(obj: unknown, path: (string | number)[], value: unknown): unknown {
|
|
728
|
+
if (path.length === 0) return value;
|
|
729
|
+
const [first, ...rest] = path;
|
|
730
|
+
if (Array.isArray(obj)) {
|
|
731
|
+
const next = [...obj];
|
|
732
|
+
next[first as number] = this._setValueAtPath(next[first as number], rest, value);
|
|
733
|
+
return next;
|
|
734
|
+
}
|
|
735
|
+
if (this._isObjectRecord(obj)) {
|
|
736
|
+
return { ...obj, [first]: this._setValueAtPath(obj[first as string], rest, value) };
|
|
737
|
+
}
|
|
738
|
+
return obj;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Normalise une valeur d'input uploader vers le format legacy backend :
|
|
743
|
+
* `{ updateDate: ["DD/MM/YYYY"], files: { "<docId>": "<docPath>", ... } }`
|
|
744
|
+
* @private
|
|
745
|
+
*/
|
|
746
|
+
private _normalizeUploaderValue(value: unknown): unknown {
|
|
747
|
+
const today = new Date().toLocaleDateString("fr-FR");
|
|
748
|
+
|
|
749
|
+
const toFilesObject = (arr: unknown[]): Record<string, string> => {
|
|
750
|
+
const obj: Record<string, string> = {};
|
|
751
|
+
for (const item of arr) {
|
|
752
|
+
if (this._isObjectRecord(item)
|
|
753
|
+
&& typeof item.docId === "string"
|
|
754
|
+
&& typeof item.docPath === "string") {
|
|
755
|
+
obj[item.docId as string] = item.docPath as string;
|
|
756
|
+
} else if (typeof item === "string") {
|
|
757
|
+
obj[item] = item;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return obj;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
if (this._isObjectRecord(value) && "updateDate" in value && Array.isArray(value.updateDate)) {
|
|
764
|
+
if (this._isObjectRecord(value.files)) return value;
|
|
765
|
+
if (Array.isArray(value.files)) return { ...value, files: toFilesObject(value.files) };
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (Array.isArray(value)) {
|
|
770
|
+
return { updateDate: [today], files: toFilesObject(value) };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return value;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private _cleanUrlToRelativePath(value: unknown): unknown {
|
|
777
|
+
if (typeof value === "string") {
|
|
778
|
+
if (this._isDataUri(value)) return value;
|
|
779
|
+
const urlMatch = value.match(/^https?:\/\/[^/]+(\/.*)/i);
|
|
780
|
+
if (urlMatch) return urlMatch[1];
|
|
781
|
+
return value;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (Array.isArray(value)) return value.map(v => this._cleanUrlToRelativePath(v));
|
|
785
|
+
|
|
786
|
+
if (this._isObjectRecord(value)) {
|
|
787
|
+
if ("data" in value && typeof value.data === "string") {
|
|
788
|
+
const cleanedData = !this._isDataUri(value.data)
|
|
789
|
+
? this._cleanUrlToRelativePath(value.data)
|
|
790
|
+
: value.data;
|
|
791
|
+
return { ...value, data: cleanedData };
|
|
792
|
+
}
|
|
793
|
+
const cleaned: Record<string, unknown> = {};
|
|
794
|
+
for (const [k, v] of Object.entries(value)) {
|
|
795
|
+
cleaned[k] = this._cleanUrlToRelativePath(v);
|
|
796
|
+
}
|
|
797
|
+
return cleaned;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Suffixes de types d'inputs dont les valeurs contiennent des URLs de fichiers
|
|
805
|
+
* à nettoyer avant envoi au serveur.
|
|
806
|
+
* @private
|
|
807
|
+
*/
|
|
808
|
+
private static readonly INPUT_TYPES_TO_CLEAN_URLS = [".uploader", ".simpleTable"] as const;
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Nettoie les URLs absolues → chemins relatifs **uniquement** sur les champs
|
|
812
|
+
* uploader/simpleTable. Les autres champs (finder, text…) peuvent contenir
|
|
813
|
+
* des URLs légitimes qu'il ne faut pas tronquer.
|
|
814
|
+
* @private
|
|
815
|
+
*/
|
|
816
|
+
private _cleanUploaderUrls(
|
|
817
|
+
data: AllStepsData,
|
|
818
|
+
formData: FormItemNormalized | undefined,
|
|
819
|
+
): AllStepsData {
|
|
820
|
+
if (!formData) return data;
|
|
821
|
+
const result: Record<string, unknown> = {};
|
|
822
|
+
|
|
823
|
+
for (const [subFormId, subFormData] of Object.entries(data)) {
|
|
824
|
+
if (!this._isObjectRecord(subFormData)) {
|
|
825
|
+
result[subFormId] = subFormData;
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const subForm = formData.inputs?.[subFormId];
|
|
829
|
+
if (!subForm) {
|
|
830
|
+
result[subFormId] = subFormData;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
const cleaned: Record<string, unknown> = {};
|
|
834
|
+
for (const [inputId, inputValue] of Object.entries(subFormData as Record<string, unknown>)) {
|
|
835
|
+
const input = subForm.inputs?.[inputId];
|
|
836
|
+
const inputType = input && typeof input === "object" && "type" in input
|
|
837
|
+
? (input.type as string | undefined)
|
|
838
|
+
: undefined;
|
|
839
|
+
const shouldClean = !!inputType
|
|
840
|
+
&& Answer.INPUT_TYPES_TO_CLEAN_URLS.some(suffix => inputType.endsWith(suffix));
|
|
841
|
+
cleaned[inputId] = shouldClean
|
|
842
|
+
? this._cleanUrlToRelativePath(inputValue)
|
|
843
|
+
: inputValue;
|
|
844
|
+
}
|
|
845
|
+
result[subFormId] = cleaned;
|
|
846
|
+
}
|
|
847
|
+
return result as AllStepsData;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Upload par batches parallèles (défaut : 4 fichiers simultanés).
|
|
852
|
+
* @private
|
|
853
|
+
*/
|
|
854
|
+
private async _uploadInBatches<T, R>(
|
|
855
|
+
items: T[],
|
|
856
|
+
uploadFn: (item: T, index: number) => Promise<R>,
|
|
857
|
+
batchSize: number = 4,
|
|
858
|
+
): Promise<R[]> {
|
|
859
|
+
const results: R[] = [];
|
|
860
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
861
|
+
const batch = items.slice(i, i + batchSize);
|
|
862
|
+
const batchResults = await Promise.all(
|
|
863
|
+
batch.map((item, batchIndex) => uploadFn(item, i + batchIndex)),
|
|
864
|
+
);
|
|
865
|
+
results.push(...batchResults);
|
|
866
|
+
}
|
|
867
|
+
return results;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Upload un PendingUpload, retourne la valeur de remplacement à insérer
|
|
872
|
+
* dans `allData` (`{docId, docPath}` pour uploader, `docPath` string sinon).
|
|
873
|
+
* @private
|
|
874
|
+
*/
|
|
875
|
+
private async _uploadPendingItem(
|
|
876
|
+
item: PendingUpload,
|
|
877
|
+
index: number,
|
|
878
|
+
): Promise<{ docId: string; docPath: string } | string> {
|
|
879
|
+
const fallbackName = `upload-${index}`;
|
|
880
|
+
const { file, docType, filename } = await this._dataUriToFile(item.value, fallbackName);
|
|
881
|
+
const { contentKey, subKey } = this._getUploadKeys(item.inputType, item.path);
|
|
882
|
+
|
|
883
|
+
const result = await this.uploadFile(file, {
|
|
884
|
+
docType,
|
|
885
|
+
contentKey,
|
|
886
|
+
subKey,
|
|
887
|
+
qqfilename: filename,
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
const isUploader = item.inputType?.endsWith(".uploader") ?? false;
|
|
891
|
+
return isUploader
|
|
892
|
+
? { docId: result.docId, docPath: result.docPath }
|
|
893
|
+
: result.docPath;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Upload un PendingUpload + remplace immédiatement dans `prepared` à son path.
|
|
898
|
+
* Utilisé pour le premier upload (séquentiel) qui crée l'Answer.
|
|
899
|
+
* @private
|
|
900
|
+
*/
|
|
901
|
+
private async _uploadAndReplace(
|
|
902
|
+
item: PendingUpload,
|
|
903
|
+
prepared: AllStepsData,
|
|
904
|
+
index: number,
|
|
905
|
+
): Promise<AllStepsData> {
|
|
906
|
+
const replacement = await this._uploadPendingItem(item, index);
|
|
907
|
+
return this._setValueAtPath(prepared, item.path, replacement) as AllStepsData;
|
|
908
|
+
}
|
|
72
909
|
}
|