Files
nocodb/packages/nc-gui/components/cell/attachment/utils.ts
2026-01-13 14:46:10 +00:00

592 lines
18 KiB
TypeScript

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<string, any>[]) => 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<AttachmentType[]>([])
const attachments = ref<AttachmentType[]>([])
const modalRendered = ref(false)
const modalVisible = ref(false)
/** for image carousel */
const selectedFile = ref()
const videoStream = ref<MediaStream | null>(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<any[]>(() => (isPublic.value && isForm.value ? storedFiles.value : attachments.value))
/** for bulk download */
const selectedVisibleItems = ref<boolean[]>(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<AttachmentType>(
Array.from(files).map(
(file) =>
new Promise<AttachmentType>((resolve) => {
const res: { file: File; title: string; mimetype: string; data?: any } = {
...file,
file,
title: file.name,
mimetype: file.type,
}
if (isImage(file.name, (<any>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<boolean>((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<string, Uint8Array> = {}
// Add files to zip object
filesData.forEach(({ name, data }) => {
zip[name] = data
})
try {
// Use fflate to create zip
const zipData = await new Promise<Uint8Array>((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',
)