@adminforth/upload 2.12.0 → 2.14.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.
- package/build.log +2 -2
- package/custom/imageGenerator.vue +187 -23
- package/custom/uploader.vue +2 -1
- package/dist/custom/imageGenerator.vue +187 -23
- package/dist/custom/uploader.vue +2 -1
- package/dist/index.js +232 -26
- package/index.ts +298 -34
- package/package.json +1 -1
- package/types.ts +103 -0
package/build.log
CHANGED
|
@@ -11,5 +11,5 @@ custom/preview.vue
|
|
|
11
11
|
custom/tsconfig.json
|
|
12
12
|
custom/uploader.vue
|
|
13
13
|
|
|
14
|
-
sent
|
|
15
|
-
total size is
|
|
14
|
+
sent 59,824 bytes received 134 bytes 119,916.00 bytes/sec
|
|
15
|
+
total size is 59,335 speedup is 0.99
|
|
@@ -34,14 +34,43 @@
|
|
|
34
34
|
|
|
35
35
|
<!-- Thumbnails -->
|
|
36
36
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
37
|
-
<img
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
<div class="group relative" v-for="(img, key) in requestAttachmentFilesUrls">
|
|
38
|
+
<img
|
|
39
|
+
:key="key"
|
|
40
|
+
:src="img"
|
|
41
|
+
class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
|
|
42
|
+
:alt="`Generated image ${key + 1}`"
|
|
43
|
+
@click="zoomImage(img)"
|
|
44
|
+
/>
|
|
45
|
+
<div
|
|
46
|
+
class="opacity-0 group-hover:opacity-100 flex items-center justify-center w-5 h-5 bg-black absolute -top-2 -end-2 rounded-full border-2 border-white cursor-pointer hover:border-gray-300 hover:scale-110"
|
|
47
|
+
@click="removeFileFromList(key)"
|
|
48
|
+
>
|
|
49
|
+
<Tooltip class="absolute top-0 end-0">
|
|
50
|
+
<div>
|
|
51
|
+
<div class="w-4 h-4 absolute"></div>
|
|
52
|
+
<IconCloseOutline class="w-3 h-3 text-white hover:text-gray-300" />
|
|
53
|
+
</div>
|
|
54
|
+
<template #tooltip>
|
|
55
|
+
Remove file
|
|
56
|
+
</template>
|
|
57
|
+
</Tooltip>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<input
|
|
61
|
+
ref="fileInput"
|
|
62
|
+
class="hidden"
|
|
63
|
+
type="file"
|
|
64
|
+
@change="handleAddFile"
|
|
65
|
+
accept="image/*"
|
|
44
66
|
/>
|
|
67
|
+
<button v-if="!uploading" @click="fileInput?.click()" type="button" class="relative group hover:border-gray-500 transition border-gray-300 flex items-center justify-center w-20 h-20 border-2 border-dashed rounded-md">
|
|
68
|
+
<div class="flex flex-col items-center justify-center gap-2 mt-4 mb-4">
|
|
69
|
+
<IconCloseOutline class="group-hover:text-gray-500 transition rotate-45 w-6 h-6 text-gray-300 hover:text-gray-300" />
|
|
70
|
+
<p class="text-gray-300 group-hover:text-gray-500 transition bottom-0">Ctrl + v</p>
|
|
71
|
+
</div>
|
|
72
|
+
</button>
|
|
73
|
+
<Skeleton v-else type="image" class="w-20 h-20" />
|
|
45
74
|
</div>
|
|
46
75
|
|
|
47
76
|
<!-- Fullscreen Modal -->
|
|
@@ -175,38 +204,39 @@
|
|
|
175
204
|
|
|
176
205
|
<script setup lang="ts">
|
|
177
206
|
|
|
178
|
-
import { ref, onMounted, nextTick, Ref,
|
|
207
|
+
import { ref, onMounted, nextTick, Ref, watch, onUnmounted } from 'vue'
|
|
179
208
|
import { Carousel } from 'flowbite';
|
|
180
209
|
import { callAdminForthApi } from '@/utils';
|
|
181
210
|
import { useI18n } from 'vue-i18n';
|
|
182
211
|
import adminforth from '@/adminforth';
|
|
183
212
|
import { ProgressBar } from '@/afcl';
|
|
184
213
|
import * as Handlebars from 'handlebars';
|
|
214
|
+
import { IconCloseOutline } from '@iconify-prerendered/vue-flowbite';
|
|
215
|
+
import { Tooltip, Skeleton } from '@/afcl'
|
|
216
|
+
import { useRoute } from 'vue-router';
|
|
217
|
+
|
|
218
|
+
const { t: $t, t } = useI18n();
|
|
219
|
+
const route = useRoute();
|
|
185
220
|
|
|
186
|
-
const { t: $t } = useI18n();
|
|
187
221
|
|
|
188
222
|
const prompt = ref('');
|
|
189
223
|
const emit = defineEmits(['close', 'uploadImage']);
|
|
190
|
-
const props = defineProps(
|
|
224
|
+
const props = defineProps({
|
|
225
|
+
meta: Object,
|
|
226
|
+
record: Object,
|
|
227
|
+
humanifySize: Function,
|
|
228
|
+
});
|
|
191
229
|
const images = ref([]);
|
|
192
230
|
const loading = ref(false);
|
|
193
231
|
const attachmentFiles = ref<string[]>([])
|
|
232
|
+
const requestAttachmentFiles = ref<Blob[] | null>([]);
|
|
233
|
+
const requestAttachmentFilesUrls = ref<string[]>([]);
|
|
194
234
|
const stopGeneration = ref(false);
|
|
195
|
-
|
|
196
|
-
function minifyField(field: string): string {
|
|
197
|
-
if (field.length > 100) {
|
|
198
|
-
return field.slice(0, 100) + '...';
|
|
199
|
-
}
|
|
200
|
-
return field;
|
|
201
|
-
}
|
|
235
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
202
236
|
|
|
203
237
|
const caurosel = ref(null);
|
|
204
238
|
onMounted(async () => {
|
|
205
239
|
// Initialize carousel
|
|
206
|
-
const context = {
|
|
207
|
-
field: props.meta.pathColumnLabel,
|
|
208
|
-
resource: props.meta.resourceLabel,
|
|
209
|
-
};
|
|
210
240
|
let template = '';
|
|
211
241
|
if (props.meta.generationPrompt) {
|
|
212
242
|
template = props.meta.generationPrompt;
|
|
@@ -230,13 +260,20 @@ onMounted(async () => {
|
|
|
230
260
|
});
|
|
231
261
|
|
|
232
262
|
if (resp?.files?.length) {
|
|
233
|
-
|
|
263
|
+
requestAttachmentFilesUrls.value = resp.files;
|
|
234
264
|
}
|
|
235
265
|
} catch (err) {
|
|
236
266
|
console.error('Failed to fetch attachment files', err);
|
|
237
267
|
}
|
|
268
|
+
|
|
269
|
+
for ( const fileUrl in requestAttachmentFilesUrls.value ) {
|
|
270
|
+
const image = await fetch(fileUrl);
|
|
271
|
+
const imageBlob = await image.blob();
|
|
272
|
+
requestAttachmentFiles.value!.push(imageBlob);
|
|
273
|
+
}
|
|
238
274
|
});
|
|
239
275
|
|
|
276
|
+
|
|
240
277
|
async function slide(direction: number) {
|
|
241
278
|
if (!caurosel.value) return;
|
|
242
279
|
const curPos = caurosel.value.getActiveItem().position;
|
|
@@ -323,11 +360,13 @@ async function generateImages() {
|
|
|
323
360
|
method: 'POST',
|
|
324
361
|
body: {
|
|
325
362
|
prompt: prompt.value,
|
|
326
|
-
recordId: props.record[props.meta.recorPkFieldName]
|
|
363
|
+
recordId: props.record[props.meta.recorPkFieldName],
|
|
364
|
+
requestAttachmentFiles: requestAttachmentFilesUrls.value,
|
|
327
365
|
},
|
|
328
366
|
});
|
|
329
367
|
} catch (e) {
|
|
330
368
|
console.error(e);
|
|
369
|
+
return;
|
|
331
370
|
}
|
|
332
371
|
|
|
333
372
|
if (resp?.error) {
|
|
@@ -451,4 +490,129 @@ watch(zoomedImage, async (val) => {
|
|
|
451
490
|
}).show()
|
|
452
491
|
}
|
|
453
492
|
})
|
|
493
|
+
|
|
494
|
+
async function handleAddFile(event, clipboardFile = null) {
|
|
495
|
+
if (clipboardFile) {
|
|
496
|
+
clipboardFile = renameFile(clipboardFile, `pasted_image_${Date.now()}.png`);
|
|
497
|
+
}
|
|
498
|
+
const files = event?.target?.files || (clipboardFile ? [clipboardFile] : []);
|
|
499
|
+
for (let i = 0; i < files.length; i++) {
|
|
500
|
+
if (requestAttachmentFiles.value.find((f: any) => f.name === files[i].name)) {
|
|
501
|
+
adminforth.alert({
|
|
502
|
+
message: $t('File with the same name already added'),
|
|
503
|
+
variant: 'warning',
|
|
504
|
+
timeout: 5000,
|
|
505
|
+
});
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const file = files[i];
|
|
509
|
+
const fileUrl = await uploadFile(file);
|
|
510
|
+
if (!fileUrl) continue;
|
|
511
|
+
requestAttachmentFiles.value!.push(file);
|
|
512
|
+
requestAttachmentFilesUrls.value.push(fileUrl);
|
|
513
|
+
}
|
|
514
|
+
fileInput.value!.value = '';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function removeFileFromList(index: number) {
|
|
518
|
+
requestAttachmentFiles.value!.splice(index, 1);
|
|
519
|
+
requestAttachmentFilesUrls.value.splice(index, 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const uploading = ref(false);
|
|
523
|
+
|
|
524
|
+
async function uploadFile(file: any): Promise<string> {
|
|
525
|
+
const { name, size, type } = file;
|
|
526
|
+
|
|
527
|
+
let imgPreview = '';
|
|
528
|
+
|
|
529
|
+
const extension = name.split('.').pop();
|
|
530
|
+
const nameNoExtension = name.replace(`.${extension}`, '');
|
|
531
|
+
|
|
532
|
+
if (props.meta.maxFileSize && size > props.meta.maxFileSize) {
|
|
533
|
+
adminforth.alert({
|
|
534
|
+
message: t('Sorry but the file size {size} is too large. Please upload a file with a maximum size of {maxFileSize}', {
|
|
535
|
+
size: props.humanifySize(size),
|
|
536
|
+
maxFileSize: props.humanifySize(props.meta.maxFileSize),
|
|
537
|
+
}),
|
|
538
|
+
variant: 'danger'
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
uploading.value = true;
|
|
545
|
+
const { uploadUrl, uploadExtraParams, filePath, error, previewUrl } = await callAdminForthApi({
|
|
546
|
+
path: `/plugin/${props.meta.pluginInstanceId}/get_file_upload_url`,
|
|
547
|
+
method: 'POST',
|
|
548
|
+
body: {
|
|
549
|
+
originalFilename: nameNoExtension,
|
|
550
|
+
contentType: type,
|
|
551
|
+
size,
|
|
552
|
+
originalExtension: extension,
|
|
553
|
+
recordPk: route?.params?.primaryKey,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (error) {
|
|
557
|
+
adminforth.alert({
|
|
558
|
+
message: t('File was not uploaded because of error: {error}', { error }),
|
|
559
|
+
variant: 'danger'
|
|
560
|
+
});
|
|
561
|
+
imgPreview = null;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const xhr = new XMLHttpRequest();
|
|
566
|
+
const success = await new Promise((resolve) => {
|
|
567
|
+
xhr.addEventListener('loadend', () => {
|
|
568
|
+
const success = xhr.readyState === 4 && xhr.status === 200;
|
|
569
|
+
// try to read response
|
|
570
|
+
resolve(success);
|
|
571
|
+
});
|
|
572
|
+
xhr.open('PUT', uploadUrl, true);
|
|
573
|
+
xhr.setRequestHeader('Content-Type', type);
|
|
574
|
+
uploadExtraParams && Object.entries(uploadExtraParams).forEach(([key, value]: [string, string]) => {
|
|
575
|
+
xhr.setRequestHeader(key, value);
|
|
576
|
+
})
|
|
577
|
+
xhr.send(file);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (success) {
|
|
581
|
+
imgPreview = previewUrl;
|
|
582
|
+
} else {
|
|
583
|
+
throw new Error('File upload failed');
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
uploading.value = false;
|
|
587
|
+
console.error('File upload failed', err);
|
|
588
|
+
}
|
|
589
|
+
uploading.value = false;
|
|
590
|
+
return imgPreview;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function uploadImageOnPaste(event) {
|
|
594
|
+
const items = event.clipboardData?.items;
|
|
595
|
+
if (!items) return;
|
|
596
|
+
|
|
597
|
+
for (let item of items) {
|
|
598
|
+
if (item.type.startsWith('image/')) {
|
|
599
|
+
const file = item.getAsFile();
|
|
600
|
+
if (file) {
|
|
601
|
+
await handleAddFile(null, file);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function renameFile(file, newName) {
|
|
608
|
+
return new File([file], newName, { type: file.type });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
onMounted(() => {
|
|
612
|
+
document.addEventListener('paste', uploadImageOnPaste);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
onUnmounted(() => {
|
|
616
|
+
document.removeEventListener('paste', uploadImageOnPaste);
|
|
617
|
+
});
|
|
454
618
|
</script>
|
package/custom/uploader.vue
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="relative w-full">
|
|
3
3
|
<ImageGenerator v-if="showImageGen" @close="showImageGen = false" :record="record" :meta="meta"
|
|
4
|
-
@uploadImage="uploadGeneratedImage"
|
|
4
|
+
@uploadImage="uploadGeneratedImage"
|
|
5
|
+
:humanifySize="humanifySize"
|
|
5
6
|
></ImageGenerator>
|
|
6
7
|
|
|
7
8
|
<button v-if="meta.generateImages"
|
|
@@ -34,14 +34,43 @@
|
|
|
34
34
|
|
|
35
35
|
<!-- Thumbnails -->
|
|
36
36
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
37
|
-
<img
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
<div class="group relative" v-for="(img, key) in requestAttachmentFilesUrls">
|
|
38
|
+
<img
|
|
39
|
+
:key="key"
|
|
40
|
+
:src="img"
|
|
41
|
+
class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
|
|
42
|
+
:alt="`Generated image ${key + 1}`"
|
|
43
|
+
@click="zoomImage(img)"
|
|
44
|
+
/>
|
|
45
|
+
<div
|
|
46
|
+
class="opacity-0 group-hover:opacity-100 flex items-center justify-center w-5 h-5 bg-black absolute -top-2 -end-2 rounded-full border-2 border-white cursor-pointer hover:border-gray-300 hover:scale-110"
|
|
47
|
+
@click="removeFileFromList(key)"
|
|
48
|
+
>
|
|
49
|
+
<Tooltip class="absolute top-0 end-0">
|
|
50
|
+
<div>
|
|
51
|
+
<div class="w-4 h-4 absolute"></div>
|
|
52
|
+
<IconCloseOutline class="w-3 h-3 text-white hover:text-gray-300" />
|
|
53
|
+
</div>
|
|
54
|
+
<template #tooltip>
|
|
55
|
+
Remove file
|
|
56
|
+
</template>
|
|
57
|
+
</Tooltip>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
<input
|
|
61
|
+
ref="fileInput"
|
|
62
|
+
class="hidden"
|
|
63
|
+
type="file"
|
|
64
|
+
@change="handleAddFile"
|
|
65
|
+
accept="image/*"
|
|
44
66
|
/>
|
|
67
|
+
<button v-if="!uploading" @click="fileInput?.click()" type="button" class="relative group hover:border-gray-500 transition border-gray-300 flex items-center justify-center w-20 h-20 border-2 border-dashed rounded-md">
|
|
68
|
+
<div class="flex flex-col items-center justify-center gap-2 mt-4 mb-4">
|
|
69
|
+
<IconCloseOutline class="group-hover:text-gray-500 transition rotate-45 w-6 h-6 text-gray-300 hover:text-gray-300" />
|
|
70
|
+
<p class="text-gray-300 group-hover:text-gray-500 transition bottom-0">Ctrl + v</p>
|
|
71
|
+
</div>
|
|
72
|
+
</button>
|
|
73
|
+
<Skeleton v-else type="image" class="w-20 h-20" />
|
|
45
74
|
</div>
|
|
46
75
|
|
|
47
76
|
<!-- Fullscreen Modal -->
|
|
@@ -175,38 +204,39 @@
|
|
|
175
204
|
|
|
176
205
|
<script setup lang="ts">
|
|
177
206
|
|
|
178
|
-
import { ref, onMounted, nextTick, Ref,
|
|
207
|
+
import { ref, onMounted, nextTick, Ref, watch, onUnmounted } from 'vue'
|
|
179
208
|
import { Carousel } from 'flowbite';
|
|
180
209
|
import { callAdminForthApi } from '@/utils';
|
|
181
210
|
import { useI18n } from 'vue-i18n';
|
|
182
211
|
import adminforth from '@/adminforth';
|
|
183
212
|
import { ProgressBar } from '@/afcl';
|
|
184
213
|
import * as Handlebars from 'handlebars';
|
|
214
|
+
import { IconCloseOutline } from '@iconify-prerendered/vue-flowbite';
|
|
215
|
+
import { Tooltip, Skeleton } from '@/afcl'
|
|
216
|
+
import { useRoute } from 'vue-router';
|
|
217
|
+
|
|
218
|
+
const { t: $t, t } = useI18n();
|
|
219
|
+
const route = useRoute();
|
|
185
220
|
|
|
186
|
-
const { t: $t } = useI18n();
|
|
187
221
|
|
|
188
222
|
const prompt = ref('');
|
|
189
223
|
const emit = defineEmits(['close', 'uploadImage']);
|
|
190
|
-
const props = defineProps(
|
|
224
|
+
const props = defineProps({
|
|
225
|
+
meta: Object,
|
|
226
|
+
record: Object,
|
|
227
|
+
humanifySize: Function,
|
|
228
|
+
});
|
|
191
229
|
const images = ref([]);
|
|
192
230
|
const loading = ref(false);
|
|
193
231
|
const attachmentFiles = ref<string[]>([])
|
|
232
|
+
const requestAttachmentFiles = ref<Blob[] | null>([]);
|
|
233
|
+
const requestAttachmentFilesUrls = ref<string[]>([]);
|
|
194
234
|
const stopGeneration = ref(false);
|
|
195
|
-
|
|
196
|
-
function minifyField(field: string): string {
|
|
197
|
-
if (field.length > 100) {
|
|
198
|
-
return field.slice(0, 100) + '...';
|
|
199
|
-
}
|
|
200
|
-
return field;
|
|
201
|
-
}
|
|
235
|
+
const fileInput = ref<HTMLInputElement | null>(null);
|
|
202
236
|
|
|
203
237
|
const caurosel = ref(null);
|
|
204
238
|
onMounted(async () => {
|
|
205
239
|
// Initialize carousel
|
|
206
|
-
const context = {
|
|
207
|
-
field: props.meta.pathColumnLabel,
|
|
208
|
-
resource: props.meta.resourceLabel,
|
|
209
|
-
};
|
|
210
240
|
let template = '';
|
|
211
241
|
if (props.meta.generationPrompt) {
|
|
212
242
|
template = props.meta.generationPrompt;
|
|
@@ -230,13 +260,20 @@ onMounted(async () => {
|
|
|
230
260
|
});
|
|
231
261
|
|
|
232
262
|
if (resp?.files?.length) {
|
|
233
|
-
|
|
263
|
+
requestAttachmentFilesUrls.value = resp.files;
|
|
234
264
|
}
|
|
235
265
|
} catch (err) {
|
|
236
266
|
console.error('Failed to fetch attachment files', err);
|
|
237
267
|
}
|
|
268
|
+
|
|
269
|
+
for ( const fileUrl in requestAttachmentFilesUrls.value ) {
|
|
270
|
+
const image = await fetch(fileUrl);
|
|
271
|
+
const imageBlob = await image.blob();
|
|
272
|
+
requestAttachmentFiles.value!.push(imageBlob);
|
|
273
|
+
}
|
|
238
274
|
});
|
|
239
275
|
|
|
276
|
+
|
|
240
277
|
async function slide(direction: number) {
|
|
241
278
|
if (!caurosel.value) return;
|
|
242
279
|
const curPos = caurosel.value.getActiveItem().position;
|
|
@@ -323,11 +360,13 @@ async function generateImages() {
|
|
|
323
360
|
method: 'POST',
|
|
324
361
|
body: {
|
|
325
362
|
prompt: prompt.value,
|
|
326
|
-
recordId: props.record[props.meta.recorPkFieldName]
|
|
363
|
+
recordId: props.record[props.meta.recorPkFieldName],
|
|
364
|
+
requestAttachmentFiles: requestAttachmentFilesUrls.value,
|
|
327
365
|
},
|
|
328
366
|
});
|
|
329
367
|
} catch (e) {
|
|
330
368
|
console.error(e);
|
|
369
|
+
return;
|
|
331
370
|
}
|
|
332
371
|
|
|
333
372
|
if (resp?.error) {
|
|
@@ -451,4 +490,129 @@ watch(zoomedImage, async (val) => {
|
|
|
451
490
|
}).show()
|
|
452
491
|
}
|
|
453
492
|
})
|
|
493
|
+
|
|
494
|
+
async function handleAddFile(event, clipboardFile = null) {
|
|
495
|
+
if (clipboardFile) {
|
|
496
|
+
clipboardFile = renameFile(clipboardFile, `pasted_image_${Date.now()}.png`);
|
|
497
|
+
}
|
|
498
|
+
const files = event?.target?.files || (clipboardFile ? [clipboardFile] : []);
|
|
499
|
+
for (let i = 0; i < files.length; i++) {
|
|
500
|
+
if (requestAttachmentFiles.value.find((f: any) => f.name === files[i].name)) {
|
|
501
|
+
adminforth.alert({
|
|
502
|
+
message: $t('File with the same name already added'),
|
|
503
|
+
variant: 'warning',
|
|
504
|
+
timeout: 5000,
|
|
505
|
+
});
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const file = files[i];
|
|
509
|
+
const fileUrl = await uploadFile(file);
|
|
510
|
+
if (!fileUrl) continue;
|
|
511
|
+
requestAttachmentFiles.value!.push(file);
|
|
512
|
+
requestAttachmentFilesUrls.value.push(fileUrl);
|
|
513
|
+
}
|
|
514
|
+
fileInput.value!.value = '';
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function removeFileFromList(index: number) {
|
|
518
|
+
requestAttachmentFiles.value!.splice(index, 1);
|
|
519
|
+
requestAttachmentFilesUrls.value.splice(index, 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const uploading = ref(false);
|
|
523
|
+
|
|
524
|
+
async function uploadFile(file: any): Promise<string> {
|
|
525
|
+
const { name, size, type } = file;
|
|
526
|
+
|
|
527
|
+
let imgPreview = '';
|
|
528
|
+
|
|
529
|
+
const extension = name.split('.').pop();
|
|
530
|
+
const nameNoExtension = name.replace(`.${extension}`, '');
|
|
531
|
+
|
|
532
|
+
if (props.meta.maxFileSize && size > props.meta.maxFileSize) {
|
|
533
|
+
adminforth.alert({
|
|
534
|
+
message: t('Sorry but the file size {size} is too large. Please upload a file with a maximum size of {maxFileSize}', {
|
|
535
|
+
size: props.humanifySize(size),
|
|
536
|
+
maxFileSize: props.humanifySize(props.meta.maxFileSize),
|
|
537
|
+
}),
|
|
538
|
+
variant: 'danger'
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
try {
|
|
544
|
+
uploading.value = true;
|
|
545
|
+
const { uploadUrl, uploadExtraParams, filePath, error, previewUrl } = await callAdminForthApi({
|
|
546
|
+
path: `/plugin/${props.meta.pluginInstanceId}/get_file_upload_url`,
|
|
547
|
+
method: 'POST',
|
|
548
|
+
body: {
|
|
549
|
+
originalFilename: nameNoExtension,
|
|
550
|
+
contentType: type,
|
|
551
|
+
size,
|
|
552
|
+
originalExtension: extension,
|
|
553
|
+
recordPk: route?.params?.primaryKey,
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
if (error) {
|
|
557
|
+
adminforth.alert({
|
|
558
|
+
message: t('File was not uploaded because of error: {error}', { error }),
|
|
559
|
+
variant: 'danger'
|
|
560
|
+
});
|
|
561
|
+
imgPreview = null;
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const xhr = new XMLHttpRequest();
|
|
566
|
+
const success = await new Promise((resolve) => {
|
|
567
|
+
xhr.addEventListener('loadend', () => {
|
|
568
|
+
const success = xhr.readyState === 4 && xhr.status === 200;
|
|
569
|
+
// try to read response
|
|
570
|
+
resolve(success);
|
|
571
|
+
});
|
|
572
|
+
xhr.open('PUT', uploadUrl, true);
|
|
573
|
+
xhr.setRequestHeader('Content-Type', type);
|
|
574
|
+
uploadExtraParams && Object.entries(uploadExtraParams).forEach(([key, value]: [string, string]) => {
|
|
575
|
+
xhr.setRequestHeader(key, value);
|
|
576
|
+
})
|
|
577
|
+
xhr.send(file);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (success) {
|
|
581
|
+
imgPreview = previewUrl;
|
|
582
|
+
} else {
|
|
583
|
+
throw new Error('File upload failed');
|
|
584
|
+
}
|
|
585
|
+
} catch (err) {
|
|
586
|
+
uploading.value = false;
|
|
587
|
+
console.error('File upload failed', err);
|
|
588
|
+
}
|
|
589
|
+
uploading.value = false;
|
|
590
|
+
return imgPreview;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function uploadImageOnPaste(event) {
|
|
594
|
+
const items = event.clipboardData?.items;
|
|
595
|
+
if (!items) return;
|
|
596
|
+
|
|
597
|
+
for (let item of items) {
|
|
598
|
+
if (item.type.startsWith('image/')) {
|
|
599
|
+
const file = item.getAsFile();
|
|
600
|
+
if (file) {
|
|
601
|
+
await handleAddFile(null, file);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function renameFile(file, newName) {
|
|
608
|
+
return new File([file], newName, { type: file.type });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
onMounted(() => {
|
|
612
|
+
document.addEventListener('paste', uploadImageOnPaste);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
onUnmounted(() => {
|
|
616
|
+
document.removeEventListener('paste', uploadImageOnPaste);
|
|
617
|
+
});
|
|
454
618
|
</script>
|
package/dist/custom/uploader.vue
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<div class="relative w-full">
|
|
3
3
|
<ImageGenerator v-if="showImageGen" @close="showImageGen = false" :record="record" :meta="meta"
|
|
4
|
-
@uploadImage="uploadGeneratedImage"
|
|
4
|
+
@uploadImage="uploadGeneratedImage"
|
|
5
|
+
:humanifySize="humanifySize"
|
|
5
6
|
></ImageGenerator>
|
|
6
7
|
|
|
7
8
|
<button v-if="meta.generateImages"
|
package/dist/index.js
CHANGED
|
@@ -10,8 +10,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
10
10
|
import { AdminForthPlugin, Filters, suggestIfTypo, RateLimiter } from "adminforth";
|
|
11
11
|
import { Readable } from "stream";
|
|
12
12
|
import { randomUUID } from "crypto";
|
|
13
|
-
import { interpretResource } from 'adminforth';
|
|
14
|
-
import { ActionCheckSource } from 'adminforth';
|
|
13
|
+
import { interpretResource, ActionCheckSource } from 'adminforth';
|
|
15
14
|
const ADMINFORTH_NOT_YET_USED_TAG = 'adminforth-candidate-for-cleanup';
|
|
16
15
|
const jobs = new Map();
|
|
17
16
|
export default class UploadPlugin extends AdminForthPlugin {
|
|
@@ -90,35 +89,16 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
90
89
|
markKeyForDeletion(filePath) {
|
|
91
90
|
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
|
|
92
91
|
}
|
|
93
|
-
generateImages(jobId, prompt, recordId, adminUser, headers) {
|
|
92
|
+
generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers) {
|
|
94
93
|
return __awaiter(this, void 0, void 0, function* () {
|
|
95
|
-
var _a
|
|
94
|
+
var _a;
|
|
96
95
|
if ((_a = this.options.generation.rateLimit) === null || _a === void 0 ? void 0 : _a.limit) {
|
|
97
|
-
// rate limit
|
|
98
|
-
// const { error } = RateLimiter.checkRateLimit(
|
|
99
|
-
// this.pluginInstanceId,
|
|
100
|
-
// this.options.generation.rateLimit?.limit,
|
|
101
|
-
// this.adminforth.auth.getClientIp(headers),
|
|
102
|
-
// );
|
|
103
96
|
if (!(yield this.rateLimiter.consume(`${this.pluginInstanceId}-${this.adminforth.auth.getClientIp(headers)}`))) {
|
|
104
97
|
jobs.set(jobId, { status: "failed", error: this.options.generation.rateLimit.errorMessage });
|
|
105
98
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
106
99
|
}
|
|
107
100
|
}
|
|
108
|
-
let attachmentFiles =
|
|
109
|
-
if (this.options.generation.attachFiles) {
|
|
110
|
-
// TODO - does it require additional allowed action to check this record id has access to get the image?
|
|
111
|
-
// or should we mention in docs that user should do validation in method itself
|
|
112
|
-
const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_b = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, recordId)]);
|
|
113
|
-
if (!record) {
|
|
114
|
-
return { error: `Record with id ${recordId} not found` };
|
|
115
|
-
}
|
|
116
|
-
attachmentFiles = yield this.options.generation.attachFiles({ record, adminUser });
|
|
117
|
-
// if files is not array, make it array
|
|
118
|
-
if (!Array.isArray(attachmentFiles)) {
|
|
119
|
-
attachmentFiles = [attachmentFiles];
|
|
120
|
-
}
|
|
121
|
-
}
|
|
101
|
+
let attachmentFiles = requestAttachmentFiles;
|
|
122
102
|
let error = undefined;
|
|
123
103
|
const STUB_MODE = false;
|
|
124
104
|
const images = yield Promise.all((new Array(this.options.generation.countToGenerate)).fill(0).map(() => __awaiter(this, void 0, void 0, function* () {
|
|
@@ -390,10 +370,10 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
390
370
|
method: 'POST',
|
|
391
371
|
path: `/plugin/${this.pluginInstanceId}/create-image-generation-job`,
|
|
392
372
|
handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) {
|
|
393
|
-
const { prompt, recordId } = body;
|
|
373
|
+
const { prompt, recordId, requestAttachmentFiles } = body;
|
|
394
374
|
const jobId = randomUUID();
|
|
395
375
|
jobs.set(jobId, { status: "in_progress" });
|
|
396
|
-
this.generateImages(jobId, prompt, recordId, adminUser, headers);
|
|
376
|
+
this.generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers);
|
|
397
377
|
setTimeout(() => jobs.delete(jobId), 1800000);
|
|
398
378
|
setTimeout(() => { jobs.set(jobId, { status: "timeout" }); }, 300000);
|
|
399
379
|
return { ok: true, jobId };
|
|
@@ -710,4 +690,230 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
710
690
|
};
|
|
711
691
|
});
|
|
712
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Generates a new signed upload URL for future uploading from the frontend via a direct upload (e.g. using fetch + FormData).
|
|
695
|
+
*
|
|
696
|
+
* After the upload, file still will be marked for auto-deletion after short time, so to keep it permanently,
|
|
697
|
+
* you need to either:
|
|
698
|
+
* * Use commitUrlToExistingRecord to commit the URL to an existing record. This will replace the path in the existing record and will do a cleanup of the old
|
|
699
|
+
* file pointed in this path column.
|
|
700
|
+
* * If you want to create a new record with this URL, you can call commitUrlToNewRecord, which will create a new record and set the path column to the uploaded file path.
|
|
701
|
+
* * Write URL to special field called pathColumnName so afterSave hook installed by the plugin will automatically mark as not candidate for auto-deletion
|
|
702
|
+
*
|
|
703
|
+
* ```ts
|
|
704
|
+
* const file = input.files[0];
|
|
705
|
+
*
|
|
706
|
+
* // 1) Ask your backend to call getUploadUrlForExistingRecord
|
|
707
|
+
* const { uploadUrl, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
|
|
708
|
+
* method: 'POST',
|
|
709
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
710
|
+
* body: JSON.stringify({
|
|
711
|
+
* recordId,
|
|
712
|
+
* filename: file.name,
|
|
713
|
+
* contentType: file.type,
|
|
714
|
+
* size: file.size,
|
|
715
|
+
* }),
|
|
716
|
+
* }).then(r => r.json());
|
|
717
|
+
*
|
|
718
|
+
* const formData = new FormData();
|
|
719
|
+
* if (uploadExtraParams) {
|
|
720
|
+
* Object.entries(uploadExtraParams).forEach(([key, value]) => {
|
|
721
|
+
* formData.append(key, value as string);
|
|
722
|
+
* });
|
|
723
|
+
* }
|
|
724
|
+
* formData.append('file', file);
|
|
725
|
+
*
|
|
726
|
+
* // 2) Direct upload from the browser to storage (multipart/form-data)
|
|
727
|
+
* const uploadResp = await fetch(uploadUrl, {
|
|
728
|
+
* method: 'POST',
|
|
729
|
+
* body: formData,
|
|
730
|
+
* });
|
|
731
|
+
* if (!uploadResp.ok) {
|
|
732
|
+
* throw new Error('Upload failed');
|
|
733
|
+
* }
|
|
734
|
+
*
|
|
735
|
+
* // 3) Tell your backend to commit the URL to the record e.g. from rest API call
|
|
736
|
+
* await fetch('/api/uploads/commit-existing', {
|
|
737
|
+
* method: 'POST',
|
|
738
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
739
|
+
* body: JSON.stringify({ recordId, filePath }),
|
|
740
|
+
* });
|
|
741
|
+
* ```
|
|
742
|
+
*/
|
|
743
|
+
getUploadUrl(_a) {
|
|
744
|
+
return __awaiter(this, arguments, void 0, function* ({ recordId, filename, contentType, size, }) {
|
|
745
|
+
if (!filename || !contentType) {
|
|
746
|
+
throw new Error('filename and contentType are required');
|
|
747
|
+
}
|
|
748
|
+
if (!this.resourceConfig) {
|
|
749
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
750
|
+
}
|
|
751
|
+
const pkColumn = this.resourceConfig.columns.find((column) => column.primaryKey);
|
|
752
|
+
const pkName = pkColumn === null || pkColumn === void 0 ? void 0 : pkColumn.name;
|
|
753
|
+
if (!pkName) {
|
|
754
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
755
|
+
}
|
|
756
|
+
let existingRecord = undefined;
|
|
757
|
+
if (recordId !== undefined && recordId !== null) {
|
|
758
|
+
existingRecord = yield this.adminforth
|
|
759
|
+
.resource(this.resourceConfig.resourceId)
|
|
760
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
761
|
+
if (!existingRecord) {
|
|
762
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
766
|
+
if (lastDotIndex === -1) {
|
|
767
|
+
throw new Error('filename must contain an extension');
|
|
768
|
+
}
|
|
769
|
+
const originalExtension = filename.substring(lastDotIndex + 1).toLowerCase();
|
|
770
|
+
const originalFilename = filename.substring(0, lastDotIndex);
|
|
771
|
+
if (this.options.allowedFileExtensions &&
|
|
772
|
+
!this.options.allowedFileExtensions.includes(originalExtension)) {
|
|
773
|
+
throw new Error(`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}`);
|
|
774
|
+
}
|
|
775
|
+
if (size != null && this.options.maxFileSize && size > this.options.maxFileSize) {
|
|
776
|
+
throw new Error(`File size ${size} is too large. Maximum allowed size is ${this.options.maxFileSize}`);
|
|
777
|
+
}
|
|
778
|
+
const existingValue = existingRecord === null || existingRecord === void 0 ? void 0 : existingRecord[this.options.pathColumnName];
|
|
779
|
+
const existingPaths = existingValue ? this.normalizePaths(existingValue) : undefined;
|
|
780
|
+
const filePath = this.options.filePath({
|
|
781
|
+
originalFilename,
|
|
782
|
+
originalExtension,
|
|
783
|
+
contentType,
|
|
784
|
+
record: existingRecord,
|
|
785
|
+
});
|
|
786
|
+
if (filePath.startsWith('/')) {
|
|
787
|
+
throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
|
|
788
|
+
}
|
|
789
|
+
if (existingPaths && existingPaths.includes(filePath)) {
|
|
790
|
+
throw new Error('New file path cannot be the same as existing path to avoid caching issues');
|
|
791
|
+
}
|
|
792
|
+
const { uploadUrl, uploadExtraParams } = yield this.options.storageAdapter.getUploadSignedUrl(filePath, contentType, 1800);
|
|
793
|
+
return {
|
|
794
|
+
uploadUrl,
|
|
795
|
+
filePath,
|
|
796
|
+
uploadExtraParams,
|
|
797
|
+
pathColumnName: this.options.pathColumnName,
|
|
798
|
+
};
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Commits a previously generated upload URL to an existing record.
|
|
803
|
+
*
|
|
804
|
+
* Never call this method from edit afterSave and beforeSave hooks of the same resource,
|
|
805
|
+
* as it would create infinite loop of record updates.
|
|
806
|
+
* You should call this method from your own custom API endpoint after the upload is done.
|
|
807
|
+
*/
|
|
808
|
+
commitUrlToUpdateExistingRecord(_a) {
|
|
809
|
+
return __awaiter(this, arguments, void 0, function* ({ recordId, filePath, adminUser, extra, }) {
|
|
810
|
+
var _b;
|
|
811
|
+
if (recordId === undefined || recordId === null) {
|
|
812
|
+
throw new Error('recordId is required');
|
|
813
|
+
}
|
|
814
|
+
if (!filePath) {
|
|
815
|
+
throw new Error('filePath is required');
|
|
816
|
+
}
|
|
817
|
+
if (!this.resourceConfig) {
|
|
818
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
819
|
+
}
|
|
820
|
+
const pkColumn = this.resourceConfig.columns.find((column) => column.primaryKey);
|
|
821
|
+
const pkName = pkColumn === null || pkColumn === void 0 ? void 0 : pkColumn.name;
|
|
822
|
+
if (!pkName) {
|
|
823
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
824
|
+
}
|
|
825
|
+
const existingRecord = yield this.adminforth
|
|
826
|
+
.resource(this.resourceConfig.resourceId)
|
|
827
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
828
|
+
if (!existingRecord) {
|
|
829
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
830
|
+
}
|
|
831
|
+
const existingValue = existingRecord[this.options.pathColumnName];
|
|
832
|
+
const existingPaths = this.normalizePaths(existingValue);
|
|
833
|
+
if (existingPaths.includes(filePath)) {
|
|
834
|
+
throw new Error('New file path cannot be the same as existing path to avoid caching issues');
|
|
835
|
+
}
|
|
836
|
+
const { error: updateError } = yield this.adminforth.updateResourceRecord({
|
|
837
|
+
resource: this.resourceConfig,
|
|
838
|
+
recordId,
|
|
839
|
+
oldRecord: existingRecord,
|
|
840
|
+
adminUser,
|
|
841
|
+
extra,
|
|
842
|
+
updates: {
|
|
843
|
+
[this.options.pathColumnName]: filePath,
|
|
844
|
+
},
|
|
845
|
+
});
|
|
846
|
+
if (updateError) {
|
|
847
|
+
try {
|
|
848
|
+
yield this.markKeyForDeletion(filePath);
|
|
849
|
+
}
|
|
850
|
+
catch (e) {
|
|
851
|
+
// best-effort cleanup, ignore error
|
|
852
|
+
}
|
|
853
|
+
throw new Error(`Error updating record after upload: ${updateError}`);
|
|
854
|
+
}
|
|
855
|
+
let previewUrl;
|
|
856
|
+
if ((_b = this.options.preview) === null || _b === void 0 ? void 0 : _b.previewUrl) {
|
|
857
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
previewUrl = yield this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
path: filePath,
|
|
864
|
+
previewUrl,
|
|
865
|
+
};
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Commits a previously generated upload URL to a new record.
|
|
870
|
+
*
|
|
871
|
+
* Never call this method from create afterSave and beforeSave hooks of the same resource,
|
|
872
|
+
* as it would create infinite loop of record creations.
|
|
873
|
+
*
|
|
874
|
+
* You should call this method from your own custom API endpoint after the upload is done.
|
|
875
|
+
*/
|
|
876
|
+
commitUrlToNewRecord(_a) {
|
|
877
|
+
return __awaiter(this, arguments, void 0, function* ({ filePath, adminUser, extra, recordAttributes, }) {
|
|
878
|
+
var _b;
|
|
879
|
+
if (!filePath) {
|
|
880
|
+
throw new Error('filePath is required');
|
|
881
|
+
}
|
|
882
|
+
if (!this.resourceConfig) {
|
|
883
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
884
|
+
}
|
|
885
|
+
// Mark this key as used so lifecycle rules do not delete it.
|
|
886
|
+
yield this.markKeyForNotDeletion(filePath);
|
|
887
|
+
const { error: createError, createdRecord, newRecordId } = yield this.adminforth.createResourceRecord({
|
|
888
|
+
resource: this.resourceConfig,
|
|
889
|
+
record: Object.assign(Object.assign({}, (recordAttributes !== null && recordAttributes !== void 0 ? recordAttributes : {})), { [this.options.pathColumnName]: filePath }),
|
|
890
|
+
adminUser,
|
|
891
|
+
extra,
|
|
892
|
+
});
|
|
893
|
+
if (createError) {
|
|
894
|
+
try {
|
|
895
|
+
yield this.markKeyForDeletion(filePath);
|
|
896
|
+
}
|
|
897
|
+
catch (e) {
|
|
898
|
+
// best-effort cleanup, ignore error
|
|
899
|
+
}
|
|
900
|
+
throw new Error(`Error creating record after upload: ${createError}`);
|
|
901
|
+
}
|
|
902
|
+
const pkColumn = this.resourceConfig.columns.find((column) => column.primaryKey);
|
|
903
|
+
const pkName = pkColumn === null || pkColumn === void 0 ? void 0 : pkColumn.name;
|
|
904
|
+
const newRecordPk = newRecordId !== null && newRecordId !== void 0 ? newRecordId : (pkName && createdRecord ? createdRecord[pkName] : undefined);
|
|
905
|
+
let previewUrl;
|
|
906
|
+
if ((_b = this.options.preview) === null || _b === void 0 ? void 0 : _b.previewUrl) {
|
|
907
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
908
|
+
}
|
|
909
|
+
else {
|
|
910
|
+
previewUrl = yield this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
path: filePath,
|
|
914
|
+
previewUrl,
|
|
915
|
+
newRecordPk,
|
|
916
|
+
};
|
|
917
|
+
});
|
|
918
|
+
}
|
|
713
919
|
}
|
package/index.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
PluginOptions,
|
|
4
|
+
UploadFromBufferParams,
|
|
5
|
+
UploadFromBufferToExistingRecordParams,
|
|
6
|
+
CommitUrlToUpdateExistingRecordParams,
|
|
7
|
+
CommitUrlToNewRecordParams,
|
|
8
|
+
GetUploadUrlParams,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import { AdminForthPlugin, AdminForthResource, Filters, IAdminForth, IHttpServer, suggestIfTypo, RateLimiter } from "adminforth";
|
|
4
11
|
import { Readable } from "stream";
|
|
5
12
|
import { randomUUID } from "crypto";
|
|
6
|
-
import { interpretResource } from 'adminforth';
|
|
7
|
-
import { ActionCheckSource } from 'adminforth';
|
|
13
|
+
import { interpretResource, ActionCheckSource } from 'adminforth';
|
|
8
14
|
|
|
9
15
|
const ADMINFORTH_NOT_YET_USED_TAG = 'adminforth-candidate-for-cleanup';
|
|
10
16
|
const jobs = new Map();
|
|
@@ -104,39 +110,14 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
104
110
|
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
|
|
105
111
|
}
|
|
106
112
|
|
|
107
|
-
private async generateImages(jobId: string, prompt: string, recordId: any, adminUser: any, headers: any) {
|
|
113
|
+
private async generateImages(jobId: string, prompt: string,requestAttachmentFiles: string[], recordId: any, adminUser: any, headers: any) {
|
|
108
114
|
if (this.options.generation.rateLimit?.limit) {
|
|
109
|
-
// rate limit
|
|
110
|
-
// const { error } = RateLimiter.checkRateLimit(
|
|
111
|
-
// this.pluginInstanceId,
|
|
112
|
-
// this.options.generation.rateLimit?.limit,
|
|
113
|
-
// this.adminforth.auth.getClientIp(headers),
|
|
114
|
-
// );
|
|
115
115
|
if (!await this.rateLimiter.consume(`${this.pluginInstanceId}-${this.adminforth.auth.getClientIp(headers)}`)) {
|
|
116
116
|
jobs.set(jobId, { status: "failed", error: this.options.generation.rateLimit.errorMessage });
|
|
117
117
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
|
-
let attachmentFiles =
|
|
121
|
-
if (this.options.generation.attachFiles) {
|
|
122
|
-
// TODO - does it require additional allowed action to check this record id has access to get the image?
|
|
123
|
-
// or should we mention in docs that user should do validation in method itself
|
|
124
|
-
const record = await this.adminforth.resource(this.resourceConfig.resourceId).get(
|
|
125
|
-
[Filters.EQ(this.resourceConfig.columns.find(c => c.primaryKey)?.name, recordId)]
|
|
126
|
-
);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!record) {
|
|
130
|
-
return { error: `Record with id ${recordId} not found` };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
attachmentFiles = await this.options.generation.attachFiles({ record, adminUser });
|
|
134
|
-
// if files is not array, make it array
|
|
135
|
-
if (!Array.isArray(attachmentFiles)) {
|
|
136
|
-
attachmentFiles = [attachmentFiles];
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
}
|
|
120
|
+
let attachmentFiles = requestAttachmentFiles;
|
|
140
121
|
|
|
141
122
|
let error: string | undefined = undefined;
|
|
142
123
|
|
|
@@ -448,12 +429,11 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
448
429
|
method: 'POST',
|
|
449
430
|
path: `/plugin/${this.pluginInstanceId}/create-image-generation-job`,
|
|
450
431
|
handler: async ({ body, adminUser, headers }) => {
|
|
451
|
-
const { prompt, recordId } = body;
|
|
452
|
-
|
|
432
|
+
const { prompt, recordId, requestAttachmentFiles } = body;
|
|
453
433
|
const jobId = randomUUID();
|
|
454
434
|
jobs.set(jobId, { status: "in_progress" });
|
|
455
435
|
|
|
456
|
-
this.generateImages(jobId, prompt, recordId, adminUser, headers);
|
|
436
|
+
this.generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers);
|
|
457
437
|
setTimeout(() => jobs.delete(jobId), 1_800_000);
|
|
458
438
|
setTimeout(() => {jobs.set(jobId, { status: "timeout" });}, 300_000);
|
|
459
439
|
|
|
@@ -832,4 +812,288 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
832
812
|
};
|
|
833
813
|
}
|
|
834
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Generates a new signed upload URL for future uploading from the frontend via a direct upload (e.g. using fetch + FormData).
|
|
817
|
+
*
|
|
818
|
+
* After the upload, file still will be marked for auto-deletion after short time, so to keep it permanently,
|
|
819
|
+
* you need to either:
|
|
820
|
+
* * Use commitUrlToExistingRecord to commit the URL to an existing record. This will replace the path in the existing record and will do a cleanup of the old
|
|
821
|
+
* file pointed in this path column.
|
|
822
|
+
* * If you want to create a new record with this URL, you can call commitUrlToNewRecord, which will create a new record and set the path column to the uploaded file path.
|
|
823
|
+
* * Write URL to special field called pathColumnName so afterSave hook installed by the plugin will automatically mark as not candidate for auto-deletion
|
|
824
|
+
*
|
|
825
|
+
* ```ts
|
|
826
|
+
* const file = input.files[0];
|
|
827
|
+
*
|
|
828
|
+
* // 1) Ask your backend to call getUploadUrlForExistingRecord
|
|
829
|
+
* const { uploadUrl, filePath, uploadExtraParams } = await fetch('/api/uploads/get-url-existing', {
|
|
830
|
+
* method: 'POST',
|
|
831
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
832
|
+
* body: JSON.stringify({
|
|
833
|
+
* recordId,
|
|
834
|
+
* filename: file.name,
|
|
835
|
+
* contentType: file.type,
|
|
836
|
+
* size: file.size,
|
|
837
|
+
* }),
|
|
838
|
+
* }).then(r => r.json());
|
|
839
|
+
*
|
|
840
|
+
* const formData = new FormData();
|
|
841
|
+
* if (uploadExtraParams) {
|
|
842
|
+
* Object.entries(uploadExtraParams).forEach(([key, value]) => {
|
|
843
|
+
* formData.append(key, value as string);
|
|
844
|
+
* });
|
|
845
|
+
* }
|
|
846
|
+
* formData.append('file', file);
|
|
847
|
+
*
|
|
848
|
+
* // 2) Direct upload from the browser to storage (multipart/form-data)
|
|
849
|
+
* const uploadResp = await fetch(uploadUrl, {
|
|
850
|
+
* method: 'POST',
|
|
851
|
+
* body: formData,
|
|
852
|
+
* });
|
|
853
|
+
* if (!uploadResp.ok) {
|
|
854
|
+
* throw new Error('Upload failed');
|
|
855
|
+
* }
|
|
856
|
+
*
|
|
857
|
+
* // 3) Tell your backend to commit the URL to the record e.g. from rest API call
|
|
858
|
+
* await fetch('/api/uploads/commit-existing', {
|
|
859
|
+
* method: 'POST',
|
|
860
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
861
|
+
* body: JSON.stringify({ recordId, filePath }),
|
|
862
|
+
* });
|
|
863
|
+
* ```
|
|
864
|
+
*/
|
|
865
|
+
async getUploadUrl({
|
|
866
|
+
recordId,
|
|
867
|
+
filename,
|
|
868
|
+
contentType,
|
|
869
|
+
size,
|
|
870
|
+
}: GetUploadUrlParams): Promise<{
|
|
871
|
+
uploadUrl: string;
|
|
872
|
+
filePath: string;
|
|
873
|
+
uploadExtraParams?: Record<string, string>;
|
|
874
|
+
pathColumnName: string;
|
|
875
|
+
}> {
|
|
876
|
+
if (!filename || !contentType) {
|
|
877
|
+
throw new Error('filename and contentType are required');
|
|
878
|
+
}
|
|
879
|
+
if (!this.resourceConfig) {
|
|
880
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
|
|
884
|
+
const pkName = pkColumn?.name;
|
|
885
|
+
if (!pkName) {
|
|
886
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
let existingRecord: any = undefined;
|
|
890
|
+
if (recordId !== undefined && recordId !== null) {
|
|
891
|
+
existingRecord = await this.adminforth
|
|
892
|
+
.resource(this.resourceConfig.resourceId)
|
|
893
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
894
|
+
|
|
895
|
+
if (!existingRecord) {
|
|
896
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
901
|
+
if (lastDotIndex === -1) {
|
|
902
|
+
throw new Error('filename must contain an extension');
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
const originalExtension = filename.substring(lastDotIndex + 1).toLowerCase();
|
|
906
|
+
const originalFilename = filename.substring(0, lastDotIndex);
|
|
907
|
+
|
|
908
|
+
if (
|
|
909
|
+
this.options.allowedFileExtensions &&
|
|
910
|
+
!this.options.allowedFileExtensions.includes(originalExtension)
|
|
911
|
+
) {
|
|
912
|
+
throw new Error(
|
|
913
|
+
`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(
|
|
914
|
+
', ',
|
|
915
|
+
)}`,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (size != null && this.options.maxFileSize && size > this.options.maxFileSize) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`File size ${size} is too large. Maximum allowed size is ${this.options.maxFileSize}`,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const existingValue = existingRecord?.[this.options.pathColumnName];
|
|
926
|
+
const existingPaths = existingValue ? this.normalizePaths(existingValue) : undefined;
|
|
927
|
+
|
|
928
|
+
const filePath: string = this.options.filePath({
|
|
929
|
+
originalFilename,
|
|
930
|
+
originalExtension,
|
|
931
|
+
contentType,
|
|
932
|
+
record: existingRecord,
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
if (filePath.startsWith('/')) {
|
|
936
|
+
throw new Error(
|
|
937
|
+
's3Path should not start with /, please adjust s3path function to not return / at the start of the path',
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (existingPaths && existingPaths.includes(filePath)) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
'New file path cannot be the same as existing path to avoid caching issues',
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const { uploadUrl, uploadExtraParams } = await this.options.storageAdapter.getUploadSignedUrl(
|
|
948
|
+
filePath,
|
|
949
|
+
contentType,
|
|
950
|
+
1800,
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
return {
|
|
954
|
+
uploadUrl,
|
|
955
|
+
filePath,
|
|
956
|
+
uploadExtraParams,
|
|
957
|
+
pathColumnName: this.options.pathColumnName,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Commits a previously generated upload URL to an existing record.
|
|
963
|
+
*
|
|
964
|
+
* Never call this method from edit afterSave and beforeSave hooks of the same resource,
|
|
965
|
+
* as it would create infinite loop of record updates.
|
|
966
|
+
* You should call this method from your own custom API endpoint after the upload is done.
|
|
967
|
+
*/
|
|
968
|
+
async commitUrlToUpdateExistingRecord({
|
|
969
|
+
recordId,
|
|
970
|
+
filePath,
|
|
971
|
+
adminUser,
|
|
972
|
+
extra,
|
|
973
|
+
}: CommitUrlToUpdateExistingRecordParams): Promise<{ path: string; previewUrl: string }> {
|
|
974
|
+
if (recordId === undefined || recordId === null) {
|
|
975
|
+
throw new Error('recordId is required');
|
|
976
|
+
}
|
|
977
|
+
if (!filePath) {
|
|
978
|
+
throw new Error('filePath is required');
|
|
979
|
+
}
|
|
980
|
+
if (!this.resourceConfig) {
|
|
981
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
|
|
985
|
+
const pkName = pkColumn?.name;
|
|
986
|
+
if (!pkName) {
|
|
987
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const existingRecord = await this.adminforth
|
|
991
|
+
.resource(this.resourceConfig.resourceId)
|
|
992
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
993
|
+
|
|
994
|
+
if (!existingRecord) {
|
|
995
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const existingValue = existingRecord[this.options.pathColumnName];
|
|
999
|
+
const existingPaths = this.normalizePaths(existingValue);
|
|
1000
|
+
|
|
1001
|
+
if (existingPaths.includes(filePath)) {
|
|
1002
|
+
throw new Error(
|
|
1003
|
+
'New file path cannot be the same as existing path to avoid caching issues',
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const { error: updateError } = await this.adminforth.updateResourceRecord({
|
|
1008
|
+
resource: this.resourceConfig,
|
|
1009
|
+
recordId,
|
|
1010
|
+
oldRecord: existingRecord,
|
|
1011
|
+
adminUser,
|
|
1012
|
+
extra,
|
|
1013
|
+
updates: {
|
|
1014
|
+
[this.options.pathColumnName]: filePath,
|
|
1015
|
+
},
|
|
1016
|
+
} as any);
|
|
1017
|
+
|
|
1018
|
+
if (updateError) {
|
|
1019
|
+
try {
|
|
1020
|
+
await this.markKeyForDeletion(filePath);
|
|
1021
|
+
} catch (e) {
|
|
1022
|
+
// best-effort cleanup, ignore error
|
|
1023
|
+
}
|
|
1024
|
+
throw new Error(`Error updating record after upload: ${updateError}`);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
let previewUrl: string;
|
|
1028
|
+
if (this.options.preview?.previewUrl) {
|
|
1029
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
1030
|
+
} else {
|
|
1031
|
+
previewUrl = await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
return {
|
|
1035
|
+
path: filePath,
|
|
1036
|
+
previewUrl,
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Commits a previously generated upload URL to a new record.
|
|
1042
|
+
*
|
|
1043
|
+
* Never call this method from create afterSave and beforeSave hooks of the same resource,
|
|
1044
|
+
* as it would create infinite loop of record creations.
|
|
1045
|
+
*
|
|
1046
|
+
* You should call this method from your own custom API endpoint after the upload is done.
|
|
1047
|
+
*/
|
|
1048
|
+
async commitUrlToNewRecord({
|
|
1049
|
+
filePath,
|
|
1050
|
+
adminUser,
|
|
1051
|
+
extra,
|
|
1052
|
+
recordAttributes,
|
|
1053
|
+
}: CommitUrlToNewRecordParams): Promise<{ path: string; previewUrl: string; newRecordPk: any }> {
|
|
1054
|
+
if (!filePath) {
|
|
1055
|
+
throw new Error('filePath is required');
|
|
1056
|
+
}
|
|
1057
|
+
if (!this.resourceConfig) {
|
|
1058
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Mark this key as used so lifecycle rules do not delete it.
|
|
1062
|
+
await this.markKeyForNotDeletion(filePath);
|
|
1063
|
+
|
|
1064
|
+
const { error: createError, createdRecord, newRecordId }: any =
|
|
1065
|
+
await this.adminforth.createResourceRecord({
|
|
1066
|
+
resource: this.resourceConfig,
|
|
1067
|
+
record: { ...(recordAttributes ?? {}), [this.options.pathColumnName]: filePath },
|
|
1068
|
+
adminUser,
|
|
1069
|
+
extra,
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
if (createError) {
|
|
1073
|
+
try {
|
|
1074
|
+
await this.markKeyForDeletion(filePath);
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
// best-effort cleanup, ignore error
|
|
1077
|
+
}
|
|
1078
|
+
throw new Error(`Error creating record after upload: ${createError}`);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
|
|
1082
|
+
const pkName = pkColumn?.name;
|
|
1083
|
+
const newRecordPk = newRecordId ?? (pkName && createdRecord ? createdRecord[pkName] : undefined);
|
|
1084
|
+
|
|
1085
|
+
let previewUrl: string;
|
|
1086
|
+
if (this.options.preview?.previewUrl) {
|
|
1087
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
1088
|
+
} else {
|
|
1089
|
+
previewUrl = await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
path: filePath,
|
|
1094
|
+
previewUrl,
|
|
1095
|
+
newRecordPk,
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
835
1099
|
}
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -212,4 +212,107 @@ export type UploadFromBufferToExistingRecordParams = UploadFromBufferParams & {
|
|
|
212
212
|
* should be replaced.
|
|
213
213
|
*/
|
|
214
214
|
recordId: any;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Parameters for generating an upload URL for an existing record
|
|
219
|
+
* that will be uploaded directly from the browser.
|
|
220
|
+
*/
|
|
221
|
+
export type GetUploadUrlParams = {
|
|
222
|
+
/**
|
|
223
|
+
* Primary key of the record whose file is being replaced.
|
|
224
|
+
*/
|
|
225
|
+
recordId?: any;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Full file name including extension, as provided by the browser.
|
|
229
|
+
*/
|
|
230
|
+
filename: string;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* MIME type reported by the browser, used as Content-Type for storage.
|
|
234
|
+
*/
|
|
235
|
+
contentType: string;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Optional file size in bytes. If provided, it will be validated
|
|
239
|
+
* against {@link PluginOptions.maxFileSize}.
|
|
240
|
+
*/
|
|
241
|
+
size?: number;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parameters for generating an upload URL for a new record
|
|
246
|
+
* that will be uploaded directly from the browser.
|
|
247
|
+
*/
|
|
248
|
+
export type GetUploadUrlForNewRecordParams = {
|
|
249
|
+
/**
|
|
250
|
+
* Full file name including extension, as provided by the browser.
|
|
251
|
+
*/
|
|
252
|
+
filename: string;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* MIME type reported by the browser, used as Content-Type for storage.
|
|
256
|
+
*/
|
|
257
|
+
contentType: string;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Optional file size in bytes. If provided, it will be validated
|
|
261
|
+
* against {@link PluginOptions.maxFileSize}.
|
|
262
|
+
*/
|
|
263
|
+
size?: number;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Parameters for committing a previously generated upload URL
|
|
268
|
+
* to an existing record. This is used after the browser finished
|
|
269
|
+
* uploading directly to the storage provider.
|
|
270
|
+
*/
|
|
271
|
+
export type CommitUrlToUpdateExistingRecordParams = {
|
|
272
|
+
/**
|
|
273
|
+
* Primary key of the record whose file is being replaced.
|
|
274
|
+
*/
|
|
275
|
+
recordId: any;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Storage path (key) that was returned by getUploadUrlForExistingRecord.
|
|
279
|
+
*/
|
|
280
|
+
filePath: string;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Authenticated admin user on whose behalf the record is updated.
|
|
284
|
+
*/
|
|
285
|
+
adminUser: AdminUser;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Optional HTTP context (headers, IP, etc.).
|
|
289
|
+
*/
|
|
290
|
+
extra?: HttpExtra;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Parameters for committing a previously generated upload URL
|
|
295
|
+
* to a new record. This is used after the browser finished
|
|
296
|
+
* uploading directly to the storage provider.
|
|
297
|
+
*/
|
|
298
|
+
export type CommitUrlToNewRecordParams = {
|
|
299
|
+
/**
|
|
300
|
+
* Storage path (key) that was returned by getUploadUrlForNewRecord.
|
|
301
|
+
*/
|
|
302
|
+
filePath: string;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Authenticated admin user on whose behalf the record is created.
|
|
306
|
+
*/
|
|
307
|
+
adminUser: AdminUser;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Optional HTTP context (headers, IP, etc.).
|
|
311
|
+
*/
|
|
312
|
+
extra?: HttpExtra;
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Optional additional attributes for the new record.
|
|
316
|
+
*/
|
|
317
|
+
recordAttributes?: Record<string, any>;
|
|
215
318
|
};
|