Browse Source

Merge pull request #4135 from nocodb/feat/import-optimization

feat: import optimization
pull/4179/head
աɨռɢӄաօռɢ 2 years ago committed by GitHub
parent
commit
ef82033080
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/nc-gui/.eslintrc.js
  2. 395
      packages/nc-gui/components/dlg/QuickImport.vue
  3. 2
      packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue
  4. 2
      packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue
  5. 398
      packages/nc-gui/components/template/Editor.vue
  6. 5
      packages/nc-gui/lib/types.ts
  7. 34
      packages/nc-gui/utils/dateTimeUtils.ts
  8. 331
      packages/nc-gui/utils/parsers/CSVTemplateAdapter.ts
  9. 464
      packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts
  10. 3
      packages/nc-gui/utils/parsers/ExcelUrlTemplateAdapter.ts
  11. 68
      packages/nc-gui/utils/parsers/JSONTemplateAdapter.ts
  12. 3
      packages/nc-gui/utils/parsers/JSONUrlTemplateAdapter.ts
  13. 92
      packages/nc-gui/utils/parsers/parserHelpers.ts
  14. 26
      packages/noco-docs/content/en/setup-and-usages/table-operations.md

1
packages/nc-gui/.eslintrc.js

@ -3,6 +3,7 @@ const baseRules = {
'no-console': 0,
'antfu/if-newline': 0,
'no-unused-vars': 0,
'@typescript-eslint/no-this-alias': 0,
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },

395
packages/nc-gui/components/dlg/QuickImport.vue

@ -1,7 +1,9 @@
<script setup lang="ts">
import type { TableType } from 'nocodb-sdk'
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { Upload } from 'ant-design-vue'
import {
CSVTemplateAdapter,
ExcelTemplateAdapter,
ExcelUrlTemplateAdapter,
Form,
@ -20,14 +22,15 @@ import {
useProject,
useVModel,
} from '#imports'
import type { importFileList, streamImportFileList } from '~/lib'
interface Props {
modelValue: boolean
importType: 'csv' | 'json' | 'excel'
importOnly?: boolean
importDataOnly?: boolean
}
const { importType, importOnly = false, ...rest } = defineProps<Props>()
const { importType, importDataOnly = false, ...rest } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
@ -41,7 +44,9 @@ const jsonEditorRef = ref()
const templateEditorRef = ref()
const loading = ref(false)
const preImportLoading = ref(false)
const importLoading = ref(false)
const templateData = ref()
@ -51,16 +56,20 @@ const importColumns = ref([])
const templateEditorModal = ref(false)
const isParsingData = ref(false)
const useForm = Form.useForm
const importState = reactive({
fileList: [] as (UploadFile & { data: string | ArrayBuffer })[],
fileList: [] as importFileList | streamImportFileList,
url: '',
jsonEditor: {},
parserConfig: {
maxRowsToParse: 500,
normalizeNested: true,
importData: true,
autoSelectFieldTypes: true,
firstRowAsHeaders: true,
shouldImportData: true,
},
})
@ -130,47 +139,56 @@ const modalWidth = computed(() => {
return 'max(60vw, 600px)'
})
let templateGenerator: CSVTemplateAdapter | JSONTemplateAdapter | ExcelTemplateAdapter | null
async function handlePreImport() {
loading.value = true
preImportLoading.value = true
isParsingData.value = true
if (activeKey.value === 'uploadTab') {
await parseAndExtractData(importState.fileList[0].data, importState.fileList[0].name)
if (isImportTypeCsv.value) {
await parseAndExtractData(importState.fileList as streamImportFileList)
} else {
await parseAndExtractData((importState.fileList as importFileList)[0].data)
}
} else if (activeKey.value === 'urlTab') {
try {
await validate()
await parseAndExtractData(importState.url, '')
await parseAndExtractData(importState.url)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
} else if (activeKey.value === 'jsonEditorTab') {
await parseAndExtractData(JSON.stringify(importState.jsonEditor), '')
await parseAndExtractData(JSON.stringify(importState.jsonEditor))
}
loading.value = false
}
async function handleImport() {
try {
loading.value = true
if (!templateGenerator) {
message.error(t('msg.error.templateGeneratorNotFound'))
return
}
importLoading.value = true
await templateEditorRef.value.importTemplate()
} catch (e: any) {
return message.error(await extractSdkResponseErrorMsg(e))
} finally {
loading.value = false
importLoading.value = false
}
dialogShow.value = false
}
async function parseAndExtractData(val: string | ArrayBuffer, name: string) {
// UploadFile[] for csv import (streaming)
// ArrayBuffer for excel import
// string for json import
async function parseAndExtractData(val: UploadFile[] | ArrayBuffer | string) {
try {
templateData.value = null
importData.value = null
importColumns.value = []
const templateGenerator = getAdapter(name, val)
templateGenerator = getAdapter(val)
if (!templateGenerator) {
message.error(t('msg.error.templateGeneratorNotFound'))
@ -179,16 +197,24 @@ async function parseAndExtractData(val: string | ArrayBuffer, name: string) {
await templateGenerator.init()
templateGenerator.parse()
templateData.value = templateGenerator.getTemplate()
templateData.value.tables[0].table_name = populateUniqueTableName()
importData.value = templateGenerator.getData()
if (importOnly) importColumns.value = templateGenerator.getColumns()
await templateGenerator.parse()
templateData.value = templateGenerator!.getTemplate()
if (importDataOnly) importColumns.value = templateGenerator!.getColumns()
else {
// ensure the target table name not exist in current table list
templateData.value.tables = templateData.value.tables.map((table: Record<string, any>) => ({
...table,
table_name: populateUniqueTableName(table.table_name),
}))
}
importData.value = templateGenerator!.getData()
templateEditorModal.value = true
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isParsingData.value = false
preImportLoading.value = false
}
}
@ -200,29 +226,34 @@ function rejectDrop(fileList: UploadFile[]) {
function handleChange(info: UploadChangeParam) {
const status = info.file.status
if (status !== 'uploading' && status !== 'removed') {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = importState.fileList.find((f) => f.uid === info.file.uid)
if (e.target && e.target.result) {
/** if the file was pushed into the list by `<a-upload-dragger>` we just add the data to the file */
if (target) {
target.data = e.target.result
} else if (!target) {
/** if the file was added programmatically and not with d&d, we create file infos and push it into the list */
importState.fileList.push({
...info.file,
status: 'done',
data: e.target.result,
})
if (status && status !== 'uploading' && status !== 'removed') {
if (isImportTypeCsv.value) {
if (!importState.fileList.find((f) => f.uid === info.file.uid)) {
;(importState.fileList as streamImportFileList).push({
...info.file,
status: 'done',
})
}
} else {
const reader = new FileReader()
reader.onload = (e: ProgressEvent<FileReader>) => {
const target = (importState.fileList as importFileList).find((f) => f.uid === info.file.uid)
if (e.target && e.target.result) {
/** if the file was pushed into the list by `<a-upload-dragger>` we just add the data to the file */
if (target) {
target.data = e.target.result
} else if (!target) {
/** if the file was added programmatically and not with d&d, we create file infos and push it into the list */
importState.fileList.push({
...info.file,
status: 'done',
data: e.target.result,
})
}
}
}
reader.readAsArrayBuffer(info.file.originFileObj!)
}
reader.readAsArrayBuffer(info.file.originFileObj!)
}
if (status === 'done') {
@ -236,32 +267,50 @@ function formatJson() {
jsonEditorRef.value?.format()
}
function populateUniqueTableName() {
function populateUniqueTableName(tn: string) {
let c = 1
while (tables.value.some((t: TableType) => t.title === `Sheet${c}`)) {
c++
while (
tables.value.some((t: TableType) => {
const s = t.table_name.split('___')
let target = t.table_name
if (s.length > 1) target = s[1]
return target === `${tn}`
})
) {
tn = `${tn}_${c++}`
}
return `Sheet${c}`
return tn
}
function getAdapter(name: string, val: any) {
if (IsImportTypeExcel.value || isImportTypeCsv.value) {
function getAdapter(val: any) {
if (isImportTypeCsv.value) {
switch (activeKey.value) {
case 'uploadTab':
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: false,
})
case 'urlTab':
return new CSVTemplateAdapter(val, {
...importState.parserConfig,
importFromURL: true,
})
}
} else if (IsImportTypeExcel.value) {
switch (activeKey.value) {
case 'uploadTab':
return new ExcelTemplateAdapter(name, val, importState.parserConfig)
return new ExcelTemplateAdapter(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)
return new JSONTemplateAdapter(val, importState.parserConfig)
case 'urlTab':
return new JSONUrlTemplateAdapter(val, importState.parserConfig)
case 'jsonEditorTab':
return new JSONTemplateAdapter(name, val, importState.parserConfig)
return new JSONTemplateAdapter(val, importState.parserConfig)
}
}
@ -282,6 +331,15 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
})
customReqArgs.onSuccess()
}
/** check if the file size exceeds the limit */
const beforeUpload = (file: UploadFile) => {
const exceedLimit = file.size! / 1024 / 1024 > 5
if (exceedLimit) {
message.error(`File ${file.name} is too big. The accepted file size is less than 5MB.`)
}
return !exceedLimit || Upload.LIST_IGNORE
}
</script>
<template>
@ -291,115 +349,130 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
wrap-class-name="nc-modal-quick-import"
@keydown.esc="dialogShow = false"
>
<div class="px-5">
<div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<div class="mt-5">
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
:project-template="templateData"
:import-data="importData"
:import-columns="importColumns"
:import-only="importOnly"
:quick-import-type="importType"
:max-rows-to-parse="importState.parserConfig.maxRowsToParse"
class="nc-quick-import-template-editor"
@import="handleImport"
/>
<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>
<!-- Upload -->
<div class="flex items-center gap-2">
<MdiFileUploadOutline />
{{ $t('general.upload') }}
<a-spin :spinning="isParsingData" tip="Parsing Data ..." size="large">
<div class="px-5">
<div class="prose-xl font-weight-bold my-5">{{ importMeta.header }}</div>
<div class="mt-5">
<LazyTemplateEditor
v-if="templateEditorModal"
ref="templateEditorRef"
:project-template="templateData"
:import-data="importData"
:import-columns="importColumns"
:import-data-only="importDataOnly"
:quick-import-type="importType"
:max-rows-to-parse="importState.parserConfig.maxRowsToParse"
class="nc-quick-import-template-editor"
@import="handleImport"
/>
<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>
<!-- Upload -->
<div class="flex items-center gap-2">
<MdiFileUploadOutline />
{{ $t('general.upload') }}
</div>
</template>
<div class="py-6">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-input-import !scrollbar-thin-dull"
list-type="picture"
:accept="importMeta.acceptTypes"
:max-count="isImportTypeCsv ? 5 : 1"
:multiple="true"
:custom-request="customReqCbk"
:before-upload="beforeUpload"
@change="handleChange"
@reject="rejectDrop"
>
<MdiFilePlusOutline size="large" />
<!-- Click or drag file to this area to upload -->
<p class="ant-upload-text">{{ $t('msg.info.import.clickOrDrag') }}</p>
<p class="ant-upload-hint">
{{ importMeta.uploadHint }}
</p>
</a-upload-dragger>
</div>
</template>
<div class="py-6">
<a-upload-dragger
v-model:fileList="importState.fileList"
name="file"
class="nc-input-import !scrollbar-thin-dull"
:accept="importMeta.acceptTypes"
:max-count="1"
list-type="picture"
:custom-request="customReqCbk"
@change="handleChange"
@reject="rejectDrop"
>
<MdiFilePlusOutline size="large" />
<!-- Click or drag file to this area to upload -->
<p class="ant-upload-text">{{ $t('msg.info.import.clickOrDrag') }}</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">
<MdiCodeJson />
JSON Editor
</span>
</template>
<div class="pb-3 pt-3">
<LazyMonacoEditor 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">
<MdiLinkVariant />
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">
<a-divider />
<div class="mb-4">
<!-- Advanced Settings -->
<span class="prose-lg">{{ $t('title.advancedSettings') }}</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>
<!-- Flatten nested -->
<div v-if="isImportTypeJson" class="mt-3">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">{{ $t('labels.flattenNested') }}</span>
</a-checkbox>
</div>
</a-tab-pane>
<a-tab-pane v-if="isImportTypeJson" key="jsonEditorTab" :closable="false">
<template #tab>
<span class="flex items-center gap-2">
<MdiCodeJson />
JSON Editor
</span>
</template>
<div class="pb-3 pt-3">
<LazyMonacoEditor 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">
<MdiLinkVariant />
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>
<!-- Import Data -->
<div v-if="isImportTypeJson" class="mt-4">
<a-checkbox v-model:checked="importState.parserConfig.importData">{{ $t('labels.importData') }}</a-checkbox>
<div v-if="!templateEditorModal">
<a-divider />
<div class="mb-4">
<!-- Advanced Settings -->
<span class="prose-lg">{{ $t('title.advancedSettings') }}</span>
<a-form-item class="!my-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>
<a-form-item v-if="!importDataOnly" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.autoSelectFieldTypes">
<span class="caption">Auto-Select Field Types</span>
</a-checkbox>
</a-form-item>
<a-form-item v-if="isImportTypeCsv || IsImportTypeExcel" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.firstRowAsHeaders">
<span class="caption">Use First Row as Headers</span>
</a-checkbox>
</a-form-item>
<!-- Flatten nested -->
<a-form-item v-if="isImportTypeJson" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.normalizeNested">
<span class="caption">{{ $t('labels.flattenNested') }}</span>
</a-checkbox>
</a-form-item>
<!-- Import Data -->
<a-form-item v-if="!importDataOnly" class="!my-2">
<a-checkbox v-model:checked="importState.parserConfig.shouldImportData">{{ $t('labels.importData') }}</a-checkbox>
</a-form-item>
</div>
</div>
</div>
</div>
</a-spin>
<template #footer>
<a-button v-if="templateEditorModal" key="back" @click="templateEditorModal = false">Back</a-button>
@ -419,14 +492,14 @@ const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
key="pre-import"
type="primary"
class="nc-btn-import"
:loading="loading"
:loading="preImportLoading"
:disabled="disablePreImportButton"
@click="handlePreImport"
>
{{ $t('activity.import') }}
</a-button>
<a-button v-else key="import" type="primary" :loading="loading" :disabled="disableImportButton" @click="handleImport">
<a-button v-else key="import" type="primary" :loading="importLoading" :disabled="disableImportButton" @click="handleImport">
{{ $t('activity.import') }}
</a-button>
</template>

2
packages/nc-gui/components/smartsheet/toolbar/MoreActions.vue

@ -166,7 +166,7 @@ const exportFile = async (exportType: ExportTypes) => {
</template>
</a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-only="true" />
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" />
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

2
packages/nc-gui/components/smartsheet/toolbar/ViewActions.vue

@ -228,7 +228,7 @@ const { isSqlView } = useSmartsheetStoreOrThrow()
</template>
</a-dropdown>
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-only="true" />
<LazyDlgQuickImport v-if="quickImportDialog" v-model="quickImportDialog" import-type="csv" :import-data-only="true" />
<LazyWebhookDrawer v-if="showWebhookDrawer" v-model="showWebhookDrawer" />

398
packages/nc-gui/components/template/Editor.vue

@ -14,22 +14,23 @@ import {
extractSdkResponseErrorMsg,
fieldRequiredValidator,
getDateFormat,
getDateTimeFormat,
getUIDTIcon,
inject,
message,
nextTick,
onMounted,
parseStringDate,
reactive,
ref,
useI18n,
useNuxtApp,
useProject,
useTabs,
useTemplateRefsList,
} from '#imports'
import { TabType } from '~/lib'
const { quickImportType, projectTemplate, importData, importColumns, importOnly, maxRowsToParse } = defineProps<Props>()
const { quickImportType, projectTemplate, importData, importColumns, importDataOnly, maxRowsToParse } = defineProps<Props>()
const emit = defineEmits(['import'])
@ -42,7 +43,7 @@ interface Props {
projectTemplate: Record<string, any>
importData: Record<string, any>
importColumns: any[]
importOnly: boolean
importDataOnly: boolean
maxRowsToParse: number
}
@ -71,11 +72,11 @@ const expansionPanel = ref<number[]>([])
const editableTn = ref<boolean[]>([])
const inputRefs = useTemplateRefsList<HTMLInputElement>()
const inputRefs = ref<HTMLInputElement[]>([])
const isImporting = ref(false)
const importingTip = ref('Importing')
const importingTips = ref<Record<string, string>>({})
const uiTypeOptions = ref<Option[]>(
(Object.keys(UITypes) as (keyof typeof UITypes)[])
@ -92,12 +93,12 @@ const uiTypeOptions = ref<Option[]>(
})),
)
const srcDestMapping = ref<Record<string, any>[]>([])
const srcDestMapping = ref<Record<string, Record<string, any>[]>>({})
const data = reactive<{
title: string | null
name: string
tables: (TableType & { ref_table_name: string; columns: (ColumnType & { _disableSelect?: boolean })[] })[]
tables: (TableType & { ref_table_name: string; columns: (ColumnType & { key: number; _disableSelect?: boolean })[] })[]
}>({
title: null,
name: 'Project Name',
@ -106,7 +107,7 @@ const data = reactive<{
const validators = computed(() =>
data.tables.reduce<Record<string, [ReturnType<typeof fieldRequiredValidator>]>>((acc, table, tableIdx) => {
acc[`tables.${tableIdx}.ref_table_name`] = [fieldRequiredValidator()]
acc[`tables.${tableIdx}.table_name`] = [fieldRequiredValidator()]
hasSelectColumn.value[tableIdx] = false
table.columns?.forEach((column, columnIdx) => {
@ -123,24 +124,36 @@ const validators = computed(() =>
const { validate, validateInfos } = useForm(data, validators)
const isValid = computed(() => {
if (importOnly) {
for (const record of srcDestMapping.value) {
if (!fieldsValidation(record)) {
return false
const isValid = ref(!importDataOnly)
watch(
() => srcDestMapping.value,
() => {
let res = true
if (importDataOnly) {
for (const tn of Object.keys(srcDestMapping.value)) {
if (!atLeastOneEnabledValidation(tn)) {
res = false
}
for (const record of srcDestMapping.value[tn]) {
if (!fieldsValidation(record, tn)) {
return false
}
}
}
}
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
return false
} else {
for (const [_, o] of Object.entries(validateInfos)) {
if (o?.validateStatus) {
if (o.validateStatus === 'error') {
res = false
}
}
}
}
}
return true
})
isValid.value = res
},
{ deep: true },
)
const prevEditableTn = ref<string[]>([])
@ -150,7 +163,11 @@ onMounted(() => {
// used to record the previous EditableTn values
// for checking the table duplication in current import
// and updating the key in importData
prevEditableTn.value = data.tables.map((t) => t.ref_table_name)
prevEditableTn.value = data.tables.map((t) => t.table_name)
if (importDataOnly) {
mapDefaultColumns()
}
nextTick(() => {
inputRefs.value[0]?.focus()
@ -183,7 +200,7 @@ function parseTemplate({ tables = [], ...rest }: Props['projectTemplate']) {
}),
...v.map((v: any) => ({
column_name: v.title,
ref_table_name: {
table_name: {
...v,
},
})),
@ -206,16 +223,15 @@ function deleteTableColumn(tableIdx: number, columnIdx: number) {
data.tables[tableIdx].columns?.splice(columnIdx, 1)
}
function addNewColumnRow(table: Record<string, any>, uidt?: string) {
table.columns.push({
key: table.columns.length,
column_name: `title${table.columns.length + 1}`,
function addNewColumnRow(tableIdx: number, uidt: string) {
data.tables[tableIdx].columns.push({
key: data.tables[tableIdx].columns.length,
column_name: `title${data.tables[tableIdx].columns.length + 1}`,
uidt,
})
nextTick(() => {
const input = inputRefs.value[table.columns.length - 1]
const input = inputRefs.value[data.tables[tableIdx].columns.length - 1]
input.focus()
input.select()
})
@ -229,7 +245,12 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
const dateFormatMap: Record<number, string> = {}
return batchData.map((data) =>
(columns || []).reduce((aggObj, col: Record<string, any>) => {
let d = data[col.ref_column_name || col.column_name]
// for excel & json, if the column name is changed in TemplateEditor,
// then only col.column_name exists in data, else col.ref_column_name
// for csv, col.column_name always exists in data
// since it streams the data in getData() with the updated col.column_name
const key = col.column_name in data ? col.column_name : col.ref_column_name
let d = data[key]
if (col.uidt === UITypes.Date && d) {
let dateFormat
if (col?.meta?.date_format) {
@ -241,12 +262,10 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
dateFormat = getDateFormat(d)
dateFormatMap[col.key] = dateFormat
}
d = dayjs(d).utc().format(dateFormat)
d = parseStringDate(d, dateFormat)
} else if (col.uidt === UITypes.DateTime && d) {
// TODO: handle more formats for DateTime
d = dayjs(data[col.ref_column_name || col.column_name])
.utc()
.format('YYYY-MM-DD HH:mm')
const dateTimeFormat = getDateTimeFormat(data[key])
d = dayjs(data[key], dateTimeFormat).format('YYYY-MM-DD HH:mm')
}
return {
...aggObj,
@ -256,10 +275,11 @@ function remapColNames(batchData: any[], columns: ColumnType[]) {
)
}
function missingRequiredColumnsValidation() {
function missingRequiredColumnsValidation(tn: string) {
const missingRequiredColumns = columns.value.filter(
(c: Record<string, any>) =>
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) && !srcDestMapping.value.some((r) => r.destCn === c.title),
(c.pk ? !c.ai && !c.cdf : !c.cdf && c.rqd) &&
!srcDestMapping.value[tn].some((r: Record<string, any>) => r.destCn === c.title),
)
if (missingRequiredColumns.length) {
@ -270,153 +290,156 @@ function missingRequiredColumnsValidation() {
return true
}
function atLeastOneEnabledValidation() {
if (srcDestMapping.value.filter((v) => v.enabled === true).length === 0) {
function atLeastOneEnabledValidation(tn: string) {
if (srcDestMapping.value[tn].filter((v: Record<string, any>) => v.enabled === true).length === 0) {
message.error(t('msg.error.selectAtleastOneColumn'))
return false
}
return true
}
function fieldsValidation(record: Record<string, any>) {
function fieldsValidation(record: Record<string, any>, tn: string) {
// if it is not selected, then pass validation
if (!record.enabled) {
return true
}
const tableName = meta.value?.title || ''
if (!record.destCn) {
message.error(`${t('msg.error.columnDescriptionNotFound')} ${record.srcCn}`)
return false
}
if (srcDestMapping.value.filter((v) => v.destCn === record.destCn).length > 1) {
if (srcDestMapping.value[tn].filter((v: Record<string, any>) => v.destCn === record.destCn).length > 1) {
message.error(t('msg.error.duplicateMappingFound'))
return false
}
const v = columns.value.find((c) => c.title === record.destCn) as Record<string, any>
// check if the input contains null value for a required column
if (v.pk ? !v.ai && !v.cdf : !v.cdf && v.rqd) {
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some((r: Record<string, any>) => r[record.srcCn] === null || r[record.srcCn] === undefined || r[record.srcCn] === '')
) {
message.error(t('msg.error.nullValueViolatesNotNull'))
}
}
switch (v.uidt) {
case UITypes.Number:
for (const tableName of Object.keys(importData)) {
// check if the input contains null value for a required column
if (v.pk ? !v.ai && !v.cdf : !v.cdf && v.rqd) {
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some(
(r: Record<string, any>) => r[record.sourceCn] !== null && r[record.srcCn] !== undefined && isNaN(+r[record.srcCn]),
)
.some((r: Record<string, any>) => r[record.srcCn] === null || r[record.srcCn] === undefined || r[record.srcCn] === '')
) {
message.error(t('msg.error.sourceHasInvalidNumbers'))
return false
message.error(t('msg.error.nullValueViolatesNotNull'))
}
}
break
case UITypes.Checkbox:
if (
importData[tableName].slice(0, maxRowsToParse).some((r: Record<string, any>) => {
if (r[record.srcCn] !== null && r[record.srcCn] !== undefined) {
let input = r[record.srcCn]
if (typeof input === 'string') {
input = input.replace(/["']/g, '').toLowerCase().trim()
return !(
input === 'false' ||
input === 'no' ||
input === 'n' ||
input === '0' ||
input === 'true' ||
input === 'yes' ||
input === 'y' ||
input === '1'
)
}
switch (v.uidt) {
case UITypes.Number:
if (
importData[tableName]
.slice(0, maxRowsToParse)
.some(
(r: Record<string, any>) => r[record.sourceCn] !== null && r[record.srcCn] !== undefined && isNaN(+r[record.srcCn]),
)
) {
message.error(t('msg.error.sourceHasInvalidNumbers'))
return false
}
return input !== 1 && input !== 0 && input !== true && input !== false
}
break
case UITypes.Checkbox:
if (
importData[tableName].slice(0, maxRowsToParse).some((r: Record<string, any>) => {
if (r[record.srcCn] !== null && r[record.srcCn] !== undefined) {
let input = r[record.srcCn]
if (typeof input === 'string') {
input = input.replace(/["']/g, '').toLowerCase().trim()
return !(
input === 'false' ||
input === 'no' ||
input === 'n' ||
input === '0' ||
input === 'true' ||
input === 'yes' ||
input === 'y' ||
input === '1'
)
}
return input !== 1 && input !== 0 && input !== true && input !== false
}
return false
})
) {
message.error(t('msg.error.sourceHasInvalidBoolean'))
return false
})
) {
message.error(t('msg.error.sourceHasInvalidBoolean'))
return false
}
break
}
break
}
}
return true
}
function updateImportTips(projectName: string, tableName: string, progress: number, total: number) {
importingTips.value[
`${projectName}-${tableName}`
] = `Importing data to ${projectName} - ${tableName}: ${progress}/${total} records`
}
async function importTemplate() {
if (importOnly) {
// validate required columns
if (!missingRequiredColumnsValidation()) return
if (importDataOnly) {
for (const table of data.tables) {
// validate required columns
if (!missingRequiredColumnsValidation(table.table_name)) return
// validate at least one column needs to be selected
if (!atLeastOneEnabledValidation()) return
// validate at least one column needs to be selected
if (!atLeastOneEnabledValidation(table.table_name)) return
}
try {
isImporting.value = true
const tableName = meta.value?.title
// only one file is allowed currently
const data = importData[Object.keys(importData)[0]]
const projectName = project.value.title!
const total = data.length
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
srcDestMapping.value.reduce((res: Record<string, any>, col: Record<string, any>) => {
if (col.enabled && col.destCn) {
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
let input = row[col.srcCn]
// parse potential boolean values
if (v.uidt === UITypes.Checkbox) {
input = input.replace(/["']/g, '').toLowerCase().trim()
if (input === 'false' || input === 'no' || input === 'n') {
input = '0'
} else if (input === 'true' || input === 'yes' || input === 'y') {
input = '1'
}
} else if (v.uidt === UITypes.Number) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.SingleSelect || v.uidt === UITypes.MultiSelect) {
if (input === '') {
input = null
}
}
res[col.destCn] = input
await Promise.all(
Object.keys(importData).map((key: string) =>
(async (k) => {
const data = importData[k]
const total = data.length
for (let i = 0, progress = 0; i < total; i += maxRowsToParse) {
const batchData = data.slice(i, i + maxRowsToParse).map((row: Record<string, any>) =>
srcDestMapping.value[k].reduce((res: Record<string, any>, col: Record<string, any>) => {
if (col.enabled && col.destCn) {
const v = columns.value.find((c: Record<string, any>) => c.title === col.destCn) as Record<string, any>
let input = row[col.srcCn]
// parse potential boolean values
if (v.uidt === UITypes.Checkbox) {
input = input.replace(/["']/g, '').toLowerCase().trim()
if (input === 'false' || input === 'no' || input === 'n') {
input = '0'
} else if (input === 'true' || input === 'yes' || input === 'y') {
input = '1'
}
} else if (v.uidt === UITypes.Number) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.SingleSelect || v.uidt === UITypes.MultiSelect) {
if (input === '') {
input = null
}
} else if (v.uidt === UITypes.Date) {
input = parseStringDate(input, v.meta.date_format)
}
res[col.destCn] = input
}
return res
}, {}),
)
await $api.dbTableRow.bulkCreate('noco', projectName, tableName!, batchData)
updateImportTips(projectName, tableName!, progress, total)
progress += batchData.length
}
return res
}, {}),
)
await $api.dbTableRow.bulkCreate('noco', projectName, tableName!, batchData)
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
progress += batchData.length
}
})(key),
),
)
// reload table
reloadHook.trigger()
@ -473,8 +496,8 @@ async function importTemplate() {
}
const tableMeta = await $api.dbTable.create(project?.value?.id as string, {
table_name: table.ref_table_name,
// leave title empty to get a generated one based on ref_table_name
table_name: table.table_name,
// leave title empty to get a generated one based on table_name
title: '',
columns: table.columns || [],
})
@ -493,22 +516,25 @@ async function importTemplate() {
}
// bulk insert data
if (importData) {
let total = 0
let progress = 0
const offset = maxRowsToParse
const projectName = project.value.title as string
await Promise.all(
data.tables.map((table: Record<string, any>) =>
(async (tableMeta) => {
let progress = 0
let total = 0
// use ref_table_name here instead of table_name
// since importData[talbeMeta.table_name] would be empty after renaming
const data = importData[tableMeta.ref_table_name]
if (data) {
total += data.length
for (let i = 0; i < data.length; i += offset) {
importingTip.value = `Importing data to ${projectName}: ${progress}/${total} records`
updateImportTips(projectName, tableMeta.title, progress, total)
const batchData = remapColNames(data.slice(i, i + offset), tableMeta.columns)
await $api.dbTableRow.bulkCreate('noco', projectName, tableMeta.title, batchData)
progress += batchData.length
}
updateImportTips(projectName, tableMeta.title, total, total)
}
})(table),
),
@ -530,18 +556,23 @@ async function importTemplate() {
}
function mapDefaultColumns() {
srcDestMapping.value = []
for (const col of importColumns[0]) {
const o = { srcCn: col.column_name, destCn: '', enabled: true }
if (columns.value) {
const tableColumn = columns.value.find((c: Record<string, any>) => c.title === col.column_name)
if (tableColumn) {
o.destCn = tableColumn.title as string
} else {
o.enabled = false
srcDestMapping.value = {}
for (let i = 0; i < data.tables.length; i++) {
for (const col of importColumns[i]) {
const o = { srcCn: col.column_name, destCn: '', enabled: true }
if (columns.value) {
const tableColumn = columns.value.find((c) => c.column_name === col.column_name)
if (tableColumn) {
o.destCn = tableColumn.title as string
} else {
o.enabled = false
}
}
if (!(data.tables[i].table_name in srcDestMapping.value)) {
srcDestMapping.value[data.tables[i].table_name] = []
}
srcDestMapping.value[data.tables[i].table_name].push(o)
}
srcDestMapping.value.push(o)
}
}
@ -550,24 +581,14 @@ defineExpose({
isValid,
})
onMounted(() => {
if (importOnly) {
mapDefaultColumns()
}
})
function handleEditableTnChange(idx: number) {
const oldValue = prevEditableTn.value[idx]
const newValue = data.tables[idx].ref_table_name
if (data.tables.filter((t) => t.ref_table_name === newValue).length > 1) {
const newValue = data.tables[idx].table_name
if (data.tables.filter((t) => t.table_name === newValue).length > 1) {
message.warn('Duplicate Table Name')
data.tables[idx].ref_table_name = oldValue
data.tables[idx].table_name = oldValue
} else {
prevEditableTn.value[idx] = newValue
if (oldValue !== newValue) {
// update the key name of importData
delete Object.assign(importData, { [newValue]: importData[oldValue] })[oldValue]
}
}
setEditableTn(idx, false)
}
@ -578,8 +599,13 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
</script>
<template>
<a-spin :spinning="isImporting" :tip="importingTip" size="large">
<a-card v-if="importOnly">
<a-spin :spinning="isImporting" size="large">
<template #tip>
<p v-for="(importingTip, idx) of importingTips" :key="idx" class="mt-[10px]">
{{ importingTip }}
</p>
</template>
<a-card v-if="importDataOnly">
<a-form :model="data" name="import-only">
<p v-if="data.tables && quickImportType === 'excel'" class="text-center">
{{ data.tables.length }} sheet{{ data.tables.length > 1 ? 's' : '' }}
@ -592,8 +618,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<template #header>
<span class="font-weight-bold text-lg flex items-center gap-2">
<mdi-table class="text-primary" />
{{ table.ref_table_name }}
{{ table.table_name }}
</span>
</template>
@ -601,7 +626,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
v-if="srcDestMapping"
class="template-form"
row-class-name="template-form-row"
:data-source="srcDestMapping"
:data-source="srcDestMapping[table.table_name]"
:columns="srcDestMappingColumns"
:pagination="false"
>
@ -660,21 +685,21 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
>
<a-collapse-panel v-for="(table, tableIdx) of data.tables" :key="tableIdx">
<template #header>
<a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.ref_table_name`]" no-style>
<a-form-item v-if="editableTn[tableIdx]" v-bind="validateInfos[`tables.${tableIdx}.table_name`]" no-style>
<a-input
v-model:value="table.ref_table_name"
class="max-w-xs"
v-model:value="table.table_name"
class="max-w-xs font-weight-bold text-lg"
size="large"
hide-details
:bordered="false"
@click="$event.stopPropagation()"
@blur="handleEditableTnChange(tableIdx)"
@keydown.enter="handleEditableTnChange(tableIdx)"
/>
</a-form-item>
<span v-else class="font-weight-bold text-lg flex items-center gap-2" @click="setEditableTn(tableIdx, true)">
<mdi-table class="text-primary" />
{{ table.ref_table_name }}
{{ table.table_name }}
</span>
</template>
@ -723,7 +748,14 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'column_name'">
<a-form-item v-bind="validateInfos[`tables.${tableIdx}.columns.${record.key}.${column.key}`]">
<a-input :ref="inputRefs.set" v-model:value="record.column_name" />
<a-input
:ref="
(el) => {
inputRefs[record.key] = el
}
"
v-model:value="record.column_name"
/>
</a-form-item>
</template>
@ -794,7 +826,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add Number Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'Number')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'Number')">
<div class="flex items-center">
<mdi-numeric class="group-hover:!text-accent flex text-lg" />
</div>
@ -807,7 +839,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add SingleLineText Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center">
<mdi-alpha-a class="group-hover:!text-accent text-lg" />
</div>
@ -820,7 +852,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add LongText Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'LongText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'LongText')">
<div class="flex items-center">
<mdi-text class="group-hover:!text-accent text-lg" />
</div>
@ -833,7 +865,7 @@ function isSelectDisabled(uidt: string, disableSelect = false) {
<span>Add Other Column</span>
</template>
<a-button class="group" @click="addNewColumnRow(table, 'SingleLineText')">
<a-button class="group" @click="addNewColumnRow(tableIdx, 'SingleLineText')">
<div class="flex items-center gap-1">
<mdi-plus class="group-hover:!text-accent text-lg" />
</div>

5
packages/nc-gui/lib/types.ts

@ -1,6 +1,7 @@
import type { FilterType, ViewTypes } from 'nocodb-sdk'
import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue'
import type { ProjectRole, Role, TabType } from './enums'
import type { rolePermissions } from './constants'
@ -94,3 +95,7 @@ export interface SharedView {
type?: ViewTypes
meta: SharedViewMeta
}
export type importFileList = (UploadFile & { data: string | ArrayBuffer })[]
export type streamImportFileList = UploadFile[]

34
packages/nc-gui/utils/dateTimeUtils.ts

@ -36,11 +36,17 @@ export function validateDateFormat(v: string) {
}
export function validateDateWithUnknownFormat(v: string) {
let res = 0
for (const format of dateFormats) {
res |= dayjs(v, format, true).isValid() as any
if (dayjs(v, format, true).isValid() as any) {
return true
}
for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) {
if (dayjs(v, `${format} ${timeFormat}`, true).isValid() as any) {
return true
}
}
}
return res
return false
}
export function getDateFormat(v: string) {
@ -51,3 +57,25 @@ export function getDateFormat(v: string) {
}
return 'YYYY/MM/DD'
}
export function getDateTimeFormat(v: string) {
for (const format of dateFormats) {
for (const timeFormat of ['HH:mm', 'HH:mm:ss', 'HH:mm:ss.SSS']) {
const dateTimeFormat = `${format} ${timeFormat}`
if (dayjs(v, dateTimeFormat, true).isValid() as any) {
return dateTimeFormat
}
}
}
return 'YYYY/MM/DD'
}
export function parseStringDate(v: string, dateFormat: string) {
const dayjsObj = dayjs(v)
if (dayjsObj.isValid()) {
v = dayjsObj.format('YYYY-MM-DD')
} else {
v = dayjs(v, dateFormat).format('YYYY-MM-DD')
}
return v
}

331
packages/nc-gui/utils/parsers/CSVTemplateAdapter.ts

@ -1,42 +1,323 @@
import { parse } 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
import type { UploadFile } from 'ant-design-vue'
import { UITypes } from 'nocodb-sdk'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
getDateFormat,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
validateDateWithUnknownFormat,
} from '#imports'
export default class CSVTemplateAdapter {
config: Record<string, any>
source: UploadFile[] | string
detectedColumnTypes: Record<number, Record<string, number>>
distinctValues: Record<number, Set<string>>
headers: Record<number, string[]>
tables: Record<number, any>
project: {
tables: Record<string, any>[]
}
data: Record<string, any> = {}
columnValues: Record<number, []>
constructor(source: UploadFile[] | string, parserConfig = {}) {
this.config = parserConfig
this.source = source
this.project = {
title: this.fileName,
tables: [],
}
this.data = {}
this.csv = {}
this.columns = {}
this.csvData = {}
this.detectedColumnTypes = {}
this.distinctValues = {}
this.headers = {}
this.columnValues = {}
this.tables = {}
}
async init() {
this.csv = parse(this.csvData, { header: true })
async init() {}
initTemplate(tableIdx: number, tn: string, columnNames: string[]) {
const columnNameRowExist = +columnNames.every((v: any) => v === null || typeof v === 'string')
const columnNamePrefixRef: Record<string, any> = { id: 0 }
const tableObj: Record<string, any> = {
table_name: tn,
ref_table_name: tn,
columns: [],
}
this.headers[tableIdx] = []
this.tables[tableIdx] = []
for (const [columnIdx, columnName] of columnNames.entries()) {
let cn: string = ((columnNameRowExist && columnName.toString().trim()) || `field_${columnIdx + 1}`)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()
while (cn in columnNamePrefixRef) {
cn = `${cn}${++columnNamePrefixRef[cn]}`
}
columnNamePrefixRef[cn] = 0
this.detectedColumnTypes[columnIdx] = {}
this.distinctValues[columnIdx] = new Set<string>()
this.columnValues[columnIdx] = []
tableObj.columns.push({
column_name: cn,
ref_column_name: cn,
meta: {},
uidt: UITypes.SingleLineText,
key: columnIdx,
})
this.headers[tableIdx].push(cn)
this.tables[tableIdx] = tableObj
}
}
detectInitialUidt(v: string) {
if (!isNaN(Number(v)) && !isNaN(parseFloat(v))) return UITypes.Number
if (validateDateWithUnknownFormat(v)) return UITypes.DateTime
if (['true', 'True', 'false', 'False', '1', '0', 'T', 'F', 'Y', 'N'].includes(v)) return UITypes.Checkbox
return UITypes.SingleLineText
}
parseData() {
this.columns = this.csv.meta.fields
this.data = this.csv.data
detectColumnType(tableIdx: number, data: []) {
for (let columnIdx = 0; columnIdx < data.length; columnIdx++) {
// skip null data
if (!data[columnIdx]) continue
const colData: any = [data[columnIdx]]
const colProps = { uidt: this.detectInitialUidt(data[columnIdx]) }
// TODO(import): centralise
if (isMultiLineTextType(colData)) {
colProps.uidt = UITypes.LongText
} else if (colProps.uidt === UITypes.SingleLineText) {
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 {
if (data[columnIdx] && columnIdx < this.config.maxRowsToParse) {
this.columnValues[columnIdx].push(data[columnIdx])
colProps.uidt = UITypes.SingleSelect
}
}
}
} else if (colProps.uidt === UITypes.Number) {
if (isDecimalType(colData)) {
colProps.uidt = UITypes.Decimal
}
} else if (colProps.uidt === UITypes.DateTime) {
if (data[columnIdx] && columnIdx < this.config.maxRowsToParse) {
this.columnValues[columnIdx].push(data[columnIdx])
}
}
if (!(colProps.uidt in this.detectedColumnTypes[columnIdx])) {
this.detectedColumnTypes[columnIdx] = {
...this.detectedColumnTypes[columnIdx],
[colProps.uidt]: 0,
}
}
this.detectedColumnTypes[columnIdx][colProps.uidt] += 1
if (data[columnIdx]) {
this.distinctValues[columnIdx].add(data[columnIdx])
}
}
}
getPossibleUidt(columnIdx: number) {
const detectedColTypes = this.detectedColumnTypes[columnIdx]
const len = Object.keys(detectedColTypes).length
// all records are null
if (len === 0) {
return UITypes.SingleLineText
}
// handle numeric case
if (len === 2 && UITypes.Number in detectedColTypes && UITypes.Decimal in detectedColTypes) {
if (detectedColTypes[UITypes.Number] > detectedColTypes[UITypes.Decimal]) {
return UITypes.Number
}
return UITypes.Decimal
}
// if there are multiple detected column types
// then return either LongText or SingleLineText
if (len > 1) {
if (UITypes.LongText in detectedColTypes) {
return UITypes.LongText
}
return UITypes.SingleLineText
}
// otherwise, all records have the same column type
return Object.keys(detectedColTypes)[0]
}
updateTemplate(tableIdx: number) {
for (let columnIdx = 0; columnIdx < this.headers[tableIdx].length; columnIdx++) {
const uidt = this.getPossibleUidt(columnIdx)
if (this.columnValues[columnIdx].length > 0) {
if (uidt === UITypes.DateTime) {
const dateFormat: Record<string, number> = {}
if (
this.columnValues[columnIdx].slice(1, this.config.maxRowsToParse).every((v: any) => {
const isDate = v.split(' ').length === 1
if (isDate) {
dateFormat[getDateFormat(v)] = (dateFormat[getDateFormat(v)] || 0) + 1
}
return isDate
})
) {
this.tables[tableIdx].columns[columnIdx].uidt = UITypes.Date
// take the date format with the max occurrence
this.tables[tableIdx].columns[columnIdx].meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
} else {
// Datetime
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
} else if (uidt === UITypes.SingleSelect || uidt === UITypes.MultiSelect) {
// assume it is a SingleLineText first
this.tables[tableIdx].columns[columnIdx].uidt = UITypes.SingleLineText
// override with UITypes.SingleSelect or UITypes.MultiSelect if applicable
Object.assign(this.tables[tableIdx].columns[columnIdx], extractMultiOrSingleSelectProps(this.columnValues[columnIdx]))
} else {
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
delete this.columnValues[columnIdx]
} else {
this.tables[tableIdx].columns[columnIdx].uidt = uidt
}
}
}
async _parseTableData(tableIdx: number, source: UploadFile | string, tn: string) {
return new Promise((resolve, reject) => {
const that = this
let steppers = 0
if (that.config.shouldImportData) {
steppers = 0
const parseSource = (this.config.importFromURL ? (source as string) : (source as UploadFile).originFileObj)!
parse(parseSource, {
download: that.config.importFromURL,
worker: true,
skipEmptyLines: 'greedy',
step(row) {
steppers += 1
if (row && steppers >= +that.config.firstRowAsHeaders + 1) {
const rowData: Record<string, any> = {}
for (let columnIdx = 0; columnIdx < that.headers[tableIdx].length; columnIdx++) {
const column = that.tables[tableIdx].columns[columnIdx]
const data = (row.data as [])[columnIdx] === '' ? null : (row.data as [])[columnIdx]
if (column.uidt === UITypes.Checkbox) {
rowData[column.column_name] = getCheckboxValue(data)
rowData[column.column_name] = data
} else if (column.uidt === UITypes.SingleSelect || column.uidt === UITypes.MultiSelect) {
rowData[column.column_name] = (data || '').toString().trim() || null
} else {
// TODO(import): do parsing if necessary based on type
rowData[column.column_name] = data
}
}
that.data[tn].push(rowData)
}
},
complete() {
resolve(true)
},
error(e: Error) {
reject(e)
},
})
}
})
}
async _parseTableMeta(tableIdx: number, source: UploadFile | string) {
return new Promise((resolve, reject) => {
const that = this
let steppers = 0
const tn = ((this.config.importFromURL ? (source as string).split('/').pop() : (source as UploadFile).name) as string)
.replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_')
.trim()!
this.data[tn] = []
const parseSource = (this.config.importFromURL ? (source as string) : (source as UploadFile).originFileObj)!
parse(parseSource, {
download: that.config.importFromURL,
worker: true,
skipEmptyLines: 'greedy',
step(row) {
steppers += 1
if (row) {
if (steppers === 1) {
if (that.config.firstRowAsHeaders) {
// row.data is header
that.initTemplate(tableIdx, tn, row.data as [])
} else {
// use dummy column names as header
that.initTemplate(
tableIdx,
tn,
[...Array((row.data as []).length)].map((_, i) => `field_${i + 1}`),
)
if (that.config.autoSelectFieldTypes) {
// row.data is data
that.detectColumnType(tableIdx, row.data as [])
}
}
} else {
if (that.config.autoSelectFieldTypes) {
// row.data is data
that.detectColumnType(tableIdx, row.data as [])
}
}
}
},
async complete() {
that.updateTemplate(tableIdx)
that.project.tables.push(that.tables[tableIdx])
await that._parseTableData(tableIdx, source, tn)
resolve(true)
},
error(e: Error) {
reject(e)
},
})
})
}
async parse() {
if (this.config.importFromURL) {
await this._parseTableMeta(0, this.source as string)
} else {
await Promise.all(
(this.source as UploadFile[]).map((file: UploadFile, tableIdx: number) =>
(async (f, idx) => {
await this._parseTableMeta(idx, f)
})(file, tableIdx),
),
)
}
}
getColumns() {
return this.columns
return this.project.tables.map((t: Record<string, any>) => t.columns)
}
getData() {
return this.data
}
getTemplate() {
return this.project
}
}

464
packages/nc-gui/utils/parsers/ExcelTemplateAdapter.ts

@ -1,7 +1,14 @@
import { UITypes } from 'nocodb-sdk'
import TemplateGenerator from './TemplateGenerator'
import { getCheckboxValue, isCheckboxType } from './parserHelpers'
import { getDateFormat } from '~/utils'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
getDateFormat,
isCheckboxType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from '#imports'
const excelTypeToUidt: Record<string, UITypes> = {
d: UITypes.DateTime,
@ -11,17 +18,12 @@ const excelTypeToUidt: Record<string, UITypes> = {
}
export default class ExcelTemplateAdapter extends TemplateGenerator {
config: {
maxRowsToParse: number
} & Record<string, any>
name: string
config: Record<string, any>
excelData: any
project: {
title: string
tables: any[]
tables: Record<string, any>[]
}
data: Record<string, any> = {}
@ -30,22 +32,13 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
xlsx: typeof import('xlsx')
constructor(name = '', data = {}, parserConfig = {}) {
constructor(data = {}, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.config = parserConfig
this.excelData = data
this.project = {
title: this.name,
tables: [],
}
this.xlsx = {} as any
}
@ -57,236 +50,229 @@ export default class ExcelTemplateAdapter extends TemplateGenerator {
cellDates: true,
}
if (this.name.slice(-3) === 'csv') {
this.wb = this.xlsx.read(new TextDecoder().decode(new Uint8Array(this.excelData)), {
type: 'string',
...options,
})
} else {
this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
this.wb = this.xlsx.read(new Uint8Array(this.excelData), {
type: 'array',
...options,
})
}
parse() {
async parse() {
const tableNamePrefixRef: Record<string, any> = {}
// TODO: find the upper bound / make it configurable
const maxSelectOptionsAllowed = 64
for (let i = 0; i < this.wb.SheetNames.length; i++) {
const columnNamePrefixRef: Record<string, 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 = { table_name: tn, ref_table_name: tn, columns: [] as any[] }
this.data[tn] = []
const ws: any = this.wb.Sheets[sheet]
const range = this.xlsx.utils.decode_range(ws['!ref'])
let rows: any = this.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 = this.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,
meta: {},
}
// const cellId = `${col.toString(26).split('').map(s => (parseInt(s, 26) + 10).toString(36).toUpperCase())}2`;
const cellId = this.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 {
if (vals.some((v: any) => v && v.toString().includes(','))) {
const flattenedVals = vals.flatMap((v: any) =>
v
? v
.toString()
.trim()
.split(/\s*,\s*/)
: [],
)
// TODO: handle case sensitive case
const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
column.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
column._disableSelect = true
} else {
// assume the column type is multiple select if there are repeated values
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
column.uidt = UITypes.MultiSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to MultiSelect
// once it's set, dtxp needs to be reset if the final column type is not MultiSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
} else {
// TODO: handle case sensitive case
const uniqueVals = [...new Set(vals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
column.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
column._disableSelect = true
} else {
// assume the column type is single select if there are repeated values
// once it's set, dtxp needs to be reset if the final column type is not Single Select
if (vals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(vals.length / 2)) {
column.uidt = UITypes.SingleSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to SingleSelect
// once it's set, dtxp needs to be reset if the final column type is not SingleSelect
column.dtxp = `'${uniqueVals.join("','")}'`
}
}
await Promise.all(
this.wb.SheetNames.map((sheetName: string) =>
(async (sheet) => {
await new Promise((resolve) => {
const columnNamePrefixRef: Record<string, any> = { id: 0 }
let tn: string = (sheet || 'table').replace(/[` ~!@#$%^&*()_|+\-=?;:'",.<>\{\}\[\]\\\/]/g, '_').trim()
while (tn in tableNamePrefixRef) {
tn = `${tn}${++tableNamePrefixRef[tn]}`
}
}
} 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]
tableNamePrefixRef[tn] = 0
const table = { table_name: tn, ref_table_name: tn, columns: [] as any[] }
const ws: any = this.wb.Sheets[sheet]
const range = this.xlsx.utils.decode_range(ws['!ref'])
let rows: any = this.xlsx.utils.sheet_to_json(ws, {
// header has to be 1 disregarding this.config.firstRowAsHeaders
// so that it generates an array of arrays
header: 1,
blankrows: false,
defval: null,
})
) {
column.uidt = UITypes.Decimal
}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
// 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 = this.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)
}
const cellObj = ws[cellId]
// fix imported date
rows = rows.map((r: any) =>
r.map((v: any) => {
return v instanceof Date ? fixImportedDate(v) : v
}),
)
for (let col = 0; col < rows[0].length; col++) {
let cn: string = (
(this.config.firstRowAsHeaders && 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
return !cellObj || (cellObj.w && cellObj.w.startsWith('$'))
})
) {
column.uidt = UITypes.Currency
}
} else if (column.uidt === UITypes.DateTime) {
// hold the possible date format found in the date
const dateFormat: Record<string, number> = {}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + columnNameRowExist,
})
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
meta: {},
uidt: UITypes.SingleLineText,
}
const cellObj = ws[cellId]
const isDate = !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1)
if (isDate && cellObj) {
dateFormat[getDateFormat(cellObj.w)] = (dateFormat[getDateFormat(cellObj.w)] || 0) + 1
if (this.config.autoSelectFieldTypes) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: +this.config.firstRowAsHeaders,
})
const cellProps = ws[cellId] || {}
column.uidt = excelTypeToUidt[cellProps.t] || UITypes.SingleLineText
if (column.uidt === UITypes.SingleLineText) {
// check for long text
if (isMultiLineTextType(rows)) {
column.uidt = UITypes.LongText
}
if (isEmailType(rows)) {
column.uidt = UITypes.Email
}
if (isUrlType(rows)) {
column.uidt = UITypes.URL
} else {
const vals = rows
.slice(+this.config.firstRowAsHeaders)
.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 {
// Single Select / Multi Select
Object.assign(column, extractMultiOrSingleSelectProps(vals))
}
}
} else if (column.uidt === UITypes.Number) {
if (
rows.slice(1, this.config.maxRowsToParse).every((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 = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && cellObj.w.startsWith('$'))
})
) {
column.uidt = UITypes.Currency
}
if (
rows.slice(1, this.config.maxRowsToParse).some((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
return !cellObj || (cellObj.w && !(!isNaN(Number(cellObj.w)) && !isNaN(parseFloat(cellObj.w))))
})
) {
// fallback to SingleLineText
column.uidt = UITypes.SingleLineText
}
} else if (column.uidt === UITypes.DateTime) {
// TODO(import): centralise
// hold the possible date format found in the date
const dateFormat: Record<string, number> = {}
if (
rows.slice(1, this.config.maxRowsToParse).every((v: any, i: any) => {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + col,
r: i + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
const isDate = !cellObj || (cellObj.w && cellObj.w.split(' ').length === 1)
if (isDate && cellObj) {
dateFormat[getDateFormat(cellObj.w)] = (dateFormat[getDateFormat(cellObj.w)] || 0) + 1
}
return isDate
})
) {
column.uidt = UITypes.Date
// take the date format with the max occurrence
column.meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
}
}
}
return isDate
})
) {
column.uidt = UITypes.Date
// take the date format with the max occurrence
column.meta.date_format =
Object.keys(dateFormat).reduce((x, y) => (dateFormat[x] > dateFormat[y] ? x : y)) || 'YYYY/MM/DD'
}
}
table.columns.push(column)
}
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 = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + columnNameRowExist,
})
table.columns.push(column)
}
this.project.tables.push(table)
this.data[tn] = []
if (this.config.shouldImportData) {
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 (!this.config.autoSelectFieldTypes) {
// take raw data instead of data parsed by xlsx
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else {
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 = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
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 if (table.columns[i].uidt === UITypes.Date) {
const cellId = this.xlsx.utils.encode_cell({
c: range.s.c + i,
r: rowIndex + +this.config.firstRowAsHeaders,
})
const cellObj = ws[cellId]
rowData[table.columns[i].column_name] = (cellObj && cellObj.w) || row[i]
} else {
// TODO: do parsing if necessary based on type
rowData[table.columns[i].column_name] = row[i]
}
}
}
this.data[tn].push(rowData)
rowIndex++
}
}
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 if (table.columns[i].uidt === UITypes.Date) {
const cellId = this.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) || row[i]
} 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)
}
resolve(true)
})
})(sheetName),
),
)
}
getTemplate() {

3
packages/nc-gui/utils/parsers/ExcelUrlTemplateAdapter.ts

@ -8,8 +8,7 @@ export default class ExcelUrlTemplateAdapter extends ExcelTemplateAdapter {
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url?.split('/').pop()
super(name, parserConfig)
super({}, parserConfig)
this.url = url
this.excelData = null
this.$api = $api

68
packages/nc-gui/utils/parsers/JSONTemplateAdapter.ts

@ -1,13 +1,5 @@
import { UITypes } from 'nocodb-sdk'
import {
extractMultiOrSingleSelectProps,
getCheckboxValue,
isCheckboxType,
isDecimalType,
isEmailType,
isMultiLineTextType,
isUrlType,
} from './parserHelpers'
import { getCheckboxValue, getColumnUIDTAndMetas } from './parserHelpers'
import TemplateGenerator from './TemplateGenerator'
const jsonTypeToUidt: Record<string, string> = {
@ -22,22 +14,19 @@ const extractNestedData: any = (obj: any, path: any) => path.reduce((val: any, k
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>
project: {
tables: Record<string, any>[]
}
columns: object
constructor(name = 'test', data: object, parserConfig = {}) {
constructor(data: object, parserConfig = {}) {
super()
this.config = {
maxRowsToParse: 500,
...parserConfig,
}
this.name = name
this.config = parserConfig
this._jsonData = data
this.project = {
title: this.name,
tables: [],
}
this.jsonData = []
@ -75,7 +64,7 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
table.columns.push(...columns)
}
if (this.config.importData) {
if (this.config.shouldImportData) {
this._parseTableData(table)
}
@ -100,52 +89,23 @@ export default class JSONTemplateAdapter extends TemplateGenerator {
}
} else {
const cn = path.join('_').replace(/\W/g, '_').trim()
const column: Record<string, any> = {
column_name: cn,
ref_column_name: cn,
uidt: UITypes.SingleLineText,
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))
if (this.config.autoSelectFieldTypes) {
column.uidt = jsonTypeToUidt[typeof firstRowVal] || UITypes.SingleLineText
const colData = jsonData.map((r: any) => extractNestedData(r, path))
Object.assign(column, 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 = {}

3
packages/nc-gui/utils/parsers/JSONUrlTemplateAdapter.ts

@ -7,8 +7,7 @@ export default class JSONUrlTemplateAdapter extends JSONTemplateAdapter {
constructor(url: string, parserConfig: Record<string, any>) {
const { $api } = useNuxtApp()
const name = url.split('/').pop()
super(name, parserConfig)
super({}, parserConfig)
this.url = url
this.$api = $api
}

92
packages/nc-gui/utils/parsers/parserHelpers.ts

@ -40,7 +40,8 @@ export const isCheckboxType: any = (values: [], col = null) => {
}
return options
}
export const getCheckboxValue = (value: number) => {
export const getCheckboxValue = (value: any) => {
return value && aggBooleanOptions[value]
}
@ -51,9 +52,10 @@ export const isMultiLineTextType = (values: [], col = null) => {
}
export const extractMultiOrSingleSelectProps = (colData: []) => {
const maxSelectOptionsAllowed = 64
const colProps: any = {}
if (colData.some((v: any) => v && (v || '').toString().includes(','))) {
let flattenedVals = colData.flatMap((v: any) =>
const flattenedVals = colData.flatMap((v: any) =>
v
? v
.toString()
@ -61,23 +63,43 @@ export const extractMultiOrSingleSelectProps = (colData: []) => {
.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("','")}'`
const uniqueVals = [...new Set(flattenedVals.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is multiple select if there are repeated values
if (flattenedVals.length > uniqueVals.length && uniqueVals.length <= Math.ceil(flattenedVals.length / 2)) {
colProps.uidt = UITypes.MultiSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to MultiSelect
// once it's set, dtxp needs to be reset if the final column type is not MultiSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).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("','")}'`
const uniqueVals = [...new Set(colData.map((v: any) => v.toString().trim().toLowerCase()))]
if (uniqueVals.length > maxSelectOptionsAllowed) {
// too many options are detected, convert the column to SingleLineText instead
colProps.uidt = UITypes.SingleLineText
// _disableSelect is used to disable the <a-select-option/> in TemplateEditor
colProps._disableSelect = true
} else {
// assume the column type is single select if there are repeated values
// once it's set, dtxp needs to be reset if the final column type is not Single Select
if (colData.length > uniqueVals.length && uniqueVals.length <= Math.ceil(colData.length / 2)) {
colProps.uidt = UITypes.SingleSelect
}
// set dtxp here so that users can have the options even they switch the type from other types to SingleSelect
// once it's set, dtxp needs to be reset if the final column type is not SingleSelect
colProps.dtxp = `${uniqueVals.map((v) => `'${v.replace(/'/gi, "''")}'`).join(',')}`
}
return colProps
}
return colProps
}
export const isDecimalType = (colData: []) =>
@ -86,10 +108,42 @@ export const isDecimalType = (colData: []) =>
})
export const isEmailType = (colData: []) =>
!colData.some((v: any) => {
return v && !isEmail(v)
colData.some((v: any) => {
return v && isEmail(v)
})
export const isUrlType = (colData: []) =>
!colData.some((v: any) => {
return v && !isValidURL(v)
colData.some((v: any) => {
return v && isValidURL(v)
})
export const getColumnUIDTAndMetas = (colData: [], defaultType: string) => {
const colProps = { uidt: defaultType }
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
}
}
// TODO(import): currency
// TODO(import): date / datetime
return colProps
}

26
packages/noco-docs/content/en/setup-and-usages/table-operations.md

@ -140,27 +140,31 @@ You can use Quick Import when you have data from external sources such as Airtab
<img width="1505" alt="image" src="https://user-images.githubusercontent.com/35857179/194795025-afd81191-4743-435b-b802-88367d2663f9.png">
### Import Airtable into an existing project
### Import Airtable into an Existing Project
- See <a href="./import-airtable-to-sql-database-within-a-minute-for-free">here</a>
### Import CSV data into an existing project
### Import CSV data into an Existing Project
- Hover `Add new table` button in table menu, click three dots, and click `CSV file`
- Drag & drop or select file to upload or specify CSV file URL
<img width="987" alt="image" src="https://user-images.githubusercontent.com/35857179/194795517-ee272b97-e2f6-4f3c-8558-810e1c0b7955.png">
- Click `Import`
<img width="975" alt="image" src="https://user-images.githubusercontent.com/35857179/194795574-cc95a6e0-053f-496f-8b6d-e1bc2a73c890.png">
- Drag & drop or select files (at most 5 files) to upload or specify CSV file URL, and Click Import
- **Auto-Select Field Types**: If it is checked, column types will be detected. Otherwise, it will default to `SingleLineText`.
- **Use First Row as Headers**: If it is checked, the first row will be treated as header row.
- **Import Data**: If it is checked, all data will be imported. Otherwise, only table will be created.
![image](https://user-images.githubusercontent.com/35857179/197454479-1ed18dce-1d0b-4ee3-88b3-9b6a132dea2a.png)
- You can revise the table name by double clicking it, column name and column type. By default, the first column will be chosen as <a href="./primary-value" target="_blank">Primary Value</a> and cannot be deleted.
<img width="984" alt="image" src="https://user-images.githubusercontent.com/35857179/194795594-25373144-436e-4b67-9e51-ad15d70f66fd.png">
![image](https://user-images.githubusercontent.com/35857179/197454633-5b30323e-2b13-4c55-843a-948c093d373e.png)
- Click `Import` to start importing process. The table will be created and the data will be imported.
<img width="1507" alt="image" src="https://user-images.githubusercontent.com/35857179/194795642-44f8b2a4-6ba7-474d-bdb6-99ee4c2b4fd1.png">
![image](https://user-images.githubusercontent.com/35857179/197455547-2d93df5e-a7f0-4c88-af53-990067625967.png)
### Import Excel data into an existing project
### Import Excel data into an Existing Project
- Hover `Add new table` button in table menu, click three dots, and click `Microsoft Excel`
- Drag & drop or select file to upload or specify Excel file URL
<img width="973" alt="image" src="https://user-images.githubusercontent.com/35857179/194795741-a2eb59ad-c95c-4c8c-9127-ab2072240439.png">
- Drag & drop or select file (at most 1 file) to upload or specify Excel file URL and Click Import.
- **Auto-Select Field Types**: If it is checked, column types will be detected. Otherwise, it will default to `SingleLineText`.
- **Use First Row as Headers**: If it is checked, the first row will be treated as header row.
- **Import Data**: If it is checked, all data will be imported. Otherwise, only table will be created.
![image](https://user-images.githubusercontent.com/35857179/197455788-8dd8a7d1-38f3-48c3-a05e-6ab0cf25045c.png)
- You can revise the table name, column name and column type. By default, the first column will be chosen as <a href="./primary-value" target="_blank">Primary Value</a> and cannot be deleted.
<alert>
Note: If your Excel file contains multiple sheets, each sheet will be stored in a separate table.

Loading…
Cancel
Save