mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
446 lines
14 KiB
446 lines
14 KiB
import type { AttachmentReqType, AttachmentType } from 'nocodb-sdk' |
|
import { populateUniqueFileName } from 'nocodb-sdk' |
|
import DOMPurify from 'isomorphic-dompurify' |
|
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 baseURL = $api.instance.defaults.baseURL |
|
|
|
const { row } = useSmartsheetRowStoreOrThrow() |
|
|
|
const { fetchSharedViewAttachment } = useSharedView() |
|
|
|
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 column = inject(ColumnInj, ref()) |
|
|
|
const editEnabled = inject(EditModeInj, ref(false)) |
|
|
|
/** 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) |
|
|
|
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: Math.max(1, +appInfo.value.ncMaxAttachmentsAllowed || 50) || 50, |
|
// 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 |
|
|
|
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 ( |
|
visibleItems.value.length + (selectedFiles.length || selectedFileUrls?.length || 0) > |
|
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 && 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) { |
|
try { |
|
const data = await api.storage.upload( |
|
{ |
|
path: [NOCO, base.value.id, meta.value?.id, column.value?.id].join('/'), |
|
}, |
|
{ |
|
files, |
|
}, |
|
) |
|
// 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(e.message || t('msg.error.internalError')) |
|
} |
|
} else if (imageUrls.length) { |
|
const data = uploadViaUrl(imageUrls) |
|
if (!data) return |
|
newAttachments.push(...data) |
|
} |
|
if (newAttachments?.length) updateModelValue([...attachments.value, ...newAttachments]) |
|
} |
|
|
|
async function uploadViaUrl(url: AttachmentReqType | AttachmentReqType[], returnError = false) { |
|
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 |
|
} |
|
} |
|
|
|
function updateAttachmentTitle(idx: number, title: string) { |
|
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, title: newTitle } |
|
} |
|
|
|
isRenameModalOpen.value = false |
|
resolve(true) |
|
}, |
|
onCancel: () => { |
|
close() |
|
isRenameModalOpen.value = false |
|
resolve(true) |
|
}, |
|
}) |
|
}) |
|
} |
|
|
|
/** save files on drop */ |
|
async function onDrop(droppedFiles: FileList | File[] | null, event: DragEvent) { |
|
if (isReadonly.value) return |
|
if (droppedFiles) { |
|
// set files |
|
await onFileSelect(droppedFiles) |
|
} else { |
|
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() { |
|
await Promise.all(selectedVisibleItems.value.map(async (v, i) => v && (await downloadAttachment(visibleItems.value[i])))) |
|
selectedVisibleItems.value = Array.from({ length: visibleItems.value.length }, () => false) |
|
} |
|
|
|
/** 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, |
|
downloadAttachment, |
|
updateModelValue, |
|
selectedFile, |
|
uploadViaUrl, |
|
selectedVisibleItems, |
|
storedFiles, |
|
bulkDownloadAttachments, |
|
defaultAttachmentMeta, |
|
startCamera, |
|
stopCamera, |
|
videoStream, |
|
permissionGranted, |
|
isRenameModalOpen, |
|
updateAttachmentTitle, |
|
} |
|
}, |
|
'useAttachmentCell', |
|
)
|
|
|