import { useStorage } from '@vueuse/core' import type { ProjectRoles } from 'nocodb-sdk' import { PlanLimitTypes, getProjectRole, hasMinimumRoleAccess } from 'nocodb-sdk' import { usePlugin } from './usePlugin' import { ExtensionsEvents } from '#imports' import { extensionUserPrefsManager } from '~/helpers/extensionUserPrefsManager' const extensionsState = createGlobalState(() => { const baseExtensions = ref>({}) return { baseExtensions } }) interface ExtensionPanelState { width: number isOpen: boolean } const extensionsPanelState = createGlobalState(() => useStorage>('nc-extensions-global-state', {}), ) export interface IKvStore> { get(key: K): T[K] | null set(key: K, value: T[K]): Promise delete(key: K): Promise serialize(): Record } abstract class ExtensionType { abstract id: string abstract uiKey: number abstract baseId: string abstract fkUserId: string abstract extensionId: string abstract title: string abstract kvStore: IKvStore abstract meta: any abstract order: number abstract setTitle(title: string): Promise abstract setMeta(key: string, value: any): Promise abstract clear(): Promise abstract delete(): Promise abstract serialize(): any abstract deserialize(data: any): void } export { ExtensionType } export const useExtensions = createSharedComposable(() => { const { pluginsLoaded, getPluginAssetUrl, availableExtensions, availableExtensionIds, availableExtensionMapById, pluginTypes, pluginDescriptionContent, isPluginsEnabled, } = usePlugin() const { baseExtensions } = extensionsState() const { $api, $e } = useNuxtApp() const { user } = useGlobal() const { isUIAllowed } = useRoles() const { base } = storeToRefs(useBase()) const { updateStatLimit, blockExtensions, showUpgradeToUseExtensions } = useEeConfig() const { isSharedBase } = storeToRefs(useWorkspace()) const eventBus = useEventBus(Symbol('useExtensions')) const extensionAccess = computed(() => { return { list: isUIAllowed('extensionList') && !isSharedBase.value, create: isUIAllowed('extensionCreate'), delete: isUIAllowed('extensionDelete'), update: isUIAllowed('extensionUpdate'), } }) const activeBaseExtensions = computed(() => { if (!base.value || !base.value.id) { return null } return baseExtensions.value[base.value.id] }) const panelState = extensionsPanelState() const extensionPanelSize = ref(40) const isPanelExpanded = ref(false) const savePanelState = () => { panelState.value = { ...panelState.value, [base.value.id!]: { width: extensionPanelSize.value, isOpen: isPanelExpanded.value, }, } } watch( base, () => { extensionPanelSize.value = +(panelState.value[base.value.id!]?.width || 40) isPanelExpanded.value = panelState.value[base.value.id!]?.isOpen || false }, { immediate: true }, ) // Debounce since width is updated continuously when user drags. watchDebounced([extensionPanelSize, isPanelExpanded], savePanelState, { debounce: 500, maxWait: 1000 }) const toggleExtensionPanel = () => { isPanelExpanded.value = !isPanelExpanded.value } /** * @param extensionId - The id of the extension which is defined in manifest.json to get the minimum access role for * @returns The minimum access role for the extension */ const getExtensionMinAccessRole = (extensionId: string): ExtensionManifest['minAccessRole'] => { const extension = availableExtensionMapById.value[extensionId] return extension?.minAccessRole || 'creator' } const extensionList = computed(() => { return (activeBaseExtensions.value ? activeBaseExtensions.value.extensions : []) .filter((e: ExtensionType) => availableExtensionIds.value.includes(e.extensionId)) .sort((a: ExtensionType, b: ExtensionType) => { return (a?.order ?? Infinity) - (b?.order ?? Infinity) }) }) const userCurrentBaseRole = computed(() => { return getProjectRole(user.value, true) }) /** * @param extensionId - The id of the extension which is defined in manifest.json to check if the user has access to * @returns True if the user has access to the extension, false otherwise */ const userHasAccessToExtension = (extensionId: string) => { return hasMinimumRoleAccess(user.value, getExtensionMinAccessRole(extensionId) as ProjectRoles) } const addExtension = async (extension: any) => { if (blockExtensions.value) { showUpgradeToUseExtensions() return } if (!base.value || !base.value.id || !baseExtensions.value[base.value.id]) { return } const extensionReq = { base_id: base.value.id, title: extension.title, extension_id: extension.id, meta: { collapsed: false, }, } try { const newExtension = await $api.internal.postOperation( base.value!.fk_workspace_id!, base.value.id, { operation: 'extensionCreate', }, extensionReq, ) if (newExtension) { updateStatLimit(PlanLimitTypes.LIMIT_EXTENSION_PER_WORKSPACE, 1) baseExtensions.value[base.value.id].extensions.push(new Extension(newExtension)) nextTick(() => { eventBus.emit(ExtensionsEvents.ADD, newExtension?.id) $e('a:extension:add', { extensionId: extensionReq.extension_id }) }) } return newExtension } catch (e: any) { message.error(await extractSdkResponseErrorMsg(e)) } } const updateExtension = async (extensionId: string, extension: any) => { if (!extensionList.value.length || !extensionAccess.value.update) { return } const extensionToUpdate = extensionList.value.find((ext: any) => ext.id === extensionId) if (!extensionToUpdate) return try { const updatedExtension = await $api.internal.postOperation( base.value!.fk_workspace_id!, base.value!.id!, { operation: 'extensionUpdate', extensionId, }, extension, ) if (updatedExtension) { extensionToUpdate.deserialize(updatedExtension) } return updatedExtension } catch (e: any) { message.error(await extractSdkResponseErrorMsg(e)) } } const updateExtensionMeta = async (extensionId: string, key: string, value: any) => { const extension = extensionList.value.find((ext: any) => ext.id === extensionId) if (!extension) { return } return updateExtension(extensionId, { meta: { ...extension.meta, [key]: value, }, }) } const deleteExtension = async (extensionId: string) => { if (!base.value || !base.value.id || !baseExtensions.value[base.value.id] || !extensionAccess.value.delete) { return } const extensionToDelete = baseExtensions.value[base.value.id].extensions.find((e: any) => e.id === extensionId) if (!extensionToDelete) return try { await $api.internal.postOperation( base.value!.fk_workspace_id!, base.value.id, { operation: 'extensionDelete', extensionId, }, {}, ) updateStatLimit(PlanLimitTypes.LIMIT_EXTENSION_PER_WORKSPACE, -1) baseExtensions.value[base.value.id].extensions = baseExtensions.value[base.value.id].extensions.filter( (ext: any) => ext.id !== extensionId, ) extensionUserPrefsManager.deleteExtension(extensionId) $e('a:extension:delete', { extensionId: extensionToDelete.extensionId }) } catch (e: any) { message.error(await extractSdkResponseErrorMsg(e)) } } const duplicateExtension = async (extensionId: string) => { if (!base.value || !base.value.id || !baseExtensions.value[base.value.id] || !extensionAccess.value.create) { return } const extension = extensionList.value.find((ext: any) => ext.id === extensionId) if (!extension) { return } const { id: _id, order: _order, ...extensionData } = extension.serialize() const newExtension = await $api.internal.postOperation( base.value!.fk_workspace_id!, base.value.id, { operation: 'extensionCreate', }, { ...extensionData, title: `${extension.title} (Copy)`, }, ) if (newExtension) { const duplicatedExtension = new Extension(newExtension) baseExtensions.value[base.value.id].extensions.push(duplicatedExtension) eventBus.emit(ExtensionsEvents.DUPLICATE, duplicatedExtension.id) $e('a:extension:duplicate', { extensionId: extension.extensionId }) } return newExtension } const clearKvStore = async (extensionId: string) => { const extension = extensionList.value.find((ext: any) => ext.id === extensionId) if (!extension) { return } let defaultKvStore = {} switch (extension.extensionId) { case 'nc-data-exporter': { defaultKvStore = { ...defaultKvStore, deletedExports: extension.kvStore.get('deletedExports') || [], } } } return updateExtension(extensionId, { kv_store: { ...defaultKvStore, }, }) } const loadExtensionsForBase = async (baseId: string) => { if (!baseId || !extensionAccess.value.list) { return } try { const { list } = await $api.internal.getOperation(base.value!.fk_workspace_id!, baseId, { operation: 'extensionList', }) const extensions = list?.map((ext: any) => new Extension(ext)) if (baseExtensions.value[baseId]) { baseExtensions.value[baseId].extensions = extensions || baseExtensions.value[baseId].extensions } else { baseExtensions.value[baseId] = { extensions: extensions || [], expanded: false, } } if (user.value?.id && extensions) { const validExtensionIds = extensions.map((ext: any) => ext.id) extensionUserPrefsManager.verifyAndCleanup(user.value.id, validExtensionIds) } } catch (e) { baseExtensions.value[baseId] = { extensions: [], expanded: false, } console.log(e) } } class KvStore = any> implements IKvStore { private _id: string private data: T private _extension: Extension | null = null constructor(id: string, data: T, extension?: Extension) { this._id = id this.data = data || {} this._extension = extension || null } get(key: K) { return this.data[key] || null } set(key: K, value: any) { this.data[key] = value // Skip update if last change was from realtime if (this._extension?.is_last_update_from_realtime) { this._extension.is_last_update_from_realtime = false return Promise.resolve() } return updateExtension(this._id, { kv_store: this.data }) } async delete(key: K) { delete this.data[key] await updateExtension(this._id, { kv_store: this.data }) } serialize() { return this.data } } class Extension implements ExtensionType { private _id: string private _baseId: string private _fkUserId: string private _extensionId: string private _title: string private _kvStore: KvStore private _meta: any private _order: number public uiKey = 0 public is_last_update_from_realtime = false constructor(data: any) { this._id = data.id this._baseId = data.base_id this._fkUserId = data.fk_user_id this._extensionId = data.extension_id this._title = data.title this._kvStore = new KvStore(this._id, data.kv_store, this) this._meta = data.meta this._order = data.order } get id() { return this._id } get baseId() { return this._baseId } get fkUserId() { return this._fkUserId } get extensionId() { return this._extensionId } get title() { return this._title } get kvStore() { return this._kvStore } get meta() { return this._meta } get order() { return this._order } serialize() { return { id: this._id, base_id: this._baseId, fk_user_id: this._fkUserId, extension_id: this._extensionId, title: this._title, kv_store: this._kvStore.serialize(), meta: this._meta, order: this._order, } } deserialize(data: any) { this._id = data.id this._baseId = data.base_id this._fkUserId = data.fk_user_id this._extensionId = data.extension_id this._title = data.title this._kvStore = new KvStore(this._id, data.kv_store, this) this._meta = data.meta this._order = data.order } setTitle(title: string): Promise { return updateExtension(this.id, { title }) } setMeta(key: string, value: any): Promise { if (!this._meta) { this._meta = {} } this._meta[key] = value return updateExtensionMeta(this.id, key, value) } async clear(): Promise { return clearKvStore(this.id).then(() => { this.uiKey++ nextTick(() => { eventBus.emit(ExtensionsEvents.CLEARDATA, this.id) $e('c:extension:clear-data', { extensionId: this._extensionId }) }) }) } delete(): Promise { return deleteExtension(this.id) } } watch( [() => base.value?.id, isPluginsEnabled, () => extensionAccess.value.list, () => pluginsLoaded.value], ([baseId, newPluginsEnabled, isAllowed, isPluginsLoaded]) => { if (!newPluginsEnabled || !baseId || !isAllowed || !isPluginsLoaded) { return } loadExtensionsForBase(baseId).catch((e) => { console.error(e) }) }, { immediate: true, }, ) // Extension details modal const isDetailsVisible = ref(false) const detailsExtensionId = ref() const detailsFrom = ref<'market' | 'extension'>('market') const showExtensionDetails = (extensionId: string, from?: 'market' | 'extension') => { detailsExtensionId.value = extensionId isDetailsVisible.value = true detailsFrom.value = from || 'market' $e('c:extension:details', { source: from, extensionId }) } // Extension market modal const isMarketVisible = ref(false) return { extensionsLoaded: pluginsLoaded, availableExtensions, descriptionContent: pluginDescriptionContent, extensionList, isPanelExpanded, toggleExtensionPanel, addExtension, duplicateExtension, updateExtension, updateExtensionMeta, clearKvStore, deleteExtension, loadExtensionsForBase, getExtensionAssetsUrl: (pathOrUrl: string) => getPluginAssetUrl(pathOrUrl, pluginTypes.extension), isDetailsVisible, detailsExtensionId, detailsFrom, showExtensionDetails, isMarketVisible, extensionPanelSize, eventBus, getExtensionMinAccessRole, userHasAccessToExtension, userCurrentBaseRole, extensionAccess, baseExtensions, Extension, } })