mirror of https://github.com/nocodb/nocodb
Pranav C
2 years ago
6 changed files with 650 additions and 3 deletions
@ -0,0 +1,436 @@
|
||||
<template> |
||||
<div :class="{'pt-10':!hideLabel}"> |
||||
<v-dialog v-model="dropOrUpload" max-width="600"> |
||||
<v-card max-width="600"> |
||||
<v-tabs height="30" :value="2"> |
||||
<v-tab> |
||||
<v-icon small class="mr-1"> |
||||
mdi-file-upload-outline |
||||
</v-icon> |
||||
<span class="caption text-capitalize">Upload</span> |
||||
</v-tab> |
||||
<v-tab> |
||||
<v-icon small class="mr-1"> |
||||
mdi-link-variant |
||||
</v-icon> |
||||
<span class="caption text-capitalize">URL</span> |
||||
</v-tab> |
||||
<v-tab> |
||||
<v-icon small class="mr-1"> |
||||
mdi-link-variant |
||||
</v-icon> |
||||
<span class="caption text-capitalize">String</span> |
||||
</v-tab> |
||||
|
||||
<v-tab-item> |
||||
<div class="nc-excel-import-tab-item "> |
||||
<div |
||||
class="nc-droppable d-flex align-center justify-center flex-column" |
||||
:style="{ |
||||
background : dragOver ? '#7772' : '' |
||||
}" |
||||
@click="$refs.file.click()" |
||||
@drop.prevent="dropHandler" |
||||
@dragover.prevent="dragOver = true" |
||||
@dragenter.prevent="dragOver = true" |
||||
@dragexit="dragOver = false" |
||||
@dragleave="dragOver = false" |
||||
@dragend="dragOver = false" |
||||
> |
||||
<x-icon :color="['primary','grey']" size="50"> |
||||
mdi-file-plus-outline |
||||
</x-icon> |
||||
<p class="title mb-1 mt-2"> |
||||
<!-- Select File to Upload--> |
||||
{{ $t('msg.info.upload') }} |
||||
</p> |
||||
<p class="grey--text mb-1"> |
||||
<!-- or drag and drop file--> |
||||
{{ $t('msg.info.upload_sub') }} |
||||
</p> |
||||
|
||||
<p v-if="quickImportType == 'excel'" class="caption grey--text"> |
||||
<!-- Supported: .xls, .xlsx, .xlsm, .ods, .ots --> |
||||
{{ $t('msg.info.excelSupport') }} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
</v-tab-item> |
||||
<v-tab-item> |
||||
<div class="nc-excel-import-tab-item align-center"> |
||||
<div class="pa-4 d-100 h-100"> |
||||
<v-form ref="form" v-model="valid"> |
||||
<div class="d-flex"> |
||||
<!--label="Enter excel file url"--> |
||||
<v-text-field |
||||
v-model="url" |
||||
hide-details="auto" |
||||
type="url" |
||||
:label="quickImportType == 'excel' ? $t('msg.info.excelURL') : $t('msg.info.csvURL') " |
||||
class="caption" |
||||
outlined |
||||
dense |
||||
:rules="[v => !!v || $t('general.required') ]" |
||||
/> |
||||
<v-btn v-t="['c:project:create:excel:load-url']" class="ml-3" color="primary" @click="loadUrl"> |
||||
<!--Load--> |
||||
{{ $t('general.load') }} |
||||
</v-btn> |
||||
</div> |
||||
</v-form> |
||||
</div> |
||||
</div> |
||||
</v-tab-item> |
||||
<v-tab-item> |
||||
<div class="nc-excel-import-tab-item align-center"> |
||||
<div class="pa-4 d-100 h-100"> |
||||
<v-form ref="form" v-model="valid"> |
||||
<div class=""> |
||||
<!--label="Enter excel file url"--> |
||||
<monaco-json-editor |
||||
v-model="jsonString" |
||||
style="height:300px" |
||||
/> |
||||
<v-btn v-t="['c:project:create:excel:load-url']" class="ml-3" color="primary" @click="loadJsonString"> |
||||
<!--Load--> |
||||
{{ $t('general.load') }} |
||||
</v-btn> |
||||
</div> |
||||
</v-form> |
||||
</div> |
||||
</div> |
||||
</v-tab-item> |
||||
</v-tabs> |
||||
|
||||
<div class="px-4 pb-2"> |
||||
<div class="d-flex"> |
||||
<v-spacer /> |
||||
<span class="caption pointer grey--text" @click="showMore = !showMore"> |
||||
{{ showMore ? $t('general.hideAll') : $t('general.showMore') }} |
||||
<v-icon small color="grey lighten-1">mdi-menu-{{ showMore ? 'up' : 'down' }}</v-icon> |
||||
</span> |
||||
</div> |
||||
<div class="mb-2 pt-2 nc-excel-import-options" :style="{ maxHeight: showMore ? '100px' : '0'}"> |
||||
<p /> |
||||
<!--hint="# of rows to parse to infer data type"--> |
||||
<v-text-field |
||||
v-model="parserConfig.maxRowsToParse" |
||||
style="max-width: 250px" |
||||
class="caption mx-auto" |
||||
dense |
||||
persistent-hint |
||||
:hint="$t('msg.info.footMsg')" |
||||
outlined |
||||
type="number" |
||||
/> |
||||
</div> |
||||
</div> |
||||
</v-card> |
||||
</v-dialog> |
||||
|
||||
<v-tooltip bottom> |
||||
<template #activator="{on}"> |
||||
<input |
||||
v-if="quickImportType == 'excel'" |
||||
ref="file" |
||||
class="nc-excel-import-input" |
||||
type="file" |
||||
style="display: none" |
||||
accept=".xlsx, .xls, .xlsm, .ods, .ots" |
||||
@change="_change($event)" |
||||
> |
||||
<input |
||||
v-if="quickImportType == 'csv'" |
||||
ref="file" |
||||
class="nc-excel-import-input" |
||||
type="file" |
||||
style="display: none" |
||||
accept=".csv" |
||||
@change="_change($event)" |
||||
> |
||||
<v-btn |
||||
|
||||
v-if="!hideLabel" |
||||
small |
||||
outlined |
||||
v-on="on" |
||||
@click="$refs.file.click()" |
||||
> |
||||
<v-icon small class="mr-1"> |
||||
mdi-file-excel-outline |
||||
</v-icon> |
||||
<!--Import--> |
||||
{{ $t('activity.import') }} |
||||
</v-btn> |
||||
</template> |
||||
<span class="caption">Create template from Excel</span> |
||||
</v-tooltip> |
||||
|
||||
<v-dialog v-if="templateData" v-model="templateEditorModal" max-width="1000"> |
||||
<v-card class="pa-6" min-width="500"> |
||||
<template-editor :project-template.sync="templateData" excel-import :quick-import-type="quickImportType"> |
||||
<template #toolbar="{valid}"> |
||||
<h3 class="mt-2 grey--text"> |
||||
<span> |
||||
JSON |
||||
</span> |
||||
: {{ templateData.title }} |
||||
</h3> |
||||
<v-spacer /> |
||||
<v-spacer /> |
||||
<create-project-from-template-btn |
||||
:template-data="templateData" |
||||
:import-data="importData" |
||||
:import-to-project="importToProject" |
||||
excel-import |
||||
:valid="valid" |
||||
create-gql-text="Import as GQL Project" |
||||
create-rest-text="Import as REST Project" |
||||
@closeModal="$emit('closeModal'),templateEditorModal = false" |
||||
> |
||||
<!--Import Excel--> |
||||
<span v-if="quickImportType === 'excel'"> |
||||
{{ $t('activity.importExcel') }} |
||||
</span> |
||||
<!--Import CSV--> |
||||
<span v-if="quickImportType === 'csv'"> |
||||
{{ $t('activity.importCSV') }} |
||||
</span> |
||||
</create-project-from-template-btn> |
||||
</template> |
||||
</template-editor> |
||||
</v-card> |
||||
</v-dialog> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
|
||||
// import XLSX from 'xlsx' |
||||
import TemplateEditor from '~/components/templates/Editor' |
||||
import CreateProjectFromTemplateBtn from '~/components/templates/CreateProjectFromTemplateBtn' |
||||
import ExcelUrlTemplateAdapter from '~/components/import/templateParsers/ExcelUrlTemplateAdapter' |
||||
import ExcelTemplateAdapter from '~/components/import/templateParsers/ExcelTemplateAdapter' |
||||
import MonacoJsonEditor from '~/components/monaco/MonacoJsonEditor' |
||||
import JSONTemplateAdapter from '~/components/import/templateParsers/JSONTemplateAdapter' |
||||
|
||||
export default { |
||||
name: 'JsonImport', |
||||
components: { MonacoJsonEditor, CreateProjectFromTemplateBtn, TemplateEditor }, |
||||
props: { |
||||
hideLabel: Boolean, |
||||
value: Boolean, |
||||
importToProject: Boolean, |
||||
quickImportType: String |
||||
}, |
||||
data() { |
||||
return { |
||||
templateEditorModal: false, |
||||
valid: null, |
||||
templateData: null, |
||||
importData: null, |
||||
dragOver: false, |
||||
url: '', |
||||
showMore: false, |
||||
parserConfig: { |
||||
maxRowsToParse: 500 |
||||
}, |
||||
filename: '', |
||||
jsonString: `[{ |
||||
"data": "Click Here", |
||||
"size": 36, |
||||
"style": "bold", |
||||
"name": "text1", |
||||
"hOffset": 250, |
||||
"vOffset": 100, |
||||
"alignment": "center", |
||||
"onMouseUp": "sun1.opacity = (sun1.opacity / 100) * 90;" |
||||
}]` |
||||
} |
||||
}, |
||||
computed: { |
||||
dropOrUpload: { |
||||
set(v) { |
||||
this.$emit('input', v) |
||||
}, |
||||
get() { |
||||
return this.value |
||||
} |
||||
} |
||||
}, |
||||
mounted() { |
||||
if (this.$route && this.$route.query && this.$route.query.excelUrl) { |
||||
this.url = this.$route.query.excelUrl |
||||
this.loadUrl() |
||||
} |
||||
}, |
||||
methods: { |
||||
|
||||
selectFile() { |
||||
this.$refs.file.files = null |
||||
this.$refs.file.click() |
||||
}, |
||||
|
||||
_change(event) { |
||||
const files = event.target.files |
||||
if (files && files[0]) { |
||||
this._file(files[0]) |
||||
event.target.value = '' |
||||
} |
||||
}, |
||||
async _file(file) { |
||||
this.templateData = null |
||||
this.importData = null |
||||
this.$store.commit('loader/MutMessage', 'Loading excel file') |
||||
let i = 0 |
||||
const int = setInterval(() => { |
||||
this.$store.commit('loader/MutMessage', `Loading excel file${'.'.repeat(++i % 4)}`) |
||||
}, 1000) |
||||
this.dropOrUpload = false |
||||
const reader = new FileReader() |
||||
this.filename = file.name |
||||
|
||||
reader.onload = async(e) => { |
||||
const ab = e.target.result |
||||
await this.parseAndExtractData('file', ab, file.name) |
||||
this.$store.commit('loader/MutMessage', null) |
||||
|
||||
clearInterval(int) |
||||
} |
||||
|
||||
const handleEvent = (event) => { |
||||
this.$store.commit('loader/MutMessage', `${event.type}: ${event.loaded} bytes transferred`) |
||||
} |
||||
|
||||
reader.addEventListener('progress', handleEvent) |
||||
reader.onerror = (e) => { |
||||
console.log('error', e) |
||||
this.$store.commit('loader/MutClear') |
||||
} |
||||
reader.readAsArrayBuffer(file) |
||||
}, |
||||
|
||||
async parseAndExtractData(type, val, name) { |
||||
try { |
||||
let templateGenerator |
||||
this.templateData = null |
||||
this.importData = null |
||||
switch (type) { |
||||
case 'file': |
||||
templateGenerator = new ExcelTemplateAdapter(name, val, this.parserConfig) |
||||
break |
||||
case 'url': |
||||
templateGenerator = new ExcelUrlTemplateAdapter(val, this.$store, this.parserConfig, this.$api) |
||||
break |
||||
case 'string': |
||||
templateGenerator = new JSONTemplateAdapter('test', val) |
||||
break |
||||
} |
||||
await templateGenerator.init() |
||||
templateGenerator.parse() |
||||
this.templateData = templateGenerator.getTemplate() |
||||
this.importData = templateGenerator.getData() |
||||
this.templateEditorModal = true |
||||
} catch (e) { |
||||
console.log(e) |
||||
this.$toast |
||||
.error(await this._extractSdkResponseErrorMsg(e)) |
||||
.goAway(3000) |
||||
} |
||||
}, |
||||
|
||||
dropHandler(ev) { |
||||
this.dragOver = false |
||||
let file |
||||
if (ev.dataTransfer.items) { |
||||
// Use DataTransferItemList interface to access the file(s) |
||||
if (ev.dataTransfer.items.length && ev.dataTransfer.items[0].kind === 'file') { |
||||
file = ev.dataTransfer.items[0].getAsFile() |
||||
} |
||||
} else if (ev.dataTransfer.files.length) { |
||||
file = ev.dataTransfer.files[0] |
||||
} |
||||
|
||||
if (!file) { |
||||
return |
||||
} |
||||
|
||||
if (this.quickImportType === 'excel') { |
||||
if (!/.*\.(xls|xlsx|xlsm|ods|ots)/.test(file.name)) { |
||||
return this.$toast.error('Dropped file is not an accepted file type. The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots!').goAway(3000) |
||||
} |
||||
} else if (this.quickImportType === 'csv') { |
||||
if (!/.*\.(csv)/.test(file.name)) { |
||||
return this.$toast.error('Dropped file is not an accepted file type. The accepted file type is .csv!').goAway(3000) |
||||
} |
||||
} |
||||
this._file(file) |
||||
}, |
||||
dragOverHandler(ev) { |
||||
// Prevent default behavior (Prevent file from being opened) |
||||
ev.preventDefault() |
||||
}, |
||||
|
||||
async loadUrl() { |
||||
if ((this.$refs.form && !this.$refs.form.validate()) || !this.url) { |
||||
return |
||||
} |
||||
|
||||
this.$store.commit('loader/MutMessage', 'Loading excel file from url') |
||||
|
||||
let i = 0 |
||||
const int = setInterval(() => { |
||||
this.$store.commit('loader/MutMessage', `Loading excel file${'.'.repeat(++i % 4)}`) |
||||
}, 1000) |
||||
|
||||
this.dropOrUpload = false |
||||
|
||||
await this.parseAndExtractData('url', this.url, '') |
||||
clearInterval(int) |
||||
this.$store.commit('loader/MutClear') |
||||
}, |
||||
|
||||
async loadJsonString() { |
||||
// if ((this.$refs.form && !this.$refs.form.validate()) || !this.url) { |
||||
// return |
||||
// } |
||||
|
||||
this.$store.commit('loader/MutMessage', 'Loading excel file from url') |
||||
|
||||
let i = 0 |
||||
const int = setInterval(() => { |
||||
this.$store.commit('loader/MutMessage', `Loading excel file${'.'.repeat(++i % 4)}`) |
||||
}, 1000) |
||||
|
||||
this.dropOrUpload = false |
||||
|
||||
await this.parseAndExtractData('string', this.jsonString) |
||||
clearInterval(int) |
||||
this.$store.commit('loader/MutClear') |
||||
} |
||||
|
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<style scoped> |
||||
.nc-droppable { |
||||
width: 100%; |
||||
min-height: 200px; |
||||
border-radius: 4px; |
||||
border: 2px dashed #ddd; |
||||
} |
||||
|
||||
.nc-excel-import-tab-item { |
||||
min-height: 400px; |
||||
padding: 20px; |
||||
display: flex; |
||||
align-items: stretch; |
||||
width: 100%; |
||||
} |
||||
|
||||
.nc-excel-import-options { |
||||
transition: .4s max-height; |
||||
overflow: hidden; |
||||
} |
||||
</style> |
@ -0,0 +1,156 @@
|
||||
import { TemplateGenerator } from 'nocodb-sdk' |
||||
import { UITypes } from '~/components/project/spreadsheet/helpers/uiTypes' |
||||
import { getCheckboxValue, isCheckboxType } from '~/components/import/templateParsers/parserHelpers' |
||||
|
||||
const jsonTypeToUidt = { |
||||
number: UITypes.Number, |
||||
string: UITypes.SingleLineText, |
||||
date: UITypes.DateTime, |
||||
boolean: UITypes.Checkbox, |
||||
object: UITypes.LongText |
||||
} |
||||
|
||||
export default class JSONTemplateAdapter extends TemplateGenerator { |
||||
constructor(name = 'test', data, parserConfig = {}) { |
||||
super() |
||||
this.config = { |
||||
maxRowsToParse: 500, |
||||
...parserConfig |
||||
} |
||||
this.name = name |
||||
this.jsonData = typeof data === 'string' ? JSON.parse(data) : data |
||||
this.project = { |
||||
title: this.name, |
||||
tables: [] |
||||
} |
||||
this.data = {} |
||||
} |
||||
|
||||
async init() { |
||||
} |
||||
|
||||
parseData() { |
||||
this.columns = this.csv.meta.fields |
||||
this.data = this.csv.data |
||||
} |
||||
|
||||
getColumns() { |
||||
return this.columns |
||||
} |
||||
|
||||
getData() { |
||||
return this.data |
||||
} |
||||
|
||||
parse() { |
||||
// for (let i = 0; i < this.wb.SheetNames.length; i++) {
|
||||
// const columnNamePrefixRef = { id: 0 }
|
||||
|
||||
const tn = 'table' |
||||
|
||||
const table = { table_name: tn, ref_table_name: tn, columns: [] } |
||||
|
||||
this.data[tn] = [] |
||||
|
||||
// const ws = this.wb.Sheets[sheet]
|
||||
// const range = XLSX.utils.decode_range(ws['!ref'])
|
||||
// const rows = XLSX.utils.sheet_to_json(ws, { header: 1, blankrows: false, cellDates: true, defval: null })
|
||||
|
||||
const objKeys = Object.keys(this.jsonData[0]) |
||||
|
||||
for (let col = 0; col < objKeys.length; col++) { |
||||
const key = objKeys[col] |
||||
const cn = objKeys[col].replace(/\W/g, '_').trim() |
||||
|
||||
const column = { |
||||
column_name: cn, |
||||
ref_column_name: cn |
||||
} |
||||
|
||||
table.columns.push(column) |
||||
|
||||
column.uidt = jsonTypeToUidt[typeof this.jsonData[0][key]] || UITypes.SingleLineText |
||||
|
||||
// todo: optimize
|
||||
if (column.uidt === UITypes.SingleLineText) { |
||||
// check for long text
|
||||
if (this.jsonData.some(r => |
||||
(r[key] || '').toString().match(/[\r\n]/) || |
||||
(r[key] || '').toString().length > 255) |
||||
) { |
||||
column.uidt = UITypes.LongText |
||||
} else { |
||||
const vals = this.jsonData |
||||
.map(r => r[key]) |
||||
.filter(v => 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:
|
||||
// eslint-disable-next-line no-lonely-if
|
||||
if (vals.some(v => v && v.toString().includes(','))) { |
||||
let flattenedVals = vals.flatMap(v => 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)) { |
||||
column.uidt = UITypes.MultiSelect |
||||
column.dtxp = `'${uniqueVals.join("','")}'` |
||||
} |
||||
} else { |
||||
const uniqueVals = vals.map(v => v.toString().trim()).filter((v, i, arr) => i === arr.findIndex(v1 => 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 (this.jsonData.slice(1, this.config.maxRowsToParse).some((v) => { |
||||
return v && v[key] && parseInt(+v[key]) !== +v[key] |
||||
})) { |
||||
column.uidt = UITypes.Decimal |
||||
} |
||||
if (this.jsonData.every((v, i) => { |
||||
return v[key] && v[key].toString().startsWith('$') |
||||
})) { |
||||
column.uidt = UITypes.Currency |
||||
} |
||||
} else if (column.uidt === UITypes.DateTime) { |
||||
if (this.jsonData.every((v, i) => { |
||||
return v[key] && v[key].toString().split(' ').length === 1 |
||||
})) { |
||||
column.uidt = UITypes.Date |
||||
} |
||||
} |
||||
} |
||||
|
||||
// let rowIndex = 0
|
||||
for (const row of this.jsonData) { |
||||
const rowData = {} |
||||
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) { |
||||
rowData[table.columns[i].column_name] = (row[table.columns[i].ref_column_name].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[table.columns[i].ref_column_name] || '').toString().trim() || null |
||||
} else { |
||||
// toto: do parsing if necessary based on type
|
||||
rowData[table.columns[i].column_name] = row[table.columns[i].ref_column_name] |
||||
} |
||||
} |
||||
this.data[tn].push(rowData) |
||||
// rowIndex++
|
||||
} |
||||
this.project.tables.push(table) |
||||
} |
||||
|
||||
getTemplate() { |
||||
return this.project |
||||
} |
||||
} |
@ -0,0 +1,30 @@
|
||||
import UITypes from './UITypes'; |
||||
|
||||
export interface Column { |
||||
column_name: string; |
||||
ref_column_name: string; |
||||
uidt?: UITypes; |
||||
dtxp?: any; |
||||
dt?: any; |
||||
} |
||||
export interface Table { |
||||
table_name: string; |
||||
ref_table_name: string; |
||||
columns: Array<Column>; |
||||
} |
||||
export interface Template { |
||||
title: string; |
||||
tables: Array<Table>; |
||||
} |
||||
|
||||
export default abstract class TemplateGenerator { |
||||
abstract parse(): Promise<any>; |
||||
abstract parseTemplate(): Promise<Template>; |
||||
abstract getColumns(): Promise<any>; |
||||
abstract parseData(): Promise<any>; |
||||
abstract getData(): Promise<{ |
||||
[table_name: string]: Array<{ |
||||
[key: string]: any; |
||||
}>; |
||||
}>; |
||||
} |
Loading…
Reference in new issue