import type { AttachmentType } from 'nocodb-sdk' import RenameFile from './RenameFile.vue' import { ColumnInj, EditModeInj, IsFormInj, IsPublicInj, MetaInj, NOCO, ReadonlyInj, computed, inject, isImage, message, mimeTypes, ref, useApi, useAttachment, useFileDialog, useI18n, useInjectionState, useProject, watch, } from '#imports' import MdiPdfBox from '~icons/mdi/pdf-box' import MdiFileWordOutline from '~icons/mdi/file-word-outline' import MdiFilePowerpointBox from '~icons/mdi/file-powerpoint-box' import MdiFileExcelOutline from '~icons/mdi/file-excel-outline' import IcOutlineInsertDriveFile from '~icons/ic/outline-insert-drive-file' export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( (updateModelValue: (data: string | Record[]) => void) => { const isReadonly = inject(ReadonlyInj, ref(false)) const isPublic = inject(IsPublicInj, ref(false)) const isForm = inject(IsFormInj, ref(false)) const meta = inject(MetaInj, ref()) const column = inject(ColumnInj, ref()) const editEnabled = inject(EditModeInj, ref(false)) /** keep user selected File object */ const storedFiles = ref([]) const attachments = ref([]) const modalVisible = ref(false) /** for image carousel */ const selectedImage = ref() const { project } = useProject() const { api, isLoading } = useApi() const { files, open } = useFileDialog() const { appInfo } = useGlobal() const { t } = useI18n() const { getAttachmentSrc } = useAttachment() const defaultAttachmentMeta = { ...(appInfo.value.ee && { // Maximum Number of Attachments per cell maxNumberOfAttachments: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50, // Maximum File Size per file maxAttachmentSize: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 20) || 20, supportedAttachmentMimeTypes: ['*'], }), } /** our currently visible items, either the locally stored or the ones from db, depending on isPublic & isForm status */ const visibleItems = computed(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value)) /** for bulk download */ const selectedVisibleItems = ref(Array.from({ length: visibleItems.value.length }, () => false)) /** remove a file from our stored attachments (either locally stored or saved ones) */ function removeFile(i: number) { if (isPublic.value) { storedFiles.value.splice(i, 1) attachments.value.splice(i, 1) selectedVisibleItems.value.splice(i, 1) updateModelValue(storedFiles.value) } else { attachments.value.splice(i, 1) selectedVisibleItems.value.splice(i, 1) updateModelValue(JSON.stringify(attachments.value)) } } /** save a file on select / drop, either locally (in-memory) or in the db */ async function onFileSelect(selectedFiles: FileList | File[]) { if (!selectedFiles.length) return const attachmentMeta = { ...defaultAttachmentMeta, ...(typeof column.value?.meta === 'string' ? JSON.parse(column.value.meta) : column.value?.meta), } const newAttachments = [] const files: File[] = [] for (const file of selectedFiles) { if (appInfo.value.ee) { // verify number of files if (visibleItems.value.length + selectedFiles.length > attachmentMeta.maxNumberOfAttachments) { message.error( `You can only upload at most ${attachmentMeta.maxNumberOfAttachments} file${ attachmentMeta.maxNumberOfAttachments > 1 ? 's' : '' } to this cell.`, ) return } // verify file size if (file.size > attachmentMeta.maxAttachmentSize * 1024 * 1024) { message.error(`The size of ${file.name} exceeds the maximum file size ${attachmentMeta.maxAttachmentSize} MB.`) continue } // verify mime type if ( !attachmentMeta.supportedAttachmentMimeTypes.includes('*') && !attachmentMeta.supportedAttachmentMimeTypes.includes(file.type) && !attachmentMeta.supportedAttachmentMimeTypes.includes(file.type.split('/')[0]) ) { message.error(`${file.name} has the mime type ${file.type} which is not allowed in this column.`) continue } } files.push(file) } if (isPublic.value && isForm.value) { const newFiles = await Promise.all( Array.from(files).map( (file) => new Promise((resolve) => { const res: { file: File; title: string; mimetype: string; data?: any } = { ...file, file, title: file.name, mimetype: file.type, } if (isImage(file.name, (file).mimetype ?? file.type)) { const reader = new FileReader() reader.onload = (e) => { res.data = e.target?.result resolve(res) } reader.onerror = () => { resolve(res) } reader.readAsDataURL(file) } else { resolve(res) } }), ), ) attachments.value = [...attachments.value, ...newFiles] return updateModelValue(attachments.value) } try { const data = await api.storage.upload( { path: [NOCO, project.value.title, meta.value?.title, column.value?.title].join('/'), }, { files, json: '{}', }, ) newAttachments.push(...data) } catch (e: any) { message.error(e.message || t('msg.error.internalError')) } updateModelValue(JSON.stringify([...attachments.value, ...newAttachments])) } async function renameFile(attachment: AttachmentType, idx: number) { return new Promise((resolve) => { const { close } = useDialog(RenameFile, { title: attachment.title, onRename: (newTitle: string) => { attachments.value[idx].title = newTitle updateModelValue(JSON.stringify(attachments.value)) close() resolve(true) }, onCancel: () => { close() resolve(true) }, }) }) } /** save files on drop */ async function onDrop(droppedFiles: File[] | null) { if (droppedFiles) { // set files await onFileSelect(droppedFiles) } } /** bulk download selected files */ async function bulkDownloadFiles() { await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadFile(visibleItems.value[i])))) selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false) } /** download a file */ async function downloadFile(item: AttachmentType) { const src = getAttachmentSrc(item) const mimeType = mimeTypes[item?.mimetype?.split('/')?.pop() || 'txt'] // test if the first source is accessible or not await fetch(src) .then((res) => { // if not accessible or the content type doesn't match // then throw the error to trigger the fallback source if (!res.ok || res.headers.get('Content-Type') !== mimeType) { throw new Error('Failed to download file') } // if accessible, then call blob() res.blob() }) .then(async (_) => { // save the file from the first source ;(await import('file-saver')).saveAs(src, item.title) }) .catch(async (_) => { // try the fallback source with similar logic const fallbackSrc = item.url! await fetch(fallbackSrc) .then((res) => { if (!res.ok || res.headers.get('Content-Type') !== mimeType) { throw new Error('Failed to download file') } res.blob() }) .then(async (_) => { ;(await import('file-saver')).saveAs(fallbackSrc, item.title) }) .catch(async (e) => { message.error(e.message) }) }) } const FileIcon = (icon: string) => { switch (icon) { case 'mdi-pdf-box': return MdiPdfBox case 'mdi-file-word-outline': return MdiFileWordOutline case 'mdi-file-powerpoint-box': return MdiFilePowerpointBox case 'mdi-file-excel-outline': return MdiFileExcelOutline default: return IcOutlineInsertDriveFile } } watch(files, (nextFiles) => nextFiles && onFileSelect(nextFiles)) return { attachments, visibleItems, isPublic, isForm, isReadonly, meta, column, editEnabled, isLoading, api, open: () => open(), onDrop, modalVisible, FileIcon, removeFile, renameFile, downloadFile, updateModelValue, selectedImage, selectedVisibleItems, storedFiles, bulkDownloadFiles, defaultAttachmentMeta, } }, 'useAttachmentCell', )