@adminforth/upload 2.11.1 → 2.13.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 +135 -26
- package/index.ts +166 -37
- package/package.json +1 -1
- package/types.ts +13 -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,817 bytes received 134 bytes 119,902.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
|
@@ -90,35 +90,16 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
90
90
|
markKeyForDeletion(filePath) {
|
|
91
91
|
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
|
|
92
92
|
}
|
|
93
|
-
generateImages(jobId, prompt, recordId, adminUser, headers) {
|
|
93
|
+
generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers) {
|
|
94
94
|
return __awaiter(this, void 0, void 0, function* () {
|
|
95
|
-
var _a
|
|
95
|
+
var _a;
|
|
96
96
|
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
97
|
if (!(yield this.rateLimiter.consume(`${this.pluginInstanceId}-${this.adminforth.auth.getClientIp(headers)}`))) {
|
|
104
98
|
jobs.set(jobId, { status: "failed", error: this.options.generation.rateLimit.errorMessage });
|
|
105
99
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
106
100
|
}
|
|
107
101
|
}
|
|
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
|
-
}
|
|
102
|
+
let attachmentFiles = requestAttachmentFiles;
|
|
122
103
|
let error = undefined;
|
|
123
104
|
const STUB_MODE = false;
|
|
124
105
|
const images = yield Promise.all((new Array(this.options.generation.countToGenerate)).fill(0).map(() => __awaiter(this, void 0, void 0, function* () {
|
|
@@ -390,10 +371,10 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
390
371
|
method: 'POST',
|
|
391
372
|
path: `/plugin/${this.pluginInstanceId}/create-image-generation-job`,
|
|
392
373
|
handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) {
|
|
393
|
-
const { prompt, recordId } = body;
|
|
374
|
+
const { prompt, recordId, requestAttachmentFiles } = body;
|
|
394
375
|
const jobId = randomUUID();
|
|
395
376
|
jobs.set(jobId, { status: "in_progress" });
|
|
396
|
-
this.generateImages(jobId, prompt, recordId, adminUser, headers);
|
|
377
|
+
this.generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers);
|
|
397
378
|
setTimeout(() => jobs.delete(jobId), 1800000);
|
|
398
379
|
setTimeout(() => { jobs.set(jobId, { status: "timeout" }); }, 300000);
|
|
399
380
|
return { ok: true, jobId };
|
|
@@ -485,7 +466,7 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
485
466
|
/*
|
|
486
467
|
* Uploads a file from a buffer, creates a record in the resource, and returns the file path and preview URL.
|
|
487
468
|
*/
|
|
488
|
-
|
|
469
|
+
uploadFromBufferToNewRecord(_a) {
|
|
489
470
|
return __awaiter(this, arguments, void 0, function* ({ filename, contentType, buffer, adminUser, extra, recordAttributes, }) {
|
|
490
471
|
var _b;
|
|
491
472
|
if (!filename || !contentType || !buffer) {
|
|
@@ -554,7 +535,7 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
554
535
|
if (!this.resourceConfig) {
|
|
555
536
|
throw new Error('resourceConfig is not initialized yet');
|
|
556
537
|
}
|
|
557
|
-
const { error: createError } = yield this.adminforth.createResourceRecord({
|
|
538
|
+
const { error: createError, createdRecord, newRecordId } = yield this.adminforth.createResourceRecord({
|
|
558
539
|
resource: this.resourceConfig,
|
|
559
540
|
record: Object.assign(Object.assign({}, (recordAttributes !== null && recordAttributes !== void 0 ? recordAttributes : {})), { [this.options.pathColumnName]: filePath }),
|
|
560
541
|
adminUser,
|
|
@@ -569,6 +550,134 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
569
550
|
}
|
|
570
551
|
throw new Error(`Error creating record after upload: ${createError}`);
|
|
571
552
|
}
|
|
553
|
+
const pkColumn = this.resourceConfig.columns.find((column) => column.primaryKey);
|
|
554
|
+
const pkName = pkColumn === null || pkColumn === void 0 ? void 0 : pkColumn.name;
|
|
555
|
+
const newRecordPk = newRecordId !== null && newRecordId !== void 0 ? newRecordId : (pkName && createdRecord ? createdRecord[pkName] : undefined);
|
|
556
|
+
let previewUrl;
|
|
557
|
+
if ((_b = this.options.preview) === null || _b === void 0 ? void 0 : _b.previewUrl) {
|
|
558
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
previewUrl = yield this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
path: filePath,
|
|
565
|
+
previewUrl,
|
|
566
|
+
newRecordPk,
|
|
567
|
+
};
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
/*
|
|
571
|
+
* Uploads a file from a buffer and updates an existing record's path column.
|
|
572
|
+
* If the newly generated storage path would be the same as the current path,
|
|
573
|
+
* throws an error to avoid potential caching issues.
|
|
574
|
+
*/
|
|
575
|
+
uploadFromBufferToExistingRecord(_a) {
|
|
576
|
+
return __awaiter(this, arguments, void 0, function* ({ recordId, filename, contentType, buffer, adminUser, extra, }) {
|
|
577
|
+
var _b;
|
|
578
|
+
if (recordId === undefined || recordId === null) {
|
|
579
|
+
throw new Error('recordId is required');
|
|
580
|
+
}
|
|
581
|
+
if (!filename || !contentType || !buffer) {
|
|
582
|
+
throw new Error('filename, contentType and buffer are required');
|
|
583
|
+
}
|
|
584
|
+
if (!this.resourceConfig) {
|
|
585
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
586
|
+
}
|
|
587
|
+
const pkColumn = this.resourceConfig.columns.find((column) => column.primaryKey);
|
|
588
|
+
const pkName = pkColumn === null || pkColumn === void 0 ? void 0 : pkColumn.name;
|
|
589
|
+
if (!pkName) {
|
|
590
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
591
|
+
}
|
|
592
|
+
const existingRecord = yield this.adminforth
|
|
593
|
+
.resource(this.resourceConfig.resourceId)
|
|
594
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
595
|
+
if (!existingRecord) {
|
|
596
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
597
|
+
}
|
|
598
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
599
|
+
if (lastDotIndex === -1) {
|
|
600
|
+
throw new Error('filename must contain an extension');
|
|
601
|
+
}
|
|
602
|
+
const originalExtension = filename.substring(lastDotIndex + 1).toLowerCase();
|
|
603
|
+
const originalFilename = filename.substring(0, lastDotIndex);
|
|
604
|
+
if (this.options.allowedFileExtensions && !this.options.allowedFileExtensions.includes(originalExtension)) {
|
|
605
|
+
throw new Error(`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}`);
|
|
606
|
+
}
|
|
607
|
+
let nodeBuffer;
|
|
608
|
+
if (Buffer.isBuffer(buffer)) {
|
|
609
|
+
nodeBuffer = buffer;
|
|
610
|
+
}
|
|
611
|
+
else if (buffer instanceof ArrayBuffer) {
|
|
612
|
+
nodeBuffer = Buffer.from(buffer);
|
|
613
|
+
}
|
|
614
|
+
else if (ArrayBuffer.isView(buffer)) {
|
|
615
|
+
nodeBuffer = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
throw new Error('Unsupported buffer type');
|
|
619
|
+
}
|
|
620
|
+
const size = nodeBuffer.byteLength;
|
|
621
|
+
if (this.options.maxFileSize && size > this.options.maxFileSize) {
|
|
622
|
+
throw new Error(`File size ${size} is too large. Maximum allowed size is ${this.options.maxFileSize}`);
|
|
623
|
+
}
|
|
624
|
+
const existingValue = existingRecord[this.options.pathColumnName];
|
|
625
|
+
const existingPaths = this.normalizePaths(existingValue);
|
|
626
|
+
const filePath = this.options.filePath({
|
|
627
|
+
originalFilename,
|
|
628
|
+
originalExtension,
|
|
629
|
+
contentType,
|
|
630
|
+
record: existingRecord,
|
|
631
|
+
});
|
|
632
|
+
if (filePath.startsWith('/')) {
|
|
633
|
+
throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
|
|
634
|
+
}
|
|
635
|
+
if (existingPaths.includes(filePath)) {
|
|
636
|
+
throw new Error('New file path cannot be the same as existing path to avoid caching issues');
|
|
637
|
+
}
|
|
638
|
+
const { uploadUrl, uploadExtraParams } = yield this.options.storageAdapter.getUploadSignedUrl(filePath, contentType, 1800);
|
|
639
|
+
const headers = {
|
|
640
|
+
'Content-Type': contentType,
|
|
641
|
+
};
|
|
642
|
+
if (uploadExtraParams) {
|
|
643
|
+
Object.entries(uploadExtraParams).forEach(([key, value]) => {
|
|
644
|
+
headers[key] = value;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const resp = yield fetch(uploadUrl, {
|
|
648
|
+
method: 'PUT',
|
|
649
|
+
headers,
|
|
650
|
+
body: nodeBuffer,
|
|
651
|
+
});
|
|
652
|
+
if (!resp.ok) {
|
|
653
|
+
let bodyText = '';
|
|
654
|
+
try {
|
|
655
|
+
bodyText = yield resp.text();
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
// ignore
|
|
659
|
+
}
|
|
660
|
+
throw new Error(`Upload failed with status ${resp.status}: ${bodyText}`);
|
|
661
|
+
}
|
|
662
|
+
const { error: updateError } = yield this.adminforth.updateResourceRecord({
|
|
663
|
+
resource: this.resourceConfig,
|
|
664
|
+
recordId,
|
|
665
|
+
oldRecord: existingRecord,
|
|
666
|
+
adminUser,
|
|
667
|
+
extra,
|
|
668
|
+
updates: {
|
|
669
|
+
[this.options.pathColumnName]: filePath,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
if (updateError) {
|
|
673
|
+
try {
|
|
674
|
+
yield this.markKeyForDeletion(filePath);
|
|
675
|
+
}
|
|
676
|
+
catch (e) {
|
|
677
|
+
// best-effort cleanup, ignore error
|
|
678
|
+
}
|
|
679
|
+
throw new Error(`Error updating record after upload: ${updateError}`);
|
|
680
|
+
}
|
|
572
681
|
let previewUrl;
|
|
573
682
|
if ((_b = this.options.preview) === null || _b === void 0 ? void 0 : _b.previewUrl) {
|
|
574
683
|
previewUrl = this.options.preview.previewUrl({ filePath });
|
package/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { PluginOptions, UploadFromBufferParams } from './types.js';
|
|
2
|
+
import { PluginOptions, UploadFromBufferParams, UploadFromBufferToExistingRecordParams } from './types.js';
|
|
3
3
|
import { AdminForthPlugin, AdminForthResourceColumn, AdminForthResource, Filters, IAdminForth, IHttpServer, suggestIfTypo, RateLimiter, AdminUser, HttpExtra } from "adminforth";
|
|
4
4
|
import { Readable } from "stream";
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
@@ -104,39 +104,14 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
104
104
|
return this.callStorageAdapter('markKeyForDeletion', 'markKeyForDeletation', filePath);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
private async generateImages(jobId: string, prompt: string, recordId: any, adminUser: any, headers: any) {
|
|
107
|
+
private async generateImages(jobId: string, prompt: string,requestAttachmentFiles: string[], recordId: any, adminUser: any, headers: any) {
|
|
108
108
|
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
109
|
if (!await this.rateLimiter.consume(`${this.pluginInstanceId}-${this.adminforth.auth.getClientIp(headers)}`)) {
|
|
116
110
|
jobs.set(jobId, { status: "failed", error: this.options.generation.rateLimit.errorMessage });
|
|
117
111
|
return { error: this.options.generation.rateLimit.errorMessage };
|
|
118
112
|
}
|
|
119
113
|
}
|
|
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
|
-
}
|
|
114
|
+
let attachmentFiles = requestAttachmentFiles;
|
|
140
115
|
|
|
141
116
|
let error: string | undefined = undefined;
|
|
142
117
|
|
|
@@ -448,12 +423,11 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
448
423
|
method: 'POST',
|
|
449
424
|
path: `/plugin/${this.pluginInstanceId}/create-image-generation-job`,
|
|
450
425
|
handler: async ({ body, adminUser, headers }) => {
|
|
451
|
-
const { prompt, recordId } = body;
|
|
452
|
-
|
|
426
|
+
const { prompt, recordId, requestAttachmentFiles } = body;
|
|
453
427
|
const jobId = randomUUID();
|
|
454
428
|
jobs.set(jobId, { status: "in_progress" });
|
|
455
429
|
|
|
456
|
-
this.generateImages(jobId, prompt, recordId, adminUser, headers);
|
|
430
|
+
this.generateImages(jobId, prompt, requestAttachmentFiles, recordId, adminUser, headers);
|
|
457
431
|
setTimeout(() => jobs.delete(jobId), 1_800_000);
|
|
458
432
|
setTimeout(() => {jobs.set(jobId, { status: "timeout" });}, 300_000);
|
|
459
433
|
|
|
@@ -549,22 +523,19 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
549
523
|
return { error: 'failed to generate preview URL' };
|
|
550
524
|
},
|
|
551
525
|
});
|
|
552
|
-
|
|
553
|
-
|
|
554
526
|
}
|
|
555
527
|
|
|
556
|
-
|
|
557
528
|
/*
|
|
558
529
|
* Uploads a file from a buffer, creates a record in the resource, and returns the file path and preview URL.
|
|
559
530
|
*/
|
|
560
|
-
async
|
|
531
|
+
async uploadFromBufferToNewRecord({
|
|
561
532
|
filename,
|
|
562
533
|
contentType,
|
|
563
534
|
buffer,
|
|
564
535
|
adminUser,
|
|
565
536
|
extra,
|
|
566
537
|
recordAttributes,
|
|
567
|
-
}: UploadFromBufferParams): Promise<{ path: string; previewUrl: string }> {
|
|
538
|
+
}: UploadFromBufferParams): Promise<{ path: string; previewUrl: string; newRecordPk: any }> {
|
|
568
539
|
if (!filename || !contentType || !buffer) {
|
|
569
540
|
throw new Error('filename, contentType and buffer are required');
|
|
570
541
|
}
|
|
@@ -648,7 +619,7 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
648
619
|
throw new Error('resourceConfig is not initialized yet');
|
|
649
620
|
}
|
|
650
621
|
|
|
651
|
-
const { error: createError } = await this.adminforth.createResourceRecord({
|
|
622
|
+
const { error: createError, createdRecord, newRecordId }: any = await this.adminforth.createResourceRecord({
|
|
652
623
|
resource: this.resourceConfig,
|
|
653
624
|
record: { ...(recordAttributes ?? {}), [this.options.pathColumnName]: filePath },
|
|
654
625
|
adminUser,
|
|
@@ -664,6 +635,164 @@ export default class UploadPlugin extends AdminForthPlugin {
|
|
|
664
635
|
throw new Error(`Error creating record after upload: ${createError}`);
|
|
665
636
|
}
|
|
666
637
|
|
|
638
|
+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
|
|
639
|
+
const pkName = pkColumn?.name;
|
|
640
|
+
const newRecordPk = newRecordId ?? (pkName && createdRecord ? createdRecord[pkName] : undefined);
|
|
641
|
+
|
|
642
|
+
let previewUrl: string;
|
|
643
|
+
if (this.options.preview?.previewUrl) {
|
|
644
|
+
previewUrl = this.options.preview.previewUrl({ filePath });
|
|
645
|
+
} else {
|
|
646
|
+
previewUrl = await this.options.storageAdapter.getDownloadUrl(filePath, 1800);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return {
|
|
650
|
+
path: filePath,
|
|
651
|
+
previewUrl,
|
|
652
|
+
newRecordPk,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/*
|
|
657
|
+
* Uploads a file from a buffer and updates an existing record's path column.
|
|
658
|
+
* If the newly generated storage path would be the same as the current path,
|
|
659
|
+
* throws an error to avoid potential caching issues.
|
|
660
|
+
*/
|
|
661
|
+
async uploadFromBufferToExistingRecord({
|
|
662
|
+
recordId,
|
|
663
|
+
filename,
|
|
664
|
+
contentType,
|
|
665
|
+
buffer,
|
|
666
|
+
adminUser,
|
|
667
|
+
extra,
|
|
668
|
+
}: UploadFromBufferToExistingRecordParams): Promise<{ path: string; previewUrl: string }> {
|
|
669
|
+
if (recordId === undefined || recordId === null) {
|
|
670
|
+
throw new Error('recordId is required');
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!filename || !contentType || !buffer) {
|
|
674
|
+
throw new Error('filename, contentType and buffer are required');
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!this.resourceConfig) {
|
|
678
|
+
throw new Error('resourceConfig is not initialized yet');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const pkColumn = this.resourceConfig.columns.find((column: any) => column.primaryKey);
|
|
682
|
+
const pkName = pkColumn?.name;
|
|
683
|
+
if (!pkName) {
|
|
684
|
+
throw new Error('Primary key column not found in resource configuration');
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const existingRecord = await this.adminforth
|
|
688
|
+
.resource(this.resourceConfig.resourceId)
|
|
689
|
+
.get([Filters.EQ(pkName, recordId)]);
|
|
690
|
+
|
|
691
|
+
if (!existingRecord) {
|
|
692
|
+
throw new Error(`Record with id ${recordId} not found`);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const lastDotIndex = filename.lastIndexOf('.');
|
|
696
|
+
if (lastDotIndex === -1) {
|
|
697
|
+
throw new Error('filename must contain an extension');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const originalExtension = filename.substring(lastDotIndex + 1).toLowerCase();
|
|
701
|
+
const originalFilename = filename.substring(0, lastDotIndex);
|
|
702
|
+
|
|
703
|
+
if (this.options.allowedFileExtensions && !this.options.allowedFileExtensions.includes(originalExtension)) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}`
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
let nodeBuffer: Buffer;
|
|
710
|
+
if (Buffer.isBuffer(buffer)) {
|
|
711
|
+
nodeBuffer = buffer;
|
|
712
|
+
} else if (buffer instanceof ArrayBuffer) {
|
|
713
|
+
nodeBuffer = Buffer.from(buffer);
|
|
714
|
+
} else if (ArrayBuffer.isView(buffer)) {
|
|
715
|
+
nodeBuffer = Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
716
|
+
} else {
|
|
717
|
+
throw new Error('Unsupported buffer type');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const size = nodeBuffer.byteLength;
|
|
721
|
+
if (this.options.maxFileSize && size > this.options.maxFileSize) {
|
|
722
|
+
throw new Error(
|
|
723
|
+
`File size ${size} is too large. Maximum allowed size is ${this.options.maxFileSize}`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const existingValue = existingRecord[this.options.pathColumnName];
|
|
728
|
+
const existingPaths = this.normalizePaths(existingValue);
|
|
729
|
+
|
|
730
|
+
const filePath: string = this.options.filePath({
|
|
731
|
+
originalFilename,
|
|
732
|
+
originalExtension,
|
|
733
|
+
contentType,
|
|
734
|
+
record: existingRecord,
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
if (filePath.startsWith('/')) {
|
|
738
|
+
throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (existingPaths.includes(filePath)) {
|
|
742
|
+
throw new Error('New file path cannot be the same as existing path to avoid caching issues');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const { uploadUrl, uploadExtraParams } = await this.options.storageAdapter.getUploadSignedUrl(
|
|
746
|
+
filePath,
|
|
747
|
+
contentType,
|
|
748
|
+
1800,
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const headers: Record<string, string> = {
|
|
752
|
+
'Content-Type': contentType,
|
|
753
|
+
};
|
|
754
|
+
if (uploadExtraParams) {
|
|
755
|
+
Object.entries(uploadExtraParams).forEach(([key, value]) => {
|
|
756
|
+
headers[key] = value as string;
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const resp = await fetch(uploadUrl as any, {
|
|
761
|
+
method: 'PUT',
|
|
762
|
+
headers,
|
|
763
|
+
body: nodeBuffer as any,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
if (!resp.ok) {
|
|
767
|
+
let bodyText = '';
|
|
768
|
+
try {
|
|
769
|
+
bodyText = await resp.text();
|
|
770
|
+
} catch (e) {
|
|
771
|
+
// ignore
|
|
772
|
+
}
|
|
773
|
+
throw new Error(`Upload failed with status ${resp.status}: ${bodyText}`);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const { error: updateError } = await this.adminforth.updateResourceRecord({
|
|
777
|
+
resource: this.resourceConfig,
|
|
778
|
+
recordId,
|
|
779
|
+
oldRecord: existingRecord,
|
|
780
|
+
adminUser,
|
|
781
|
+
extra,
|
|
782
|
+
updates: {
|
|
783
|
+
[this.options.pathColumnName]: filePath,
|
|
784
|
+
},
|
|
785
|
+
} as any);
|
|
786
|
+
|
|
787
|
+
if (updateError) {
|
|
788
|
+
try {
|
|
789
|
+
await this.markKeyForDeletion(filePath);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
// best-effort cleanup, ignore error
|
|
792
|
+
}
|
|
793
|
+
throw new Error(`Error updating record after upload: ${updateError}`);
|
|
794
|
+
}
|
|
795
|
+
|
|
667
796
|
let previewUrl: string;
|
|
668
797
|
if (this.options.preview?.previewUrl) {
|
|
669
798
|
previewUrl = this.options.preview.previewUrl({ filePath });
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -199,4 +199,17 @@ export type UploadFromBufferParams = {
|
|
|
199
199
|
* Values here do NOT affect the generated storage path.
|
|
200
200
|
*/
|
|
201
201
|
recordAttributes?: Record<string, any>;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parameters for the UploadPlugin.uploadFromBufferToExistingRecord API.
|
|
206
|
+
* Used to upload a binary file buffer and update the path column
|
|
207
|
+
* of an already existing record identified by its primary key.
|
|
208
|
+
*/
|
|
209
|
+
export type UploadFromBufferToExistingRecordParams = UploadFromBufferParams & {
|
|
210
|
+
/**
|
|
211
|
+
* Primary key value of the existing record whose file path
|
|
212
|
+
* should be replaced.
|
|
213
|
+
*/
|
|
214
|
+
recordId: any;
|
|
202
215
|
};
|