import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk' import { populateUniqueFileName } from 'nocodb-sdk' import DOMPurify from 'isomorphic-dompurify' import { zip as fflateZip } from 'fflate' import RenameFile from './RenameFile.vue' import MdiPdfBox from '~icons/nc-icons-v2/file-type-pdf' import MdiFileWordOutline from '~icons/nc-icons-v2/file-type-word' import MdiFilePowerpointBox from '~icons/nc-icons-v2/file-type-presentation' import MdiFileExcelOutline from '~icons/nc-icons-v2/file-type-csv' import IcOutlineInsertDriveFile from '~icons/nc-icons-v2/file-type-unknown' export const [useProvideAttachmentCell, useAttachmentCell] = useInjectionState( (updateModelValue: (data: string | Record[]) => void) => { const { $api } = useNuxtApp() const { isUIAllowed } = useRoles() const baseURL = $api.instance.defaults.baseURL const { row } = useSmartsheetRowStoreOrThrow() const { fetchSharedViewAttachment } = useSharedView() const { showStoragePlanLimitExceededModal, maxAttachmentsAllowedInCell, showUpgradeToAddMoreAttachmentsInCell } = useEeConfig() const { batchUploadFiles } = useAttachment() const isReadonly = inject(ReadonlyInj, ref(false)) const { t } = useI18n() const isPublic = inject(IsPublicInj, ref(false)) const isForm = inject(IsFormInj, ref(false)) const meta = inject(MetaInj, ref()) const isSharedForm = computed(() => { return isForm.value && isPublic.value }) const column = inject(ColumnInj, ref()) const editEnabled = inject(EditModeInj, ref(false)) const isEditAllowed = computed(() => (!isPublic.value && !isReadonly.value && isUIAllowed('dataEdit')) || isSharedForm.value) /** keep user selected File object */ const storedFiles = ref([]) const attachments = ref([]) const modalRendered = ref(false) const modalVisible = ref(false) /** for image carousel */ const selectedFile = ref() const videoStream = ref(null) const permissionGranted = ref(false) // User can drag and drop files multiple times so we have to keep track of that and reduce count after upload are done const uploadingCount = ref(0) const isUploading = computed(() => { return uploadingCount.value > 0 }) const { base } = storeToRefs(useBase()) const { api, isLoading } = useApi() const { files, open } = useFileDialog({ reset: true, }) const isRenameModalOpen = ref(false) const { appInfo } = useGlobal() const defaultAttachmentMeta = { ...(appInfo.value.ee && { // Maximum Number of Attachments per cell maxNumberOfAttachments: maxAttachmentsAllowedInCell.value, // Maximum File Size per file maxAttachmentSize: Math.max(1, +appInfo.value.ncAttachmentFieldSize || 20) || 20, supportedAttachmentMimeTypes: ['*'], }), } const startCamera = async () => { if (!videoStream.value) { videoStream.value = await navigator.mediaDevices.getUserMedia({ video: true }) } permissionGranted.value = true } const stopCamera = () => { videoStream.value?.getTracks().forEach((track) => track.stop()) videoStream.value = null } /** 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(attachments.value) } } /** save a file on select / drop, either locally (in-memory) or in the db */ async function onFileSelect(selectedFiles: FileList | File[], selectedFileUrls?: AttachmentReqType[]) { if (!selectedFiles.length && !selectedFileUrls?.length) return if (showStoragePlanLimitExceededModal()) return const attachmentMeta = { ...defaultAttachmentMeta, ...parseProp(column?.value?.meta), } const newAttachments: AttachmentType[] = [] const files: File[] = [] const imageUrls: AttachmentReqType[] = [] for (const file of selectedFiles.length ? selectedFiles : selectedFileUrls || []) { if (appInfo.value.ee) { // verify number of files if ( showUpgradeToAddMoreAttachmentsInCell({ totalAttachments: visibleItems.value.length + (selectedFiles.length || selectedFileUrls?.length || 0), }) ) { return } // verify file size if (file?.size && file.size > attachmentMeta.maxAttachmentSize) { message.error( `The size of ${ (file as File)?.name || (file as AttachmentReqType)?.fileName } exceeds the maximum file size ${getReadableFileSize(attachmentMeta.maxAttachmentSize)}.`, ) continue } // verify mime type if ( !attachmentMeta.supportedAttachmentMimeTypes.includes('*') && !attachmentMeta.supportedAttachmentMimeTypes.includes((file as File).type || (file as AttachmentReqType).mimetype) && !attachmentMeta.supportedAttachmentMimeTypes.includes( ((file as File)?.type || (file as AttachmentReqType).mimetype)?.split('/')[0], ) ) { message.error( `${(file as File)?.name || (file as AttachmentReqType)?.fileName} has the mime type ${ (file as File)?.type || (file as AttachmentReqType)?.mimetype } which is not allowed in this column.`, ) continue } } if (selectedFiles.length) { files.push(file as File) } else { const fileName = populateUniqueFileName( (file as AttachmentReqType).fileName ?? '', [...attachments.value, ...imageUrls].map((fn) => fn?.title || fn?.fileName), (file as File)?.type || (file as AttachmentReqType)?.mimetype || '', ) imageUrls.push({ ...(file as AttachmentReqType), fileName, title: fileName }) } } if (files.length && 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) } else if (isPublic.value && isForm.value) { attachments.value = [...attachments.value, ...imageUrls] return updateModelValue(attachments.value) } if (files.length) { uploadingCount.value++ try { const data = await batchUploadFiles(files, [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/')) // add suffix in duplicate file title for (const uploadedFile of data) { newAttachments.push({ ...uploadedFile, title: populateUniqueFileName( uploadedFile?.title, [...attachments.value, ...newAttachments].map((fn) => fn?.title || fn?.fileName), uploadedFile?.mimetype, ), }) } } catch (e: any) { message.error((await extractSdkResponseErrorMsg(e)) || t('msg.error.internalError')) } finally { uploadingCount.value-- } } else if (imageUrls.length) { const data = await uploadViaUrl(imageUrls) if (!data) return newAttachments.push(...data) } if (newAttachments?.length) updateModelValue([...attachments.value, ...newAttachments]) } async function uploadViaUrl(url: AttachmentReqType | AttachmentReqType[], returnError = false) { uploadingCount.value++ const imageUrl = Array.isArray(url) ? url : [url] try { const data = await api.storage.uploadByUrl( { path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'), }, imageUrl, ) return data } catch (e: any) { console.log(e) if (returnError) { return "File couldn't be uploaded. Verify URL & try again." } message.error("File couldn't be uploaded. Verify URL & try again.") return null } finally { uploadingCount.value-- } } function updateAttachmentTitle(idx: number, title: string) { if (attachments.value[idx]) { attachments.value[idx]!.title = title } updateModelValue(attachments.value) } async function renameFile(attachment: AttachmentType, idx: number, updateSelectedFile?: boolean) { return new Promise((resolve) => { isRenameModalOpen.value = true const { close } = useDialog(RenameFile, { title: attachment.title, onRename: (newTitle: string) => { updateAttachmentTitle(idx, newTitle) close() if (updateSelectedFile) { selectedFile.value = { ...attachment } } isRenameModalOpen.value = false resolve(true) }, onCancel: () => { close() isRenameModalOpen.value = false resolve(true) }, }) }) } async function renameFileInline(idx: number, newTitle: string, updateSelectedFile?: boolean) { updateAttachmentTitle(idx, newTitle) if (updateSelectedFile) { selectedFile.value = { ...attachments.value[idx] } } isRenameModalOpen.value = false } /** save files on drop */ async function onDrop(droppedFiles: FileList | File[] | null, event: DragEvent) { if (isReadonly.value || !isEditAllowed.value) return if (droppedFiles) { // set files await onFileSelect(droppedFiles) } else if (event) { event.preventDefault() // Sanitize the dataTransfer HTML string const sanitizedHtml = DOMPurify.sanitize(event.dataTransfer?.getData('text/html') ?? '') ?? '' const imageUrl = extractImageSrcFromRawHtml(sanitizedHtml) ?? '' if (!imageUrl) { message.error(t('msg.error.draggedContentIsNotTypeOfImage')) return } const imageData = (await getImageDataFromUrl(imageUrl)) as AttachmentReqType if (imageData?.mimetype) { await onFileSelect( [], [ { ...imageData, url: imageUrl, fileName: `image.${imageData?.mimetype?.split('/')[1]}`, title: `image.${imageData?.mimetype?.split('/')[1]}`, }, ], ) } else { message.error(t('msg.error.fieldToParseImageData')) } } } /** bulk download selected files */ async function bulkDownloadAttachments() { const items: AttachmentType[] = selectedVisibleItems.value .map((v, i) => (v ? visibleItems.value[i] : undefined)) .filter(Boolean) if (items.length === 0) return if (items.length === 1) { return downloadAttachment(items[0]!) } if (!meta.value || !column.value) return const modelId = meta.value.id const columnId = column.value.id const rowId = extractPkFromRow(unref(row).row, meta.value.columns!) if (!modelId || !columnId || !rowId) { console.error('Missing modelId, columnId or rowId') message.error('Failed to download file') } const filesData: { name: string; data: Uint8Array }[] = [] for (const item of items) { const src = item.url || item.path if (!src) { console.error('Missing src') message.error('Failed to download file') continue } const apiPromise = isPublic.value ? () => fetchSharedViewAttachment(columnId!, rowId!, src) : () => $api.dbDataTableRow.attachmentDownload(modelId!, columnId!, rowId!, { urlOrPath: src, }) let res try { res = await apiPromise() } catch {} if (!res) { console.error('Invalid response') message.error('Failed to download file') continue } let response: Response if (res.path) { response = await fetch(`${baseURL}/${res.path}`) } else if (res.url) { response = await fetch(`${res.url}`) } else { console.error('Invalid blob response') message.error('Failed to download file') continue } const arrayBuffer = await response.arrayBuffer() const fileName = item.title || src.split('/').pop() || 'file' filesData.push({ name: fileName, data: new Uint8Array(arrayBuffer), }) } if (filesData.length === 0) { message.error('No files to download') return } // Create a zip object const zip: Record = {} // Add files to zip object filesData.forEach(({ name, data }) => { zip[name] = data }) try { // Use fflate to create zip const zipData = await new Promise((resolve, reject) => { fflateZip(zip, (err, data) => { if (err) { reject(err) } else { resolve(data) } }) }) // Create blob and download const blob = new Blob([zipData], { type: 'application/zip' }) const zipURL = URL.createObjectURL(blob) try { window.open(zipURL, '_self') } catch (e) { console.error('Error opening blob window', e) message.error('Failed to download file') return undefined } finally { setTimeout(() => URL.revokeObjectURL(zipURL), 1000) } } catch (e) { console.error('Error creating zip file', e) message.error('Failed to create zip file') } } /** download a file */ async function downloadAttachment(item: AttachmentType) { if (!meta.value || !column.value) return const modelId = meta.value.id const columnId = column.value.id const rowId = extractPkFromRow(unref(row).row, meta.value.columns!) const src = item.url || item.path if (modelId && columnId && rowId && src) { const apiPromise = isPublic.value ? () => fetchSharedViewAttachment(columnId, rowId, src) : () => $api.dbDataTableRow.attachmentDownload(modelId, columnId, rowId, { urlOrPath: src, }) await apiPromise().then((res) => { if (res?.path) { window.open(`${baseURL}/${res.path}`, '_self') } else if (res?.url) { window.open(res.url, '_self') } else { message.error('Failed to download file') } }) } else { message.error('Failed to download file') } } async function getImageDataFromUrl(imageUrl: string) { try { const response = await fetch(imageUrl) if (response.ok) { if (response.headers.get('content-type')?.startsWith('image/')) { return { mimetype: response.headers.get('content-type') || undefined, size: +(response.headers.get('content-length') || 0) || undefined, } as { mimetype?: string; size?: number } } else if (imageUrl.slice(imageUrl.lastIndexOf('.') + 1).toLowerCase().length) { return { mimetype: `image/${imageUrl.slice(imageUrl.lastIndexOf('.') + 1).toLowerCase()}`, size: +(response.headers.get('content-length') || 0) || undefined, } as { mimetype?: string; size?: number } } } } catch (err) { console.log(err) } } 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, modalRendered, modalVisible, FileIcon, removeFile, renameFile, renameFileInline, downloadAttachment, updateModelValue, selectedFile, uploadViaUrl, selectedVisibleItems, storedFiles, bulkDownloadAttachments, defaultAttachmentMeta, startCamera, stopCamera, videoStream, permissionGranted, isRenameModalOpen, updateAttachmentTitle, isEditAllowed, isSharedForm, isUploading, } }, 'useAttachmentCell', )