mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
feat: Add hooks for blob storage and cached data management
- Implemented `useBlobStorage` hook for handling blob storage operations including upload, download, delete, list, and metadata retrieval. - Created `useCachedData` hook for managing cached data with automatic serialization and error handling. - Added `useDBAL` hook to ensure DBAL initialization before performing operations. - Introduced `useKVStore` hook for key-value store operations with tenant and user context. - Added tests for user retrieval with tenant filtering. - Refactored package export functionality into modular files for better maintainability. - Implemented utility functions for rendering components and executing Lua scripts. - Established a declarative component renderer with support for component configuration and Lua script registration. - Updated access rules to streamline credential access permissions.
This commit is contained in:
102
frontends/nextjs/src/hooks/use-dbal/use-blob-storage.ts
Normal file
102
frontends/nextjs/src/hooks/use-dbal/use-blob-storage.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useCallback } from 'react'
|
||||
import { dbal } from '@/lib/dbal-integration'
|
||||
import { toast } from 'sonner'
|
||||
import { useDBAL } from './use-dbal'
|
||||
|
||||
/**
|
||||
* Hook for blob storage operations
|
||||
*/
|
||||
export function useBlobStorage() {
|
||||
const { isReady } = useDBAL()
|
||||
|
||||
const upload = useCallback(
|
||||
async (key: string, data: Buffer | Uint8Array, metadata?: Record<string, string>) => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
await dbal.blobUpload(key, data, metadata)
|
||||
toast.success(`Uploaded: ${key}`)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`Upload Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
|
||||
const download = useCallback(
|
||||
async (key: string): Promise<Buffer> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.blobDownload(key)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`Download Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
|
||||
const del = useCallback(
|
||||
async (key: string) => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
await dbal.blobDelete(key)
|
||||
toast.success(`Deleted: ${key}`)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`Delete Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
|
||||
const list = useCallback(
|
||||
async (prefix?: string): Promise<string[]> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.blobList(prefix)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`List Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
|
||||
const getMetadata = useCallback(
|
||||
async (key: string): Promise<Record<string, string>> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.blobGetMetadata(key)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`Get Metadata Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady]
|
||||
)
|
||||
|
||||
return {
|
||||
isReady,
|
||||
upload,
|
||||
download,
|
||||
delete: del,
|
||||
list,
|
||||
getMetadata,
|
||||
}
|
||||
}
|
||||
69
frontends/nextjs/src/hooks/use-dbal/use-cached-data.ts
Normal file
69
frontends/nextjs/src/hooks/use-dbal/use-cached-data.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { dbal } from '@/lib/dbal-integration'
|
||||
import { useKVStore } from './use-kv-store'
|
||||
|
||||
/**
|
||||
* Hook for storing and retrieving cached data with automatic serialization
|
||||
*/
|
||||
export function useCachedData<T>(key: string, tenantId?: string, userId?: string) {
|
||||
const kv = useKVStore(tenantId, userId)
|
||||
const [data, setData] = useState<T | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!kv.isReady) return
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const cached = await kv.get<T>(key)
|
||||
setData(cached)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
setError(errorInfo.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [key, kv.isReady])
|
||||
|
||||
const save = useCallback(
|
||||
async (newData: T, ttl?: number) => {
|
||||
try {
|
||||
await kv.set(key, newData, ttl)
|
||||
setData(newData)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
setError(errorInfo.message)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[key, kv]
|
||||
)
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
try {
|
||||
await kv.delete(key)
|
||||
setData(null)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
setError(errorInfo.message)
|
||||
throw err
|
||||
}
|
||||
}, [key, kv])
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
save,
|
||||
clear,
|
||||
isReady: kv.isReady,
|
||||
}
|
||||
}
|
||||
29
frontends/nextjs/src/hooks/use-dbal/use-dbal.ts
Normal file
29
frontends/nextjs/src/hooks/use-dbal/use-dbal.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { dbal } from '@/lib/dbal-integration'
|
||||
|
||||
/**
|
||||
* Hook to ensure DBAL is initialized
|
||||
*/
|
||||
export function useDBAL() {
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
if (!dbal.isInitialized()) {
|
||||
await dbal.initialize()
|
||||
}
|
||||
setIsReady(true)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
setError(errorInfo.message)
|
||||
console.error('DBAL initialization failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
}, [])
|
||||
|
||||
return { isReady, error }
|
||||
}
|
||||
100
frontends/nextjs/src/hooks/use-dbal/use-kv-store.ts
Normal file
100
frontends/nextjs/src/hooks/use-dbal/use-kv-store.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useCallback } from 'react'
|
||||
import { dbal } from '@/lib/dbal-integration'
|
||||
import { toast } from 'sonner'
|
||||
import { useDBAL } from './use-dbal'
|
||||
|
||||
/**
|
||||
* Hook for KV store operations
|
||||
*/
|
||||
export function useKVStore(tenantId: string = 'default', userId: string = 'system') {
|
||||
const { isReady } = useDBAL()
|
||||
|
||||
const set = useCallback(
|
||||
async (key: string, value: any, ttl?: number) => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
await dbal.kvSet(key, value, ttl, tenantId, userId)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`KV Set Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady, tenantId, userId]
|
||||
)
|
||||
|
||||
const get = useCallback(
|
||||
async <T = any>(key: string): Promise<T | null> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.kvGet<T>(key, tenantId, userId)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`KV Get Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady, tenantId, userId]
|
||||
)
|
||||
|
||||
const del = useCallback(
|
||||
async (key: string): Promise<boolean> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.kvDelete(key, tenantId, userId)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`KV Delete Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady, tenantId, userId]
|
||||
)
|
||||
|
||||
const listAdd = useCallback(
|
||||
async (key: string, items: any[]) => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
await dbal.kvListAdd(key, items, tenantId, userId)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`KV List Add Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady, tenantId, userId]
|
||||
)
|
||||
|
||||
const listGet = useCallback(
|
||||
async (key: string, start?: number, end?: number): Promise<any[]> => {
|
||||
if (!isReady) {
|
||||
throw new Error('DBAL not ready')
|
||||
}
|
||||
try {
|
||||
return await dbal.kvListGet(key, tenantId, userId, start, end)
|
||||
} catch (err) {
|
||||
const errorInfo = dbal.handleError(err)
|
||||
toast.error(`KV List Get Error: ${errorInfo.message}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[isReady, tenantId, userId]
|
||||
)
|
||||
|
||||
return {
|
||||
isReady,
|
||||
set,
|
||||
get,
|
||||
delete: del,
|
||||
listAdd,
|
||||
listGet,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const mockRead = vi.fn()
|
||||
const mockAdapter = { read: mockRead }
|
||||
const mockFindFirst = vi.fn()
|
||||
const mockAdapter = { read: mockRead, findFirst: mockFindFirst }
|
||||
|
||||
vi.mock('../dbal-client', () => ({
|
||||
getAdapter: () => mockAdapter,
|
||||
@@ -12,6 +13,7 @@ import { getUserById } from './get-user-by-id'
|
||||
describe('getUserById', () => {
|
||||
beforeEach(() => {
|
||||
mockRead.mockReset()
|
||||
mockFindFirst.mockReset()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -79,6 +81,45 @@ describe('getUserById', () => {
|
||||
const result = await getUserById(userId)
|
||||
|
||||
expect(mockRead).toHaveBeenCalledWith('User', userId)
|
||||
expect(mockFindFirst).not.toHaveBeenCalled()
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'use tenant filter when provided',
|
||||
userId: 'user_3',
|
||||
tenantId: 'tenant_2',
|
||||
dbData: {
|
||||
id: 'user_3',
|
||||
username: 'tenant-user',
|
||||
email: 'tenant@example.com',
|
||||
role: 'admin',
|
||||
profilePicture: null,
|
||||
bio: null,
|
||||
createdAt: BigInt(3000),
|
||||
tenantId: 'tenant_2',
|
||||
isInstanceOwner: false,
|
||||
},
|
||||
expected: {
|
||||
id: 'user_3',
|
||||
username: 'tenant-user',
|
||||
email: 'tenant@example.com',
|
||||
role: 'admin',
|
||||
profilePicture: undefined,
|
||||
bio: undefined,
|
||||
createdAt: 3000,
|
||||
tenantId: 'tenant_2',
|
||||
isInstanceOwner: false,
|
||||
},
|
||||
},
|
||||
])('should $name', async ({ userId, tenantId, dbData, expected }) => {
|
||||
mockFindFirst.mockResolvedValue(dbData)
|
||||
|
||||
const result = await getUserById(userId, { tenantId })
|
||||
|
||||
expect(mockFindFirst).toHaveBeenCalledWith('User', { where: { id: userId, tenantId } })
|
||||
expect(mockRead).not.toHaveBeenCalled()
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,254 +1,6 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { PackageManifest, PackageContent } from './package-types'
|
||||
import type { DatabaseSchema } from './database'
|
||||
export type { AssetFile, ExportPackageOptions } from './package-export/types'
|
||||
|
||||
export interface ExportPackageOptions {
|
||||
includeAssets?: boolean
|
||||
includeSchemas?: boolean
|
||||
includePages?: boolean
|
||||
includeWorkflows?: boolean
|
||||
includeLuaScripts?: boolean
|
||||
includeComponentHierarchy?: boolean
|
||||
includeComponentConfigs?: boolean
|
||||
includeCssClasses?: boolean
|
||||
includeDropdownConfigs?: boolean
|
||||
includeSeedData?: boolean
|
||||
}
|
||||
|
||||
export interface AssetFile {
|
||||
path: string
|
||||
blob: Blob
|
||||
type: 'image' | 'video' | 'audio' | 'document'
|
||||
}
|
||||
|
||||
export async function exportPackageAsZip(
|
||||
manifest: PackageManifest,
|
||||
content: PackageContent,
|
||||
assets: AssetFile[] = [],
|
||||
options: ExportPackageOptions = {}
|
||||
): Promise<Blob> {
|
||||
const zip = new JSZip()
|
||||
|
||||
const opts = {
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
|
||||
const packageContent: Partial<PackageContent> = {}
|
||||
|
||||
if (opts.includeSchemas && content.schemas) {
|
||||
packageContent.schemas = content.schemas
|
||||
}
|
||||
|
||||
if (opts.includePages && content.pages) {
|
||||
packageContent.pages = content.pages
|
||||
}
|
||||
|
||||
if (opts.includeWorkflows && content.workflows) {
|
||||
packageContent.workflows = content.workflows
|
||||
}
|
||||
|
||||
if (opts.includeLuaScripts && content.luaScripts) {
|
||||
packageContent.luaScripts = content.luaScripts
|
||||
}
|
||||
|
||||
if (opts.includeComponentHierarchy && content.componentHierarchy) {
|
||||
packageContent.componentHierarchy = content.componentHierarchy
|
||||
}
|
||||
|
||||
if (opts.includeComponentConfigs && content.componentConfigs) {
|
||||
packageContent.componentConfigs = content.componentConfigs
|
||||
}
|
||||
|
||||
if (opts.includeCssClasses && content.cssClasses) {
|
||||
packageContent.cssClasses = content.cssClasses
|
||||
}
|
||||
|
||||
if (opts.includeDropdownConfigs && content.dropdownConfigs) {
|
||||
packageContent.dropdownConfigs = content.dropdownConfigs
|
||||
}
|
||||
|
||||
if (opts.includeSeedData && content.seedData) {
|
||||
packageContent.seedData = content.seedData
|
||||
}
|
||||
|
||||
zip.file('content.json', JSON.stringify(packageContent, null, 2))
|
||||
|
||||
if (opts.includeAssets && assets.length > 0) {
|
||||
const assetsFolder = zip.folder('assets')
|
||||
if (assetsFolder) {
|
||||
for (const asset of assets) {
|
||||
const typeFolder = assetsFolder.folder(asset.type + 's')
|
||||
if (typeFolder) {
|
||||
const fileName = asset.path.split('/').pop() || 'unnamed'
|
||||
typeFolder.file(fileName, asset.blob)
|
||||
}
|
||||
}
|
||||
|
||||
const assetManifest = assets.map(asset => ({
|
||||
originalPath: asset.path,
|
||||
type: asset.type,
|
||||
fileName: asset.path.split('/').pop(),
|
||||
}))
|
||||
|
||||
assetsFolder.file('asset-manifest.json', JSON.stringify(assetManifest, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
zip.file('README.md', generateReadme(manifest, content))
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
return blob
|
||||
}
|
||||
|
||||
export async function importPackageFromZip(zipFile: File): Promise<{
|
||||
manifest: PackageManifest
|
||||
content: PackageContent
|
||||
assets: Array<{ path: string; blob: Blob; type: 'image' | 'video' | 'audio' | 'document' }>
|
||||
}> {
|
||||
const zip = await JSZip.loadAsync(zipFile)
|
||||
|
||||
const manifestFile = zip.file('manifest.json')
|
||||
if (!manifestFile) {
|
||||
throw new Error('Invalid package: manifest.json not found')
|
||||
}
|
||||
|
||||
const manifestText = await manifestFile.async('text')
|
||||
const manifest: PackageManifest = JSON.parse(manifestText)
|
||||
|
||||
const contentFile = zip.file('content.json')
|
||||
if (!contentFile) {
|
||||
throw new Error('Invalid package: content.json not found')
|
||||
}
|
||||
|
||||
const contentText = await contentFile.async('text')
|
||||
const content: PackageContent = JSON.parse(contentText)
|
||||
|
||||
const assets: Array<{ path: string; blob: Blob; type: 'image' | 'video' | 'audio' | 'document' }> = []
|
||||
|
||||
const assetManifestFile = zip.file('assets/asset-manifest.json')
|
||||
if (assetManifestFile) {
|
||||
const assetManifestText = await assetManifestFile.async('text')
|
||||
const assetManifest: Array<{ originalPath: string; type: string; fileName: string }> = JSON.parse(assetManifestText)
|
||||
|
||||
for (const assetInfo of assetManifest) {
|
||||
const assetPath = `assets/${assetInfo.type}s/${assetInfo.fileName}`
|
||||
const assetFile = zip.file(assetPath)
|
||||
|
||||
if (assetFile) {
|
||||
const blob = await assetFile.async('blob')
|
||||
assets.push({
|
||||
path: assetInfo.originalPath,
|
||||
blob,
|
||||
type: assetInfo.type as 'image' | 'video' | 'audio' | 'document',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { manifest, content, assets }
|
||||
}
|
||||
|
||||
export function downloadZip(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function generateReadme(manifest: PackageManifest, content: PackageContent): string {
|
||||
return `# ${manifest.name}
|
||||
|
||||
Version: ${manifest.version}
|
||||
Author: ${manifest.author}
|
||||
Category: ${manifest.category}
|
||||
|
||||
## Description
|
||||
|
||||
${manifest.description}
|
||||
|
||||
## Contents
|
||||
|
||||
- **Schemas**: ${content.schemas?.length || 0} data models
|
||||
- **Pages**: ${content.pages?.length || 0} page configurations
|
||||
- **Workflows**: ${content.workflows?.length || 0} workflows
|
||||
- **Lua Scripts**: ${content.luaScripts?.length || 0} scripts
|
||||
- **Components**: ${Object.keys(content.componentHierarchy || {}).length} component hierarchies
|
||||
- **CSS Classes**: ${content.cssClasses?.length || 0} CSS categories
|
||||
- **Dropdown Configs**: ${content.dropdownConfigs?.length || 0} dropdown configurations
|
||||
|
||||
## Tags
|
||||
|
||||
${manifest.tags.join(', ')}
|
||||
|
||||
## Installation
|
||||
|
||||
Import this package through the MetaBuilder Package Manager.
|
||||
|
||||
## Dependencies
|
||||
|
||||
${manifest.dependencies.length > 0 ? manifest.dependencies.join(', ') : 'None'}
|
||||
|
||||
---
|
||||
|
||||
Generated by MetaBuilder Package Exporter
|
||||
`
|
||||
}
|
||||
|
||||
export async function exportDatabaseSnapshot(
|
||||
schemas: any[],
|
||||
pages: any[],
|
||||
workflows: any[],
|
||||
luaScripts: any[],
|
||||
componentHierarchy: Record<string, any>,
|
||||
componentConfigs: Record<string, any>,
|
||||
cssClasses: any[],
|
||||
dropdownConfigs: any[],
|
||||
assets: AssetFile[] = []
|
||||
): Promise<Blob> {
|
||||
const manifest: PackageManifest = {
|
||||
id: `snapshot_${Date.now()}`,
|
||||
name: 'Database Snapshot',
|
||||
version: '1.0.0',
|
||||
description: 'Complete database snapshot export',
|
||||
author: 'User Export',
|
||||
category: 'other',
|
||||
icon: '💾',
|
||||
screenshots: [],
|
||||
tags: ['snapshot', 'backup', 'export'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const content: PackageContent = {
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs,
|
||||
}
|
||||
|
||||
return exportPackageAsZip(manifest, content, assets)
|
||||
}
|
||||
export { exportPackageAsZip } from './package-export/export-package-as-zip'
|
||||
export { importPackageFromZip } from './package-export/import-package-from-zip'
|
||||
export { downloadZip } from './package-export/download-zip'
|
||||
export { exportDatabaseSnapshot } from './package-export/export-database-snapshot'
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export function downloadZip(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = fileName
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AssetFile, PackageContent, PackageManifest } from './types'
|
||||
import { exportPackageAsZip } from './export-package-as-zip'
|
||||
|
||||
export async function exportDatabaseSnapshot(
|
||||
schemas: any[],
|
||||
pages: any[],
|
||||
workflows: any[],
|
||||
luaScripts: any[],
|
||||
componentHierarchy: Record<string, any>,
|
||||
componentConfigs: Record<string, any>,
|
||||
cssClasses: any[],
|
||||
dropdownConfigs: any[],
|
||||
assets: AssetFile[] = []
|
||||
): Promise<Blob> {
|
||||
const manifest: PackageManifest = {
|
||||
id: `snapshot_${Date.now()}`,
|
||||
name: 'Database Snapshot',
|
||||
version: '1.0.0',
|
||||
description: 'Complete database snapshot export',
|
||||
author: 'User Export',
|
||||
category: 'other',
|
||||
icon: '💾',
|
||||
screenshots: [],
|
||||
tags: ['snapshot', 'backup', 'export'],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const content: PackageContent = {
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs,
|
||||
}
|
||||
|
||||
return exportPackageAsZip(manifest, content, assets)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { AssetFile, ExportPackageOptions, PackageContent, PackageManifest } from './types'
|
||||
import { generateReadme } from './generate-readme'
|
||||
|
||||
export async function exportPackageAsZip(
|
||||
manifest: PackageManifest,
|
||||
content: PackageContent,
|
||||
assets: AssetFile[] = [],
|
||||
options: ExportPackageOptions = {}
|
||||
): Promise<Blob> {
|
||||
const zip = new JSZip()
|
||||
|
||||
const opts = {
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
...options,
|
||||
}
|
||||
|
||||
zip.file('manifest.json', JSON.stringify(manifest, null, 2))
|
||||
|
||||
const packageContent: Partial<PackageContent> = {}
|
||||
|
||||
if (opts.includeSchemas && content.schemas) {
|
||||
packageContent.schemas = content.schemas
|
||||
}
|
||||
|
||||
if (opts.includePages && content.pages) {
|
||||
packageContent.pages = content.pages
|
||||
}
|
||||
|
||||
if (opts.includeWorkflows && content.workflows) {
|
||||
packageContent.workflows = content.workflows
|
||||
}
|
||||
|
||||
if (opts.includeLuaScripts && content.luaScripts) {
|
||||
packageContent.luaScripts = content.luaScripts
|
||||
}
|
||||
|
||||
if (opts.includeComponentHierarchy && content.componentHierarchy) {
|
||||
packageContent.componentHierarchy = content.componentHierarchy
|
||||
}
|
||||
|
||||
if (opts.includeComponentConfigs && content.componentConfigs) {
|
||||
packageContent.componentConfigs = content.componentConfigs
|
||||
}
|
||||
|
||||
if (opts.includeCssClasses && content.cssClasses) {
|
||||
packageContent.cssClasses = content.cssClasses
|
||||
}
|
||||
|
||||
if (opts.includeDropdownConfigs && content.dropdownConfigs) {
|
||||
packageContent.dropdownConfigs = content.dropdownConfigs
|
||||
}
|
||||
|
||||
if (opts.includeSeedData && content.seedData) {
|
||||
packageContent.seedData = content.seedData
|
||||
}
|
||||
|
||||
zip.file('content.json', JSON.stringify(packageContent, null, 2))
|
||||
|
||||
if (opts.includeAssets && assets.length > 0) {
|
||||
const assetsFolder = zip.folder('assets')
|
||||
if (assetsFolder) {
|
||||
for (const asset of assets) {
|
||||
const typeFolder = assetsFolder.folder(asset.type + 's')
|
||||
if (typeFolder) {
|
||||
const fileName = asset.path.split('/').pop() || 'unnamed'
|
||||
typeFolder.file(fileName, asset.blob)
|
||||
}
|
||||
}
|
||||
|
||||
const assetManifest = assets.map(asset => ({
|
||||
originalPath: asset.path,
|
||||
type: asset.type,
|
||||
fileName: asset.path.split('/').pop(),
|
||||
}))
|
||||
|
||||
assetsFolder.file('asset-manifest.json', JSON.stringify(assetManifest, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
zip.file('README.md', generateReadme(manifest, content))
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' })
|
||||
return blob
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { PackageManifest, PackageContent } from './types'
|
||||
|
||||
export function generateReadme(manifest: PackageManifest, content: PackageContent): string {
|
||||
return `# ${manifest.name}
|
||||
|
||||
Version: ${manifest.version}
|
||||
Author: ${manifest.author}
|
||||
Category: ${manifest.category}
|
||||
|
||||
## Description
|
||||
|
||||
${manifest.description}
|
||||
|
||||
## Contents
|
||||
|
||||
- **Schemas**: ${content.schemas?.length || 0} data models
|
||||
- **Pages**: ${content.pages?.length || 0} page configurations
|
||||
- **Workflows**: ${content.workflows?.length || 0} workflows
|
||||
- **Lua Scripts**: ${content.luaScripts?.length || 0} scripts
|
||||
- **Components**: ${Object.keys(content.componentHierarchy || {}).length} component hierarchies
|
||||
- **CSS Classes**: ${content.cssClasses?.length || 0} CSS categories
|
||||
- **Dropdown Configs**: ${content.dropdownConfigs?.length || 0} dropdown configurations
|
||||
|
||||
## Tags
|
||||
|
||||
${manifest.tags.join(', ')}
|
||||
|
||||
## Installation
|
||||
|
||||
Import this package through the MetaBuilder Package Manager.
|
||||
|
||||
## Dependencies
|
||||
|
||||
${manifest.dependencies.length > 0 ? manifest.dependencies.join(', ') : 'None'}
|
||||
|
||||
---
|
||||
|
||||
Generated by MetaBuilder Package Exporter
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import JSZip from 'jszip'
|
||||
import type { AssetType, PackageContent, PackageManifest } from './types'
|
||||
|
||||
export async function importPackageFromZip(zipFile: File): Promise<{
|
||||
manifest: PackageManifest
|
||||
content: PackageContent
|
||||
assets: Array<{ path: string; blob: Blob; type: AssetType }>
|
||||
}> {
|
||||
const zip = await JSZip.loadAsync(zipFile)
|
||||
|
||||
const manifestFile = zip.file('manifest.json')
|
||||
if (!manifestFile) {
|
||||
throw new Error('Invalid package: manifest.json not found')
|
||||
}
|
||||
|
||||
const manifestText = await manifestFile.async('text')
|
||||
const manifest: PackageManifest = JSON.parse(manifestText)
|
||||
|
||||
const contentFile = zip.file('content.json')
|
||||
if (!contentFile) {
|
||||
throw new Error('Invalid package: content.json not found')
|
||||
}
|
||||
|
||||
const contentText = await contentFile.async('text')
|
||||
const content: PackageContent = JSON.parse(contentText)
|
||||
|
||||
const assets: Array<{ path: string; blob: Blob; type: AssetType }> = []
|
||||
|
||||
const assetManifestFile = zip.file('assets/asset-manifest.json')
|
||||
if (assetManifestFile) {
|
||||
const assetManifestText = await assetManifestFile.async('text')
|
||||
const assetManifest: Array<{ originalPath: string; type: AssetType; fileName: string }> =
|
||||
JSON.parse(assetManifestText)
|
||||
|
||||
for (const assetInfo of assetManifest) {
|
||||
const assetPath = `assets/${assetInfo.type}s/${assetInfo.fileName}`
|
||||
const assetFile = zip.file(assetPath)
|
||||
|
||||
if (assetFile) {
|
||||
const blob = await assetFile.async('blob')
|
||||
assets.push({
|
||||
path: assetInfo.originalPath,
|
||||
blob,
|
||||
type: assetInfo.type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { manifest, content, assets }
|
||||
}
|
||||
24
frontends/nextjs/src/lib/packages/package-export/types.ts
Normal file
24
frontends/nextjs/src/lib/packages/package-export/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PackageManifest, PackageContent } from '../package-types'
|
||||
|
||||
export interface ExportPackageOptions {
|
||||
includeAssets?: boolean
|
||||
includeSchemas?: boolean
|
||||
includePages?: boolean
|
||||
includeWorkflows?: boolean
|
||||
includeLuaScripts?: boolean
|
||||
includeComponentHierarchy?: boolean
|
||||
includeComponentConfigs?: boolean
|
||||
includeCssClasses?: boolean
|
||||
includeDropdownConfigs?: boolean
|
||||
includeSeedData?: boolean
|
||||
}
|
||||
|
||||
export type AssetType = 'image' | 'video' | 'audio' | 'document'
|
||||
|
||||
export interface AssetFile {
|
||||
path: string
|
||||
blob: Blob
|
||||
type: AssetType
|
||||
}
|
||||
|
||||
export type { PackageManifest, PackageContent }
|
||||
@@ -0,0 +1,7 @@
|
||||
export function evaluateConditional(condition: string | boolean, context: Record<string, any>): boolean {
|
||||
if (typeof condition === 'boolean') return condition
|
||||
if (!condition) return true
|
||||
|
||||
const value = context[condition]
|
||||
return Boolean(value)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { DeclarativeRendererState } from './renderer-state'
|
||||
|
||||
export async function executeLuaScript(
|
||||
state: DeclarativeRendererState,
|
||||
scriptId: string,
|
||||
params: any[]
|
||||
): Promise<any> {
|
||||
const script = state.luaScripts[scriptId]
|
||||
if (!script) {
|
||||
throw new Error(`Lua script not found: ${scriptId}`)
|
||||
}
|
||||
|
||||
const paramContext: Record<string, any> = {}
|
||||
script.parameters.forEach((param, index) => {
|
||||
if (params[index] !== undefined) {
|
||||
paramContext[param.name] = params[index]
|
||||
}
|
||||
})
|
||||
|
||||
const paramAssignments = script.parameters
|
||||
.map(p => `local ${p.name} = context.data.params["${p.name}"]`)
|
||||
.join('\n')
|
||||
|
||||
const paramList = script.parameters.map(p => p.name).join(', ')
|
||||
|
||||
const wrappedCode = `
|
||||
${paramAssignments}
|
||||
|
||||
${script.code}
|
||||
|
||||
local result_fn = sendMessage or handleCommand or formatTime or userJoin or userLeave or countThreads
|
||||
if result_fn and type(result_fn) == "function" then
|
||||
return result_fn(${paramList})
|
||||
end
|
||||
`
|
||||
|
||||
const result = await state.luaEngine.execute(wrappedCode, {
|
||||
data: { params: paramContext },
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`Lua script error (${scriptId}):`, result.error, result.logs)
|
||||
throw new Error(result.error || 'Lua script execution failed')
|
||||
}
|
||||
|
||||
return result.result
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { DeclarativeRendererState } from './renderer-state'
|
||||
import type { DeclarativeComponentConfig } from './types'
|
||||
|
||||
export function getComponentConfig(
|
||||
state: DeclarativeRendererState,
|
||||
componentType: string
|
||||
): DeclarativeComponentConfig | undefined {
|
||||
return state.componentConfigs[componentType]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DeclarativeRendererState } from './renderer-state'
|
||||
|
||||
export function hasComponentConfig(state: DeclarativeRendererState, componentType: string): boolean {
|
||||
return componentType in state.componentConfigs
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function interpolateValue(template: string, context: Record<string, any>): string {
|
||||
if (!template || typeof template !== 'string') return template
|
||||
|
||||
return template.replace(/\{([^}]+)\}/g, (match, key) => {
|
||||
const value = context[key]
|
||||
return value !== undefined ? String(value) : match
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DeclarativeComponentConfig } from './types'
|
||||
import type { DeclarativeRendererState } from './renderer-state'
|
||||
|
||||
export function registerComponentConfig(
|
||||
state: DeclarativeRendererState,
|
||||
componentType: string,
|
||||
config: DeclarativeComponentConfig
|
||||
): void {
|
||||
state.componentConfigs[componentType] = config
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { DeclarativeRendererState } from './renderer-state'
|
||||
import type { LuaScriptDefinition } from './types'
|
||||
|
||||
export function registerLuaScript(
|
||||
state: DeclarativeRendererState,
|
||||
scriptId: string,
|
||||
script: LuaScriptDefinition
|
||||
): void {
|
||||
state.luaScripts[scriptId] = script
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LuaEngine } from '../lua-engine'
|
||||
import type { DeclarativeComponentConfig, LuaScriptDefinition } from './types'
|
||||
|
||||
export type DeclarativeRendererState = {
|
||||
luaEngine: LuaEngine
|
||||
componentConfigs: Record<string, DeclarativeComponentConfig>
|
||||
luaScripts: Record<string, LuaScriptDefinition>
|
||||
}
|
||||
|
||||
export function createDeclarativeRendererState(): DeclarativeRendererState {
|
||||
return {
|
||||
luaEngine: new LuaEngine(),
|
||||
componentConfigs: {},
|
||||
luaScripts: {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface DeclarativeComponentConfig {
|
||||
type: string
|
||||
category: string
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
props: Array<{
|
||||
name: string
|
||||
type: string
|
||||
label: string
|
||||
defaultValue?: any
|
||||
required: boolean
|
||||
}>
|
||||
config: {
|
||||
layout: string
|
||||
styling: {
|
||||
className: string
|
||||
}
|
||||
children: any[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface MessageFormat {
|
||||
id: string
|
||||
username: string
|
||||
userId: string
|
||||
message: string
|
||||
timestamp: number
|
||||
type: 'message' | 'system' | 'join' | 'leave'
|
||||
}
|
||||
|
||||
export interface LuaScriptDefinition {
|
||||
code: string
|
||||
parameters: any[]
|
||||
returnType: string
|
||||
}
|
||||
@@ -34,7 +34,6 @@ export const ACCESS_RULES: AccessRule[] = [
|
||||
{ resource: 'smtpConfig', operation: 'READ', allowedRoles: ['god', 'supergod'] },
|
||||
{ resource: 'smtpConfig', operation: 'UPDATE', allowedRoles: ['supergod'] },
|
||||
|
||||
{ resource: 'credential', operation: 'READ', allowedRoles: ['public', 'user', 'admin', 'god', 'supergod'] },
|
||||
{ resource: 'credential', operation: 'READ', allowedRoles: ['public', 'user', 'admin', 'god', 'supergod'] },
|
||||
{ resource: 'credential', operation: 'UPDATE', allowedRoles: ['user', 'admin', 'god', 'supergod'] },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user