mirror of https://github.com/nocodb/nocodb
աɨռɢӄաօռɢ
2 years ago
committed by
GitHub
19 changed files with 30000 additions and 16736 deletions
@ -0,0 +1,311 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import io from 'socket.io-client' |
||||||
|
import type { Socket } from 'socket.io-client' |
||||||
|
import { Form } from 'ant-design-vue' |
||||||
|
import { useToast } from 'vue-toastification' |
||||||
|
import { fieldRequiredValidator } from '~/utils/validation' |
||||||
|
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||||
|
import MdiCloseCircleOutlineIcon from '~icons/mdi/close-circle-outline' |
||||||
|
import MdiCurrencyUsdIcon from '~icons/mdi/currency-usd' |
||||||
|
import MdiLoadingIcon from '~icons/mdi/loading' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
modelValue: boolean |
||||||
|
} |
||||||
|
|
||||||
|
const { modelValue } = defineProps<Props>() |
||||||
|
const emit = defineEmits(['update:modelValue']) |
||||||
|
|
||||||
|
// TODO: handle baseURL |
||||||
|
const baseURL = 'http://localhost:8080' // this.$axios.defaults.baseURL |
||||||
|
|
||||||
|
const { $state } = useNuxtApp() |
||||||
|
const toast = useToast() |
||||||
|
const { sqlUi, project, loadTables } = useProject() |
||||||
|
const loading = ref(false) |
||||||
|
const step = ref(1) |
||||||
|
const progress = ref<Record<string, any>[]>([]) |
||||||
|
let socket: Socket | null |
||||||
|
const syncSource = ref({ |
||||||
|
id: '', |
||||||
|
type: 'Airtable', |
||||||
|
details: { |
||||||
|
syncInterval: '15mins', |
||||||
|
syncDirection: 'Airtable to NocoDB', |
||||||
|
syncRetryCount: 1, |
||||||
|
apiKey: '', |
||||||
|
shareId: '', |
||||||
|
syncSourceUrlOrId: '', |
||||||
|
options: { |
||||||
|
syncViews: true, |
||||||
|
syncData: true, |
||||||
|
syncRollup: false, |
||||||
|
syncLookup: true, |
||||||
|
syncFormula: false, |
||||||
|
syncAttachment: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const validators = computed(() => { |
||||||
|
return { |
||||||
|
'details.apiKey': [fieldRequiredValidator], |
||||||
|
'details.syncSourceUrlOrId': [fieldRequiredValidator], |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const dialogShow = computed({ |
||||||
|
get() { |
||||||
|
return modelValue |
||||||
|
}, |
||||||
|
set(v) { |
||||||
|
emit('update:modelValue', v) |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const useForm = Form.useForm |
||||||
|
const { resetFields, validate, validateInfos } = useForm(syncSource, validators) |
||||||
|
|
||||||
|
const disableImportButton = computed(() => { |
||||||
|
return !syncSource.value.details.apiKey || !syncSource.value.details.syncSourceUrlOrId |
||||||
|
}) |
||||||
|
|
||||||
|
async function saveAndSync() { |
||||||
|
await createOrUpdate() |
||||||
|
await sync() |
||||||
|
} |
||||||
|
|
||||||
|
async function createOrUpdate() { |
||||||
|
try { |
||||||
|
const { id, ...payload } = syncSource.value |
||||||
|
if (id !== '') { |
||||||
|
await $fetch(`/api/v1/db/meta/syncs/${id}`, { |
||||||
|
baseURL, |
||||||
|
method: 'PATCH', |
||||||
|
headers: { 'xc-auth': $state.token.value as string }, |
||||||
|
body: payload, |
||||||
|
}) |
||||||
|
} else { |
||||||
|
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, { |
||||||
|
baseURL, |
||||||
|
method: 'POST', |
||||||
|
headers: { 'xc-auth': $state.token.value as string }, |
||||||
|
body: payload, |
||||||
|
}) |
||||||
|
syncSource.value = data |
||||||
|
} |
||||||
|
} catch (e: any) { |
||||||
|
toast.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function loadSyncSrc() { |
||||||
|
const data: any = await $fetch(`/api/v1/db/meta/projects/${project.value.id}/syncs`, { |
||||||
|
baseURL, |
||||||
|
method: 'GET', |
||||||
|
headers: { 'xc-auth': $state.token.value as string }, |
||||||
|
}) |
||||||
|
const { list: srcs } = data |
||||||
|
if (srcs && srcs[0]) { |
||||||
|
srcs[0].details = srcs[0].details || {} |
||||||
|
syncSource.value = migrateSync(srcs[0]) |
||||||
|
syncSource.value.details.syncSourceUrlOrId = srcs[0].details.shareId |
||||||
|
} else { |
||||||
|
syncSource.value = { |
||||||
|
id: '', |
||||||
|
type: 'Airtable', |
||||||
|
details: { |
||||||
|
syncInterval: '15mins', |
||||||
|
syncDirection: 'Airtable to NocoDB', |
||||||
|
syncRetryCount: 1, |
||||||
|
apiKey: '', |
||||||
|
shareId: '', |
||||||
|
syncSourceUrlOrId: '', |
||||||
|
options: { |
||||||
|
syncViews: true, |
||||||
|
syncData: true, |
||||||
|
syncRollup: false, |
||||||
|
syncLookup: true, |
||||||
|
syncFormula: false, |
||||||
|
syncAttachment: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function sync() { |
||||||
|
step.value = 2 |
||||||
|
try { |
||||||
|
await $fetch(`/api/v1/db/meta/syncs/${syncSource.value.id}/trigger`, { |
||||||
|
baseURL, |
||||||
|
method: 'POST', |
||||||
|
headers: { 'xc-auth': $state.token.value as string }, |
||||||
|
params: { |
||||||
|
id: socket.id, |
||||||
|
}, |
||||||
|
}) |
||||||
|
} catch (e: any) { |
||||||
|
console.log(e) |
||||||
|
toast.error(e) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function migrateSync(src: any) { |
||||||
|
if (!src.details?.options) { |
||||||
|
src.details.options = { |
||||||
|
syncViews: false, |
||||||
|
syncData: true, |
||||||
|
syncRollup: false, |
||||||
|
syncLookup: true, |
||||||
|
syncFormula: false, |
||||||
|
syncAttachment: true, |
||||||
|
} |
||||||
|
src.details.options.syncViews = src.syncViews |
||||||
|
delete src.syncViews |
||||||
|
} |
||||||
|
return src |
||||||
|
} |
||||||
|
|
||||||
|
watch( |
||||||
|
() => syncSource.value.details.syncSourceUrlOrId, |
||||||
|
(v) => { |
||||||
|
if (syncSource.value.details) { |
||||||
|
const m = v && v.match(/(exp|shr).{14}/g) |
||||||
|
syncSource.value.details.shareId = m ? m[0] : '' |
||||||
|
} |
||||||
|
}, |
||||||
|
) |
||||||
|
|
||||||
|
onMounted(async () => { |
||||||
|
socket = io(new URL(baseURL, window.location.href.split(/[?#]/)[0]).href, { |
||||||
|
extraHeaders: { 'xc-auth': $state.token.value as string }, |
||||||
|
}) |
||||||
|
socket.on('connect_error', () => { |
||||||
|
socket?.disconnect() |
||||||
|
socket = null |
||||||
|
}) |
||||||
|
|
||||||
|
// connect event does not provide data |
||||||
|
socket.on('connect', () => { |
||||||
|
console.log(socket?.id) |
||||||
|
console.log('socket connected') |
||||||
|
}) |
||||||
|
|
||||||
|
socket.on('progress', async (d: Record<string, any>) => { |
||||||
|
progress.value.push(d) |
||||||
|
if (d.status === 'COMPLETED') { |
||||||
|
await loadTables() |
||||||
|
// TODO: add tab of the first table |
||||||
|
} |
||||||
|
}) |
||||||
|
await loadSyncSrc() |
||||||
|
}) |
||||||
|
|
||||||
|
onBeforeUnmount(() => { |
||||||
|
if (socket) { |
||||||
|
socket.disconnect() |
||||||
|
} |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-modal v-model:visible="dialogShow" width="max(90vw, 600px)" @keydown.esc="dialogShow = false"> |
||||||
|
<template #footer> |
||||||
|
<div v-if="step === 1"> |
||||||
|
<a-button key="back" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> |
||||||
|
<a-button |
||||||
|
key="submit" |
||||||
|
v-t="['c:sync-airtable:save-and-sync']" |
||||||
|
type="primary" |
||||||
|
:disabled="disableImportButton" |
||||||
|
@click="saveAndSync" |
||||||
|
>Import |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<a-typography-title class="ml-5 mt-5" type="secondary" :level="5">QUICK IMPORT - AIRTABLE</a-typography-title> |
||||||
|
<div class="ml-5 mr-5"> |
||||||
|
<a-divider /> |
||||||
|
<div v-if="step === 1"> |
||||||
|
<div class="mb-4"> |
||||||
|
<span class="prose-xl font-bold mr-3">Credentials</span> |
||||||
|
<a |
||||||
|
href="https://docs.nocodb.com/setup-and-usages/import-airtable-to-sql-database-within-a-minute-for-free/#get-airtable-credentials" |
||||||
|
class="prose-sm underline text-grey" |
||||||
|
target="_blank" |
||||||
|
>Where to find this? |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
<a-form ref="form" :model="syncSource" name="quick-import-airtable-form" layout="horizontal" class="ma-0"> |
||||||
|
<a-form-item v-bind="validateInfos['details.apiKey']"> |
||||||
|
<a-input-password v-model:value="syncSource.details.apiKey" placeholder="Api Key" size="large" /> |
||||||
|
</a-form-item> |
||||||
|
<a-form-item v-bind="validateInfos['details.syncSourceUrlOrId']"> |
||||||
|
<a-input v-model:value="syncSource.details.syncSourceUrlOrId" placeholder="Shared Base ID / URL" size="large" /> |
||||||
|
</a-form-item> |
||||||
|
<span class="prose-xl font-bold self-center my-4">Advanced Settings</span> |
||||||
|
<a-divider class="mt-2 mb-5" /> |
||||||
|
<div class="mt-0 my-2"> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncData">Import Data</a-checkbox> |
||||||
|
</div> |
||||||
|
<div class="my-2"> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncViews">Import Secondary Views</a-checkbox> |
||||||
|
</div> |
||||||
|
<div class="my-2"> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncRollup">Import Rollup Columns</a-checkbox> |
||||||
|
</div> |
||||||
|
<div class="my-2"> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncLookup">Import Lookup Columns</a-checkbox> |
||||||
|
</div> |
||||||
|
<div class="my-2"> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncAttachment">Import Attachment Columns</a-checkbox> |
||||||
|
</div> |
||||||
|
<a-tooltip placement="top"> |
||||||
|
<template #title> |
||||||
|
<span>Coming Soon!</span> |
||||||
|
</template> |
||||||
|
<a-checkbox v-model:checked="syncSource.details.options.syncFormula" disabled>Import Formula Columns</a-checkbox> |
||||||
|
</a-tooltip> |
||||||
|
</a-form> |
||||||
|
<a-divider /> |
||||||
|
<div> |
||||||
|
<a href="https://github.com/nocodb/nocodb/issues/2052" target="_blank">Questions / Help - Reach out here</a> |
||||||
|
<br /> |
||||||
|
<div> |
||||||
|
This feature is currently in beta and more information can be found |
||||||
|
<a class="prose-sm" href="https://github.com/nocodb/nocodb/discussions/2122" target="_blank">here</a>. |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div v-if="step === 2"> |
||||||
|
<div class="mb-4 prose-xl font-bold">Logs</div> |
||||||
|
<a-card body-style="background-color: #000000; height:400px; overflow: auto;"> |
||||||
|
<div v-for="({ msg, status }, i) in progress" :key="i"> |
||||||
|
<div v-if="status === 'FAILED'" class="flex items-center"> |
||||||
|
<MdiCloseCircleOutlineIcon class="text-red-500" /> |
||||||
|
<span class="text-red-500 ml-2">{{ msg }}</span> |
||||||
|
</div> |
||||||
|
<div v-else class="flex items-center"> |
||||||
|
<MdiCurrencyUsdIcon class="text-green-500" /> |
||||||
|
<span class="text-green-500 ml-2">{{ msg }}</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
v-if=" |
||||||
|
!progress || |
||||||
|
!progress.length || |
||||||
|
(progress[progress.length - 1].status !== 'COMPLETED' && progress[progress.length - 1].status !== 'FAILED') |
||||||
|
" |
||||||
|
class="flex items-center" |
||||||
|
> |
||||||
|
<MdiLoadingIcon class="text-green-500 animate-spin" /> |
||||||
|
<span class="text-green-500 ml-2"> Importing</span> |
||||||
|
</div> |
||||||
|
</a-card> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"></style> |
@ -0,0 +1,338 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useToast } from 'vue-toastification' |
||||||
|
import { Form } from 'ant-design-vue' |
||||||
|
import type { TableType } from 'nocodb-sdk' |
||||||
|
import type { UploadChangeParam } from 'ant-design-vue' |
||||||
|
import { useI18n } from 'vue-i18n' |
||||||
|
import MdiFileIcon from '~icons/mdi/file-plus-outline' |
||||||
|
import MdiFileUploadOutlineIcon from '~icons/mdi/file-upload-outline' |
||||||
|
import MdiLinkVariantIcon from '~icons/mdi/link-variant' |
||||||
|
import MdiCodeJSONIcon from '~icons/mdi/code-json' |
||||||
|
import { fieldRequiredValidator, importCsvUrlValidator, importExcelUrlValidator, importUrlValidator } from '~/utils/validation' |
||||||
|
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils' |
||||||
|
import { ExcelTemplateAdapter, ExcelUrlTemplateAdapter, JSONTemplateAdapter, JSONUrlTemplateAdapter } from '~/utils/parsers' |
||||||
|
import { useProject } from '#imports' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
modelValue: boolean |
||||||
|
importType: 'csv' | 'json' | 'excel' |
||||||
|
} |
||||||
|
|
||||||
|
const { modelValue, importType } = defineProps<Props>() |
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue']) |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const { tables } = useProject() |
||||||
|
|
||||||
|
const toast = useToast() |
||||||
|
|
||||||
|
const activeKey = ref('uploadTab') |
||||||
|
|
||||||
|
const jsonEditorRef = ref() |
||||||
|
|
||||||
|
const templateEditorRef = ref() |
||||||
|
|
||||||
|
const loading = ref(false) |
||||||
|
|
||||||
|
const templateData = ref() |
||||||
|
|
||||||
|
const importData = ref() |
||||||
|
|
||||||
|
const templateEditorModal = ref(false) |
||||||
|
|
||||||
|
const useForm = Form.useForm |
||||||
|
|
||||||
|
const importState = reactive({ |
||||||
|
fileList: [] as Record<string, any>, |
||||||
|
url: '', |
||||||
|
jsonEditor: {}, |
||||||
|
parserConfig: { |
||||||
|
maxRowsToParse: 500, |
||||||
|
normalizeNested: true, |
||||||
|
importData: true, |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const isImportTypeJson = computed(() => importType === 'json') |
||||||
|
|
||||||
|
const isImportTypeCsv = computed(() => importType === 'csv') |
||||||
|
|
||||||
|
const IsImportTypeExcel = computed(() => importType === 'excel') |
||||||
|
|
||||||
|
const validators = computed(() => { |
||||||
|
return { |
||||||
|
url: [fieldRequiredValidator, importUrlValidator, isImportTypeCsv.value ? importCsvUrlValidator : importExcelUrlValidator], |
||||||
|
maxRowsToParse: [fieldRequiredValidator], |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const { resetFields, validate, validateInfos } = useForm(importState, validators) |
||||||
|
|
||||||
|
const importMeta = computed(() => { |
||||||
|
if (IsImportTypeExcel.value) { |
||||||
|
return { |
||||||
|
header: 'QUICK IMPORT - EXCEL', |
||||||
|
uploadHint: t('msg.info.excelSupport'), |
||||||
|
urlInputLabel: t('msg.info.excelURL'), |
||||||
|
loadUrlDirective: ['c:quick-import:excel:load-url'], |
||||||
|
acceptTypes: '.xls, .xlsx, .xlsm, .ods, .ots', |
||||||
|
} |
||||||
|
} else if (isImportTypeCsv.value) { |
||||||
|
return { |
||||||
|
header: 'QUICK IMPORT - CSV', |
||||||
|
uploadHint: '', |
||||||
|
urlInputLabel: t('msg.info.csvURL'), |
||||||
|
loadUrlDirective: ['c:quick-import:csv:load-url'], |
||||||
|
acceptTypes: '.csv', |
||||||
|
} |
||||||
|
} else if (isImportTypeJson.value) { |
||||||
|
return { |
||||||
|
header: 'QUICK IMPORT - JSON', |
||||||
|
uploadHint: '', |
||||||
|
acceptTypes: '.json', |
||||||
|
} |
||||||
|
} |
||||||
|
return {} |
||||||
|
}) |
||||||
|
|
||||||
|
const dialogShow = computed({ |
||||||
|
get() { |
||||||
|
return modelValue |
||||||
|
}, |
||||||
|
set(v) { |
||||||
|
emit('update:modelValue', v) |
||||||
|
}, |
||||||
|
}) |
||||||
|
|
||||||
|
const disablePreImportButton = computed(() => { |
||||||
|
if (activeKey.value === 'uploadTab') { |
||||||
|
return !(importState.fileList.length > 0) |
||||||
|
} else if (activeKey.value === 'urlTab') { |
||||||
|
if (!validateInfos.url.validateStatus) return true |
||||||
|
return validateInfos.url.validateStatus === 'error' |
||||||
|
} else if (activeKey.value === 'jsonEditorTab') { |
||||||
|
return !jsonEditorRef.value?.isValid |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const disableImportButton = computed(() => { |
||||||
|
return !templateEditorRef.value?.isValid |
||||||
|
}) |
||||||
|
|
||||||
|
const disableFormatJsonButton = computed(() => !jsonEditorRef.value?.isValid) |
||||||
|
|
||||||
|
async function handlePreImport() { |
||||||
|
loading.value = true |
||||||
|
if (activeKey.value === 'uploadTab') { |
||||||
|
await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name) |
||||||
|
} else if (activeKey.value === 'urlTab') { |
||||||
|
try { |
||||||
|
await validate() |
||||||
|
await parseAndExtractData(importState.url, '') |
||||||
|
} catch (e: any) { |
||||||
|
toast.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} else if (activeKey.value === 'jsonEditorTab') { |
||||||
|
await parseAndExtractData(JSON.stringify(importState.jsonEditor), '') |
||||||
|
} |
||||||
|
loading.value = false |
||||||
|
} |
||||||
|
|
||||||
|
async function handleImport() { |
||||||
|
loading.value = true |
||||||
|
await templateEditorRef.value.importTemplate() |
||||||
|
loading.value = false |
||||||
|
dialogShow.value = false |
||||||
|
} |
||||||
|
|
||||||
|
async function parseAndExtractData(val: any, name: string) { |
||||||
|
try { |
||||||
|
templateData.value = null |
||||||
|
importData.value = null |
||||||
|
const templateGenerator: any = getAdapter(name, val) |
||||||
|
if (!templateGenerator) { |
||||||
|
toast.error('Template Generator cannot be found!') |
||||||
|
return |
||||||
|
} |
||||||
|
await templateGenerator.init() |
||||||
|
templateGenerator.parse() |
||||||
|
templateData.value = templateGenerator.getTemplate() |
||||||
|
templateData.value.tables[0].table_name = populateUniqueTableName() |
||||||
|
importData.value = templateGenerator.getData() |
||||||
|
templateEditorModal.value = true |
||||||
|
} catch (e: any) { |
||||||
|
console.log(e) |
||||||
|
toast.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function rejectDrop(fileList: any[]) { |
||||||
|
fileList.map((file) => { |
||||||
|
return toast.error(`Failed to upload file ${file.name}`) |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function handleChange(info: UploadChangeParam) { |
||||||
|
const status = info.file.status |
||||||
|
if (status !== 'uploading') { |
||||||
|
const reader: any = new FileReader() |
||||||
|
reader.onload = (e: any) => { |
||||||
|
const target: any = importState.fileList.find((f: any) => f?.uid === info.file.uid) |
||||||
|
if (target) { |
||||||
|
target.data = e.target.result |
||||||
|
} |
||||||
|
} |
||||||
|
reader.readAsArrayBuffer(info.file.originFileObj) |
||||||
|
} |
||||||
|
if (status === 'done') { |
||||||
|
toast.success(`Uploaded file ${info.file.name} successfully`) |
||||||
|
} else if (status === 'error') { |
||||||
|
toast.error(`Failed to upload file ${info.file.name}`) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function formatJson() { |
||||||
|
jsonEditorRef.value?.format() |
||||||
|
} |
||||||
|
|
||||||
|
function populateUniqueTableName() { |
||||||
|
let c = 1 |
||||||
|
while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) { |
||||||
|
c++ |
||||||
|
} |
||||||
|
return `Sheet${c}` |
||||||
|
} |
||||||
|
|
||||||
|
function getAdapter(name: string, val: any) { |
||||||
|
if (IsImportTypeExcel.value || isImportTypeCsv.value) { |
||||||
|
switch (activeKey.value) { |
||||||
|
case 'uploadTab': |
||||||
|
return new ExcelTemplateAdapter(name, val, importState.parserConfig) |
||||||
|
case 'urlTab': |
||||||
|
return new ExcelUrlTemplateAdapter(val, importState.parserConfig) |
||||||
|
} |
||||||
|
} else if (isImportTypeJson.value) { |
||||||
|
switch (activeKey.value) { |
||||||
|
case 'uploadTab': |
||||||
|
return new JSONTemplateAdapter(name, val, importState.parserConfig) |
||||||
|
case 'urlTab': |
||||||
|
return new JSONUrlTemplateAdapter(val, importState.parserConfig) |
||||||
|
case 'jsonEditorTab': |
||||||
|
return new JSONTemplateAdapter(name, val, importState.parserConfig) |
||||||
|
} |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-modal v-model:visible="dialogShow" width="max(90vw, 600px)" @keydown.esc="dialogShow = false"> |
||||||
|
<a-typography-title class="ml-5 mt-5 mb-5" type="secondary" :level="5">{{ importMeta.header }}</a-typography-title> |
||||||
|
<template #footer> |
||||||
|
<a-button v-if="templateEditorModal" key="back" @click="templateEditorModal = false">Back</a-button> |
||||||
|
<a-button v-else key="cancel" @click="dialogShow = false">{{ $t('general.cancel') }}</a-button> |
||||||
|
<a-button v-if="activeKey === 'jsonEditorTab'" key="format" :disabled="disableFormatJsonButton" @click="formatJson" |
||||||
|
>Format JSON</a-button |
||||||
|
> |
||||||
|
<a-button |
||||||
|
v-if="!templateEditorModal" |
||||||
|
key="pre-import" |
||||||
|
type="primary" |
||||||
|
:loading="loading" |
||||||
|
:disabled="disablePreImportButton" |
||||||
|
@click="handlePreImport" |
||||||
|
>{{ $t('activity.import') }} |
||||||
|
</a-button> |
||||||
|
<a-button v-else key="import" type="primary" :loading="loading" :disabled="disableImportButton" @click="handleImport">{{ |
||||||
|
$t('activity.import') |
||||||
|
}}</a-button> |
||||||
|
</template> |
||||||
|
<div class="ml-5 mr-5"> |
||||||
|
<TemplateEditor |
||||||
|
v-if="templateEditorModal" |
||||||
|
ref="templateEditorRef" |
||||||
|
:project-template="templateData" |
||||||
|
:import-data="importData" |
||||||
|
:quick-import-type="importType" |
||||||
|
/> |
||||||
|
<a-tabs v-else v-model:activeKey="activeKey" hide-add type="editable-card" :tab-position="top"> |
||||||
|
<a-tab-pane key="uploadTab" :closable="false"> |
||||||
|
<template #tab> |
||||||
|
<span class="flex items-center gap-2"> |
||||||
|
<MdiFileUploadOutlineIcon /> |
||||||
|
Upload |
||||||
|
</span> |
||||||
|
</template> |
||||||
|
<div class="pr-10 pb-0 pt-5"> |
||||||
|
<a-upload-dragger |
||||||
|
v-model:fileList="importState.fileList" |
||||||
|
name="file" |
||||||
|
:accept="importMeta.acceptTypes" |
||||||
|
:max-count="1" |
||||||
|
list-type="picture" |
||||||
|
@change="handleChange" |
||||||
|
@reject="rejectDrop" |
||||||
|
> |
||||||
|
<MdiFileIcon size="large" /> |
||||||
|
<p class="ant-upload-text">Click or drag file to this area to upload</p> |
||||||
|
<p class="ant-upload-hint"> |
||||||
|
{{ importMeta.uploadHint }} |
||||||
|
</p> |
||||||
|
</a-upload-dragger> |
||||||
|
</div> |
||||||
|
</a-tab-pane> |
||||||
|
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false"> |
||||||
|
<template #tab> |
||||||
|
<span class="flex items-center gap-2"> |
||||||
|
<MdiCodeJSONIcon /> |
||||||
|
Json Editor |
||||||
|
</span> |
||||||
|
</template> |
||||||
|
<div class="pb-3 pt-3"> |
||||||
|
<MonacoEditor ref="jsonEditorRef" v-model="importState.jsonEditor" class="min-h-60 max-h-80" /> |
||||||
|
</div> |
||||||
|
</a-tab-pane> |
||||||
|
<a-tab-pane v-else key="urlTab" :closable="false"> |
||||||
|
<template #tab> |
||||||
|
<span class="flex items-center gap-2"> |
||||||
|
<MdiLinkVariantIcon /> |
||||||
|
Url |
||||||
|
</span> |
||||||
|
</template> |
||||||
|
<div class="pr-10 pt-5"> |
||||||
|
<a-form :model="importState" name="quick-import-url-form" layout="horizontal" class="mb-0"> |
||||||
|
<a-form-item :label="importMeta.urlInputLabel" v-bind="validateInfos.url"> |
||||||
|
<a-input v-model:value="importState.url" size="large" /> |
||||||
|
</a-form-item> |
||||||
|
</a-form> |
||||||
|
</div> |
||||||
|
</a-tab-pane> |
||||||
|
</a-tabs> |
||||||
|
</div> |
||||||
|
<div v-if="!templateEditorModal" class="ml-5 mr-5"> |
||||||
|
<a-divider /> |
||||||
|
<div class="mb-4"> |
||||||
|
<span class="prose-xl font-bold">Advanced Settings</span> |
||||||
|
<a-form-item class="mt-4 mb-2" :label="t('msg.info.footMsg')" v-bind="validateInfos.maxRowsToParse"> |
||||||
|
<a-input-number v-model:value="importState.parserConfig.maxRowsToParse" :min="1" :max="50000" /> |
||||||
|
</a-form-item> |
||||||
|
<div v-if="isImportTypeJson" class="mt-3"> |
||||||
|
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested"> |
||||||
|
<span class="caption">Flatten nested</span> |
||||||
|
</a-checkbox> |
||||||
|
</div> |
||||||
|
<div v-if="isImportTypeJson" class="mt-4"> |
||||||
|
<a-checkbox v-model:checked="importState.parserConfig.importData">Import data</a-checkbox> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped lang="scss"> |
||||||
|
:deep(.ant-upload-list) { |
||||||
|
@apply max-h-80 overflow-auto; |
||||||
|
} |
||||||
|
</style> |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@ |
|||||||
|
import Papaparse from 'papaparse' |
||||||
|
import TemplateGenerator from './TemplateGenerator' |
||||||
|
|
||||||
|
export default class CSVTemplateAdapter extends TemplateGenerator { |
||||||
|
fileName: string |
||||||
|
project: object |
||||||
|
data: object |
||||||
|
csv: any |
||||||
|
csvData: any |
||||||
|
columns: object |
||||||
|
|
||||||
|
constructor(name: string, data: object) { |
||||||
|
super() |
||||||
|
this.fileName = name |
||||||
|
this.csvData = data |
||||||
|
this.project = { |
||||||
|
title: this.fileName, |
||||||
|
tables: [], |
||||||
|
} |
||||||
|
this.data = {} |
||||||
|
this.csv = {} |
||||||
|
this.columns = {} |
||||||
|
this.csvData = {} |
||||||
|
} |
||||||
|
|
||||||
|
async init() { |
||||||
|
this.csv = Papaparse.parse(this.csvData, { header: true }) |
||||||
|
} |
||||||
|
|
||||||
|
parseData() { |
||||||
|
this.columns = this.csv.meta.fields |
||||||
|
this.data = this.csv.data |
||||||
|
} |
||||||
|
|
||||||
|
getColumns() { |
||||||
|
return this.columns |
||||||
|
} |
||||||
|
|
||||||
|
getData() { |
||||||
|
return this.data |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,240 @@ |
|||||||
|
import XLSX from 'xlsx' |
||||||
|
import { UITypes } from 'nocodb-sdk' |
||||||
|
import TemplateGenerator from './TemplateGenerator' |
||||||
|
import { getCheckboxValue, isCheckboxType } from './parserHelpers' |
||||||
|
|
||||||
|
const excelTypeToUidt: Record<any, any> = { |
||||||
|
d: UITypes.DateTime, |
||||||
|
b: UITypes.Checkbox, |
||||||
|
n: UITypes.Number, |
||||||
|
s: UITypes.SingleLineText, |
||||||
|
} |
||||||
|
|
||||||
|
export default class ExcelTemplateAdapter extends TemplateGenerator { |
||||||
|
config: Record<string, any> |
||||||
|
name: string |
||||||
|
excelData: any |
||||||
|
project: Record<string, any> |
||||||
|
data: Record<string, any> |
||||||
|
wb: any |
||||||
|
constructor(name = '', data = {}, parserConfig = {}) { |
||||||
|
super() |
||||||
|
this.config = { |
||||||
|
maxRowsToParse: 500, |
||||||
|
...parserConfig, |
||||||
|
} |
||||||
|
this.name = name |
||||||
|
this.excelData = data |
||||||
|
this.project = { |
||||||
|
title: this.name, |
||||||
|
tables: [], |
||||||
|
} |
||||||
|
this.data = {} |
||||||
|
} |
||||||
|
|
||||||
|
async init() { |
||||||
|
const options: Record<any, boolean> = { |
||||||
|
cellText: true, |
||||||
|
cellDates: true, |
||||||
|
} |
||||||
|
if (this.name.slice(-3) === 'csv') { |
||||||
|
this.wb = XLSX.read(new TextDecoder().decode(new Uint8Array(this.excelData)), { |
||||||
|
type: 'string', |
||||||
|
...options, |
||||||
|
}) |
||||||
|
} else { |
||||||
|
this.wb = XLSX.read(new Uint8Array(this.excelData), { |
||||||
|
type: 'array', |
||||||
|
...options, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
parse() { |
||||||
|
const tableNamePrefixRef: any = {} |
||||||
|
for (let i = 0; i < this.wb.SheetNames.length; i++) { |
||||||
|
const columnNamePrefixRef: Record<any, any> = { id: 0 } |
||||||
|
const sheet: any = this.wb.SheetNames[i] |
||||||
|
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim() |
||||||
|
|
||||||
|
while (tn in tableNamePrefixRef) { |
||||||
|
tn = `${tn}${++tableNamePrefixRef[tn]}` |
||||||
|
} |
||||||
|
tableNamePrefixRef[tn] = 0 |
||||||
|
|
||||||
|
const table: Record<string, any> = { table_name: tn, ref_table_name: tn, columns: [] } |
||||||
|
this.data[tn] = [] |
||||||
|
const ws: any = this.wb.Sheets[sheet] |
||||||
|
const range = XLSX.utils.decode_range(ws['!ref']) |
||||||
|
let rows: any = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: false, defval: null }) |
||||||
|
|
||||||
|
if (this.name.slice(-3) !== 'csv') { |
||||||
|
// fix precision bug & timezone offset issues introduced by xlsx
|
||||||
|
const basedate = new Date(1899, 11, 30, 0, 0, 0) |
||||||
|
// number of milliseconds since base date
|
||||||
|
const dnthresh = basedate.getTime() + (new Date().getTimezoneOffset() - basedate.getTimezoneOffset()) * 60000 |
||||||
|
// number of milliseconds in a day
|
||||||
|
const day_ms = 24 * 60 * 60 * 1000 |
||||||
|
// handle date1904 property
|
||||||
|
const fixImportedDate = (date: Date) => { |
||||||
|
const parsed = XLSX.SSF.parse_date_code((date.getTime() - dnthresh) / day_ms, { |
||||||
|
date1904: this.wb.Workbook.WBProps.date1904, |
||||||
|
}) |
||||||
|
return new Date(parsed.y, parsed.m, parsed.d, parsed.H, parsed.M, parsed.S) |
||||||
|
} |
||||||
|
// fix imported date
|
||||||
|
rows = rows.map((r: any) => |
||||||
|
r.map((v: any) => { |
||||||
|
return v instanceof Date ? fixImportedDate(v) : v |
||||||
|
}), |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const columnNameRowExist = +rows[0].every((v: any) => v === null || typeof v === 'string') |
||||||
|
|
||||||
|
// const colLen = Math.max()
|
||||||
|
for (let col = 0; col < rows[0].length; col++) { |
||||||
|
let cn: string = ((columnNameRowExist && rows[0] && rows[0][col] && rows[0][col].toString().trim()) || `field_${col + 1}`) |
||||||
|
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_') |
||||||
|
.trim() |
||||||
|
|
||||||
|
while (cn in columnNamePrefixRef) { |
||||||
|
cn = `${cn}${++columnNamePrefixRef[cn]}` |
||||||
|
} |
||||||
|
columnNamePrefixRef[cn] = 0 |
||||||
|
|
||||||
|
const column: Record<string, any> = { |
||||||
|
column_name: cn, |
||||||
|
ref_column_name: cn, |
||||||
|
} |
||||||
|
|
||||||
|
table.columns.push(column) |
||||||
|
|
||||||
|
// const cellId = `${col.toString(26).split('').map(s => (parseInt(s, 26) + 10).toString(36).toUpperCase())}2`;
|
||||||
|
const cellId = XLSX.utils.encode_cell({ |
||||||
|
c: range.s.c + col, |
||||||
|
r: columnNameRowExist, |
||||||
|
}) |
||||||
|
const cellProps = ws[cellId] || {} |
||||||
|
column.uidt = excelTypeToUidt[cellProps.t] || UITypes.SingleLineText |
||||||
|
|
||||||
|
// todo: optimize
|
||||||
|
if (column.uidt === UITypes.SingleLineText) { |
||||||
|
// check for long text
|
||||||
|
if (rows.some((r: any) => (r[col] || '').toString().match(/[\r\n]/) || (r[col] || '').toString().length > 255)) { |
||||||
|
column.uidt = UITypes.LongText |
||||||
|
} else { |
||||||
|
const vals = rows |
||||||
|
.slice(columnNameRowExist ? 1 : 0) |
||||||
|
.map((r: any) => r[col]) |
||||||
|
.filter((v: any) => v !== null && v !== undefined && v.toString().trim() !== '') |
||||||
|
|
||||||
|
const checkboxType = isCheckboxType(vals) |
||||||
|
if (checkboxType.length === 1) { |
||||||
|
column.uidt = UITypes.Checkbox |
||||||
|
} else { |
||||||
|
// todo: optimize
|
||||||
|
// check column is multi or single select by comparing unique values
|
||||||
|
// todo:
|
||||||
|
if (vals.some((v: any) => v && v.toString().includes(','))) { |
||||||
|
let flattenedVals = vals.flatMap((v: any) => |
||||||
|
v |
||||||
|
? v |
||||||
|
.toString() |
||||||
|
.trim() |
||||||
|
.split(/\s*,\s*/) |
||||||
|
: [], |
||||||
|
) |
||||||
|
const uniqueVals = (flattenedVals = flattenedVals.filter( |
||||||
|
(v: any, i: any, arr: any) => i === arr.findIndex((v1: any) => v.toLowerCase() === v1.toLowerCase()), |
||||||
|
)) |
||||||
|
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) { |
||||||
|
column.uidt = UITypes.MultiSelect |
||||||
|
column.dtxp = `'${uniqueVals.join("','")}'` |
||||||
|
} |
||||||
|
} else { |
||||||
|
const uniqueVals = vals |
||||||
|
.map((v: any) => v.toString().trim()) |
||||||
|
.filter((v: any, i: any, arr: any) => i === arr.findIndex((v1: any) => v.toLowerCase() === v1.toLowerCase())) |
||||||
|
if (vals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(vals.length / 2)) { |
||||||
|
column.uidt = UITypes.SingleSelect |
||||||
|
column.dtxp = `'${uniqueVals.join("','")}'` |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} else if (column.uidt === UITypes.Number) { |
||||||
|
if ( |
||||||
|
rows.slice(1, this.config.maxRowsToParse).some((v: any) => { |
||||||
|
return v && v[col] && parseInt(v[col]) !== +v[col] |
||||||
|
}) |
||||||
|
) { |
||||||
|
column.uidt = UITypes.Decimal |
||||||
|
} |
||||||
|
if ( |
||||||
|
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => { |
||||||
|
const cellId = XLSX.utils.encode_cell({ |
||||||
|
c: range.s.c + col, |
||||||
|
r: i + columnNameRowExist, |
||||||
|
}) |
||||||
|
|
||||||
|
const cellObj = ws[cellId] |
||||||
|
|
||||||
|
return !cellObj || (cellObj.w && cellObj.w.startsWith('$')) |
||||||
|
}) |
||||||
|
) { |
||||||
|
column.uidt = UITypes.Currency |
||||||
|
} |
||||||
|
} else if (column.uidt === UITypes.DateTime) { |
||||||
|
if ( |
||||||
|
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => { |
||||||
|
const cellId = XLSX.utils.encode_cell({ |
||||||
|
c: range.s.c + col, |
||||||
|
r: i + columnNameRowExist, |
||||||
|
}) |
||||||
|
|
||||||
|
const cellObj = ws[cellId] |
||||||
|
return !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1) |
||||||
|
}) |
||||||
|
) { |
||||||
|
column.uidt = UITypes.Date |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let rowIndex = 0 |
||||||
|
for (const row of rows.slice(1)) { |
||||||
|
const rowData: Record<string, any> = {} |
||||||
|
for (let i = 0; i < table.columns.length; i++) { |
||||||
|
if (table.columns[i].uidt === UITypes.Checkbox) { |
||||||
|
rowData[table.columns[i].column_name] = getCheckboxValue(row[i]) |
||||||
|
} else if (table.columns[i].uidt === UITypes.Currency) { |
||||||
|
const cellId = XLSX.utils.encode_cell({ |
||||||
|
c: range.s.c + i, |
||||||
|
r: rowIndex + columnNameRowExist, |
||||||
|
}) |
||||||
|
|
||||||
|
const cellObj = ws[cellId] |
||||||
|
rowData[table.columns[i].column_name] = (cellObj && cellObj.w && cellObj.w.replace(/[^\d.]+/g, '')) || row[i] |
||||||
|
} else if (table.columns[i].uidt === UITypes.SingleSelect || table.columns[i].uidt === UITypes.MultiSelect) { |
||||||
|
rowData[table.columns[i].column_name] = (row[i] || '').toString().trim() || null |
||||||
|
} else { |
||||||
|
// toto: do parsing if necessary based on type
|
||||||
|
rowData[table.columns[i].column_name] = row[i] |
||||||
|
} |
||||||
|
} |
||||||
|
this.data[tn].push(rowData) |
||||||
|
rowIndex++ |
||||||
|
} |
||||||
|
this.project.tables.push(table) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
getTemplate() { |
||||||
|
return this.project |
||||||
|
} |
||||||
|
|
||||||
|
getData() { |
||||||
|
return this.data |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,27 @@ |
|||||||
|
import ExcelTemplateAdapter from './ExcelTemplateAdapter' |
||||||
|
import { useNuxtApp } from '#imports' |
||||||
|
|
||||||
|
export default class ExcelUrlTemplateAdapter extends ExcelTemplateAdapter { |
||||||
|
url: string |
||||||
|
excelData: any |
||||||
|
$api: any |
||||||
|
|
||||||
|
constructor(url: string, parserConfig: Record<string, any>) { |
||||||
|
const { $api } = useNuxtApp() |
||||||
|
const name = url?.split('/').pop() |
||||||
|
super(name, parserConfig) |
||||||
|
this.url = url |
||||||
|
this.excelData = null |
||||||
|
this.$api = $api |
||||||
|
} |
||||||
|
|
||||||
|
async init() { |
||||||
|
const data: any = await this.$api.utils.axiosRequestMake({ |
||||||
|
apiMeta: { |
||||||
|
url: this.url, |
||||||
|
}, |
||||||
|
}) |
||||||
|
this.excelData = data.data |
||||||
|
await super.init() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,168 @@ |
|||||||
|
import { UITypes } from 'nocodb-sdk' |
||||||
|
import { |
||||||
|
extractMultiOrSingleSelectProps, |
||||||
|
getCheckboxValue, |
||||||
|
isCheckboxType, |
||||||
|
isDecimalType, |
||||||
|
isEmailType, |
||||||
|
isMultiLineTextType, |
||||||
|
isUrlType, |
||||||
|
} from './parserHelpers' |
||||||
|
import TemplateGenerator from './TemplateGenerator' |
||||||
|
|
||||||
|
const jsonTypeToUidt: Record<string, string> = { |
||||||
|
number: UITypes.Number, |
||||||
|
string: UITypes.SingleLineText, |
||||||
|
date: UITypes.DateTime, |
||||||
|
boolean: UITypes.Checkbox, |
||||||
|
object: UITypes.JSON, |
||||||
|
} |
||||||
|
|
||||||
|
const extractNestedData: any = (obj: any, path: any) => path.reduce((val: any, key: any) => val && val[key], obj) |
||||||
|
|
||||||
|
export default class JSONTemplateAdapter extends TemplateGenerator { |
||||||
|
config: Record<string, any> |
||||||
|
name: string |
||||||
|
data: Record<string, any> |
||||||
|
_jsonData: string | Record<string, any> |
||||||
|
jsonData: Record<string, any> |
||||||
|
project: Record<string, any> |
||||||
|
columns: object |
||||||
|
constructor(name = 'test', data: object, parserConfig = {}) { |
||||||
|
super() |
||||||
|
this.config = { |
||||||
|
maxRowsToParse: 500, |
||||||
|
...parserConfig, |
||||||
|
} |
||||||
|
this.name = name |
||||||
|
this._jsonData = data |
||||||
|
this.project = { |
||||||
|
title: this.name, |
||||||
|
tables: [], |
||||||
|
} |
||||||
|
this.jsonData = [] |
||||||
|
this.data = [] |
||||||
|
this.columns = {} |
||||||
|
} |
||||||
|
|
||||||
|
async init() { |
||||||
|
const parsedJsonData = |
||||||
|
typeof this._jsonData === 'string' |
||||||
|
? // for json editor
|
||||||
|
JSON.parse(this._jsonData) |
||||||
|
: // for file upload
|
||||||
|
JSON.parse(new TextDecoder().decode(this._jsonData as BufferSource)) |
||||||
|
this.jsonData = Array.isArray(parsedJsonData) ? parsedJsonData : [parsedJsonData] |
||||||
|
} |
||||||
|
|
||||||
|
getColumns(): any { |
||||||
|
return this.columns |
||||||
|
} |
||||||
|
|
||||||
|
getData(): any { |
||||||
|
return this.data |
||||||
|
} |
||||||
|
|
||||||
|
parse(): any { |
||||||
|
const jsonData = this.jsonData |
||||||
|
const tn = 'table' |
||||||
|
const table: any = { table_name: tn, ref_table_name: tn, columns: [] } |
||||||
|
|
||||||
|
this.data[tn] = [] |
||||||
|
|
||||||
|
for (const col of Object.keys(jsonData[0])) { |
||||||
|
const columns = this._parseColumn([col], jsonData) |
||||||
|
table.columns.push(...columns) |
||||||
|
} |
||||||
|
|
||||||
|
if (this.config.importData) { |
||||||
|
this._parseTableData(table) |
||||||
|
} |
||||||
|
|
||||||
|
this.project.tables.push(table) |
||||||
|
} |
||||||
|
|
||||||
|
getTemplate() { |
||||||
|
return this.project |
||||||
|
} |
||||||
|
|
||||||
|
_parseColumn( |
||||||
|
path: any = [], |
||||||
|
jsonData = this.jsonData, |
||||||
|
firstRowVal = path.reduce((val: any, k: any) => val && val[k], this.jsonData[0]), |
||||||
|
): any { |
||||||
|
const columns = [] |
||||||
|
// parse nested
|
||||||
|
if (firstRowVal && typeof firstRowVal === 'object' && !Array.isArray(firstRowVal) && this.config.normalizeNested) { |
||||||
|
for (const key of Object.keys(firstRowVal)) { |
||||||
|
const normalizedNestedColumns = this._parseColumn([...path, key], this.jsonData, firstRowVal[key]) |
||||||
|
columns.push(...normalizedNestedColumns) |
||||||
|
} |
||||||
|
} else { |
||||||
|
const cn = path.join('_').replace(/\W/g, '_').trim() |
||||||
|
|
||||||
|
const column: Record<string, any> = { |
||||||
|
column_name: cn, |
||||||
|
ref_column_name: cn, |
||||||
|
path, |
||||||
|
} |
||||||
|
|
||||||
|
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText |
||||||
|
|
||||||
|
const colData = jsonData.map((r: any) => extractNestedData(r, path)) |
||||||
|
Object.assign(column, this._getColumnUIDTAndMetas(colData, column.uidt)) |
||||||
|
columns.push(column) |
||||||
|
} |
||||||
|
|
||||||
|
return columns |
||||||
|
} |
||||||
|
|
||||||
|
_getColumnUIDTAndMetas(colData: any, defaultType: any) { |
||||||
|
const colProps = { uidt: defaultType } |
||||||
|
// todo: optimize
|
||||||
|
if (colProps.uidt === UITypes.SingleLineText) { |
||||||
|
// check for long text
|
||||||
|
if (isMultiLineTextType(colData)) { |
||||||
|
colProps.uidt = UITypes.LongText |
||||||
|
} |
||||||
|
if (isEmailType(colData)) { |
||||||
|
colProps.uidt = UITypes.Email |
||||||
|
} |
||||||
|
if (isUrlType(colData)) { |
||||||
|
colProps.uidt = UITypes.URL |
||||||
|
} else { |
||||||
|
const checkboxType = isCheckboxType(colData) |
||||||
|
if (checkboxType.length === 1) { |
||||||
|
colProps.uidt = UITypes.Checkbox |
||||||
|
} else { |
||||||
|
Object.assign(colProps, extractMultiOrSingleSelectProps(colData)) |
||||||
|
} |
||||||
|
} |
||||||
|
} else if (colProps.uidt === UITypes.Number) { |
||||||
|
if (isDecimalType(colData)) { |
||||||
|
colProps.uidt = UITypes.Decimal |
||||||
|
} |
||||||
|
} |
||||||
|
return colProps |
||||||
|
} |
||||||
|
|
||||||
|
_parseTableData(tableMeta: any) { |
||||||
|
for (const row of this.jsonData as any) { |
||||||
|
const rowData: any = {} |
||||||
|
for (let i = 0; i < tableMeta.columns.length; i++) { |
||||||
|
const value = extractNestedData(row, tableMeta.columns[i].path || []) |
||||||
|
if (tableMeta.columns[i].uidt === UITypes.Checkbox) { |
||||||
|
rowData[tableMeta.columns[i].ref_column_name] = getCheckboxValue(value) |
||||||
|
} else if (tableMeta.columns[i].uidt === UITypes.SingleSelect || tableMeta.columns[i].uidt === UITypes.MultiSelect) { |
||||||
|
rowData[tableMeta.columns[i].ref_column_name] = (value || '').toString().trim() || null |
||||||
|
} else if (tableMeta.columns[i].uidt === UITypes.JSON) { |
||||||
|
rowData[tableMeta.columns[i].ref_column_name] = JSON.stringify(value) |
||||||
|
} else { |
||||||
|
// toto: do parsing if necessary based on type
|
||||||
|
rowData[tableMeta.columns[i].column_name] = value |
||||||
|
} |
||||||
|
} |
||||||
|
this.data[tableMeta.ref_table_name].push(rowData) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import JSONTemplateAdapter from './JSONTemplateAdapter' |
||||||
|
import { useNuxtApp } from '#imports' |
||||||
|
|
||||||
|
export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter { |
||||||
|
url: string |
||||||
|
$api: any |
||||||
|
|
||||||
|
constructor(url: string, parserConfig: Record<string, any>) { |
||||||
|
const { $api } = useNuxtApp() |
||||||
|
const name = url.split('/').pop() |
||||||
|
super(name, parserConfig) |
||||||
|
this.url = url |
||||||
|
this.$api = $api |
||||||
|
} |
||||||
|
|
||||||
|
async init() { |
||||||
|
const data = await this.$api.utils.axiosRequestMake({ |
||||||
|
apiMeta: { |
||||||
|
url: this.url, |
||||||
|
}, |
||||||
|
}) |
||||||
|
this._jsonData = data |
||||||
|
await super.init() |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
export default class TemplateGenerator { |
||||||
|
parse() { |
||||||
|
throw new Error("'parse' method is not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
parseData() { |
||||||
|
throw new Error("'parseData' method is not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
parseTemplate() { |
||||||
|
throw new Error("'parseTemplate' method is not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
getColumns() { |
||||||
|
throw new Error("'getColumns' method is not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
getTemplate() { |
||||||
|
throw new Error("'getTemplate' method is not implemented") |
||||||
|
} |
||||||
|
|
||||||
|
getData() { |
||||||
|
throw new Error("'getData' method is not implemented") |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
import JSONTemplateAdapter from './JSONTemplateAdapter' |
||||||
|
import JSONUrlTemplateAdapter from './JSONUrlTemplateAdapter' |
||||||
|
import CSVTemplateAdapter from './CSVTemplateAdapter' |
||||||
|
import ExcelTemplateAdapter from './ExcelTemplateAdapter' |
||||||
|
import ExcelUrlTemplateAdapter from './ExcelUrlTemplateAdapter' |
||||||
|
|
||||||
|
export { JSONTemplateAdapter, JSONUrlTemplateAdapter, CSVTemplateAdapter, ExcelTemplateAdapter, ExcelUrlTemplateAdapter } |
@ -0,0 +1,95 @@ |
|||||||
|
import { UITypes } from 'nocodb-sdk' |
||||||
|
import { isValidURL } from '~/utils/urlUtils' |
||||||
|
import { isEmail } from '~/utils/validation' |
||||||
|
|
||||||
|
const booleanOptions = [ |
||||||
|
{ checked: true, unchecked: false }, |
||||||
|
{ 'x': true, '': false }, |
||||||
|
{ yes: true, no: false }, |
||||||
|
{ y: true, n: false }, |
||||||
|
{ 1: true, 0: false }, |
||||||
|
{ '[x]': true, '[]': false, '[ ]': false }, |
||||||
|
{ '☑': true, '': false }, |
||||||
|
{ '✅': true, '': false }, |
||||||
|
{ '✓': true, '': false }, |
||||||
|
{ '✔': true, '': false }, |
||||||
|
{ enabled: true, disabled: false }, |
||||||
|
{ on: true, off: false }, |
||||||
|
{ 'done': true, '': false }, |
||||||
|
{ true: true, false: false }, |
||||||
|
] |
||||||
|
const aggBooleanOptions: any = booleanOptions.reduce((obj, o) => ({ ...obj, ...o }), {}) |
||||||
|
|
||||||
|
const getColVal = (row: any, col = null) => { |
||||||
|
return row && col ? row[col] : row |
||||||
|
} |
||||||
|
|
||||||
|
export const isCheckboxType: any = (values: [], col = null) => { |
||||||
|
let options = booleanOptions |
||||||
|
for (let i = 0; i < values.length; i++) { |
||||||
|
const val = getColVal(values[i], col) |
||||||
|
|
||||||
|
if (val === null || val === undefined || val.toString().trim() === '') { |
||||||
|
continue |
||||||
|
} |
||||||
|
|
||||||
|
options = options.filter((v) => val in v) |
||||||
|
if (!options.length) { |
||||||
|
return false |
||||||
|
} |
||||||
|
} |
||||||
|
return options |
||||||
|
} |
||||||
|
export const getCheckboxValue = (value: number) => { |
||||||
|
return value && aggBooleanOptions[value] |
||||||
|
} |
||||||
|
|
||||||
|
export const isMultiLineTextType = (values: [], col = null) => { |
||||||
|
return values.some( |
||||||
|
(r) => (getColVal(r, col) || '').toString().match(/[\r\n]/) || (getColVal(r, col) || '').toString().length > 255, |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export const extractMultiOrSingleSelectProps = (colData: []) => { |
||||||
|
const colProps: any = {} |
||||||
|
if (colData.some((v: any) => v && (v || '').toString().includes(','))) { |
||||||
|
let flattenedVals = colData.flatMap((v: any) => |
||||||
|
v |
||||||
|
? v |
||||||
|
.toString() |
||||||
|
.trim() |
||||||
|
.split(/\s*,\s*/) |
||||||
|
: [], |
||||||
|
) |
||||||
|
const uniqueVals = (flattenedVals = flattenedVals.filter( |
||||||
|
(v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase()), |
||||||
|
)) |
||||||
|
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) { |
||||||
|
colProps.uidt = UITypes.MultiSelect |
||||||
|
colProps.dtxp = `'${uniqueVals.join("','")}'` |
||||||
|
} |
||||||
|
} else { |
||||||
|
const uniqueVals = colData |
||||||
|
.map((v: any) => (v || '').toString().trim()) |
||||||
|
.filter((v, i, arr) => i === arr.findIndex((v1) => v.toLowerCase() === v1.toLowerCase())) |
||||||
|
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) { |
||||||
|
colProps.uidt = UITypes.SingleSelect |
||||||
|
colProps.dtxp = `'${uniqueVals.join("','")}'` |
||||||
|
} |
||||||
|
} |
||||||
|
return colProps |
||||||
|
} |
||||||
|
|
||||||
|
export const isDecimalType = (colData: []) => |
||||||
|
colData.some((v: any) => { |
||||||
|
return v && parseInt(v) !== +v |
||||||
|
}) |
||||||
|
|
||||||
|
export const isEmailType = (colData: []) => |
||||||
|
!colData.some((v: any) => { |
||||||
|
return v && !isEmail(v) |
||||||
|
}) |
||||||
|
export const isUrlType = (colData: []) => |
||||||
|
!colData.some((v: any) => { |
||||||
|
return v && !isValidURL(v) |
||||||
|
}) |
Loading…
Reference in new issue