diff --git a/frappe/model/naming.js b/frappe/model/naming.js index fb1f3ed0..dd42d85d 100644 --- a/frappe/model/naming.js +++ b/frappe/model/naming.js @@ -2,6 +2,24 @@ const frappe = require('frappe'); const { getRandomString } = require('frappe/utils'); module.exports = { + async isNameAutoSet(doctype) { + const doc = frappe.getNewDoc(doctype); + if (doc.meta.naming === 'autoincrement') { + return true; + } + + if (!doc.meta.settings) { + return false; + } + + const { numberSeries } = await doc.getSettings(); + if (numberSeries) { + return true; + } + + return false; + }, + async setName(doc) { if (frappe.isServer) { // if is server, always name again if autoincrement or other diff --git a/models/doctype/Item/Item.js b/models/doctype/Item/Item.js index 0adb9b2a..aabf92fc 100644 --- a/models/doctype/Item/Item.js +++ b/models/doctype/Item/Item.js @@ -2,6 +2,7 @@ import frappe, { t } from 'frappe'; export default { name: 'Item', + label: t`Item`, doctype: 'DocType', isSingle: 0, regional: 1, diff --git a/models/doctype/JournalEntry/JournalEntry.js b/models/doctype/JournalEntry/JournalEntry.js index 53fcd6dc..eb26d034 100644 --- a/models/doctype/JournalEntry/JournalEntry.js +++ b/models/doctype/JournalEntry/JournalEntry.js @@ -63,6 +63,7 @@ export default { label: t`Cancelled`, fieldtype: 'Check', default: 0, + readOnly: 1, }, ], actions: [ledgerLink], diff --git a/models/doctype/Payment/Payment.js b/models/doctype/Payment/Payment.js index a3a62bf9..5773141c 100644 --- a/models/doctype/Payment/Payment.js +++ b/models/doctype/Payment/Payment.js @@ -150,6 +150,7 @@ export default { label: t`Cancelled`, fieldtype: 'Check', default: 0, + readOnly: 1, }, ], diff --git a/models/doctype/PurchaseInvoice/PurchaseInvoice.js b/models/doctype/PurchaseInvoice/PurchaseInvoice.js index 7e9d3af0..ea2640eb 100644 --- a/models/doctype/PurchaseInvoice/PurchaseInvoice.js +++ b/models/doctype/PurchaseInvoice/PurchaseInvoice.js @@ -133,6 +133,7 @@ export default { label: t`Cancelled`, fieldtype: 'Check', default: 0, + readOnly: 1, }, ], diff --git a/models/doctype/SalesInvoice/SalesInvoice.js b/models/doctype/SalesInvoice/SalesInvoice.js index 6e27ad4d..072c19e5 100644 --- a/models/doctype/SalesInvoice/SalesInvoice.js +++ b/models/doctype/SalesInvoice/SalesInvoice.js @@ -132,6 +132,7 @@ export default { label: t`Cancelled`, fieldtype: 'Check', default: 0, + readOnly: 1, }, ], diff --git a/src/background.js b/src/background.js index b69e3581..069e27a1 100644 --- a/src/background.js +++ b/src/background.js @@ -255,6 +255,34 @@ ipcMain.handle(IPC_ACTIONS.GET_COA_LIST, async () => { return coas; }); +ipcMain.handle(IPC_ACTIONS.GET_FILE, async (event, options) => { + const response = { + name: '', + filePath: '', + success: false, + data: null, + canceled: false, + }; + const window = event.sender.getOwnerBrowserWindow(); + const { filePaths, canceled } = await dialog.showOpenDialog(window, options); + + response.filePath = filePaths?.[0]; + response.canceled = canceled; + + if (!response.filePath) { + return response; + } + + response.success = true; + if (canceled) { + return response; + } + + response.name = path.basename(response.filePath); + response.data = await fs.readFile(response.filePath); + return response; +}); + /* ------------------------------ * Register autoUpdater events lis * ------------------------------*/ diff --git a/src/components/Button.vue b/src/components/Button.vue index 5a18f317..9baeeb64 100644 --- a/src/components/Button.vue +++ b/src/components/Button.vue @@ -24,11 +24,17 @@ export default { type: Boolean, default: false, }, + padding: { + type: Boolean, + default: true, + }, }, computed: { style() { return { - padding: this.icon ? '6px 12px' : '6px 24px', + ...(this.padding + ? { padding: this.icon ? '6px 12px' : '6px 24px' } + : {}), color: this.type === 'primary' ? '#fff' : '#112B42', 'background-image': this.type === 'primary' diff --git a/src/csvParser.ts b/src/csvParser.ts new file mode 100644 index 00000000..8859efb8 --- /dev/null +++ b/src/csvParser.ts @@ -0,0 +1,80 @@ +function unwrapDq(item: string): string { + const s = item.at(0); + const e = item.at(-1); + if (s === '"' && e === '"') { + return item.slice(1, -1); + } + + return item; +} + +function splitCsvBlock(text: string): string[] { + if (!text.endsWith('\r\n')) { + text += '\r\n'; + } + const lines = []; + let line = ''; + let inDq = false; + + for (let i = 0; i <= text.length; i++) { + const c = text[i]; + + if ( + c === '"' && + ((c[i + 1] === '"' && c[i + 2] === '"') || c[i + 1] !== '"') + ) { + inDq = !inDq; + } + + if (!inDq && c === '\r' && text[i + 1] === '\n') { + lines.push(line); + line = ''; + i = i + 1; + continue; + } + + line += c; + } + + return lines; +} + +function splitCsvLine(line: string): string[] { + if (line.at(-1) !== ',') { + // if conforming to spec, it should not end with ',' + line += ','; + } + + const items = []; + let item = ''; + let inDq = false; + + for (let i = 0; i < line.length; i++) { + const c = line[i]; + + if ( + c === '"' && + ((c[i + 1] === '"' && c[i + 2] === '"') || c[i + 1] !== '"') + ) { + inDq = !inDq; + } + + if (!inDq && c === ',') { + item = unwrapDq(item); + item = item.replaceAll('""', '"'); + items.push(item); + item = ''; + continue; + } + + item += c; + } + + return items; +} + +export function parseCSV(text: string): string[][] { + // Works on RFC 4180 + const rows = splitCsvBlock(text); + return rows.map(splitCsvLine); +} diff --git a/src/dataImport.ts b/src/dataImport.ts new file mode 100644 index 00000000..8dc23dd9 --- /dev/null +++ b/src/dataImport.ts @@ -0,0 +1,409 @@ +import { Field, FieldType } from '@/types/model'; +import frappe from 'frappe'; +import { isNameAutoSet } from 'frappe/model/naming'; +import { parseCSV } from './csvParser'; + +export const importable = [ + 'SalesInvoice', + 'PurchaseInvoice', + 'Payment', + 'JournalEntry', + 'Customer', + 'Supplier', + 'Item', +]; + +type Status = { + success: boolean; + message: string; + names: string[]; +}; + +type Exclusion = { + [key: string]: string[]; +}; + +type Map = { + [key: string]: unknown; +}; + +type ObjectMap = { + [key: string]: Map; +}; + +type LabelTemplateFieldMap = { + [key: string]: TemplateField; +}; + +interface TemplateField { + label: string; + fieldname: string; + required: boolean; + doctype: string; + options?: string[]; + fieldtype: FieldType; + parentField: string; +} + +function formatValue(value: string, fieldtype: FieldType): unknown { + switch (fieldtype) { + case FieldType.Date: + return new Date(value); + case FieldType.Currency: + // @ts-ignore + return frappe.pesa(value); + case FieldType.Int: + case FieldType.Float: { + const n = parseFloat(value); + if (!Number.isNaN(n)) { + return n; + } + return 0; + } + default: + return value; + } +} + +const exclusion: Exclusion = { + Item: ['image'], + Supplier: ['address', 'outstandingAmount', 'supplier', 'image', 'customer'], + Customer: ['address', 'outstandingAmount', 'supplier', 'image', 'customer'], +}; + +function getFilteredDocFields( + df: string | string[] +): [TemplateField[], string[][]] { + let doctype = df[0]; + let parentField = df[1] ?? ''; + + if (typeof df === 'string') { + doctype = df; + parentField = ''; + } + + // @ts-ignore + const primaryFields: Field[] = frappe.models[doctype].fields; + const fields: TemplateField[] = []; + const tableTypes: string[][] = []; + const exclusionFields: string[] = exclusion[doctype] ?? []; + + primaryFields.forEach( + ({ + label, + fieldtype, + childtype, + fieldname, + readOnly, + required, + hidden, + options, + }) => { + if ( + !(fieldname === 'name' && !parentField) && + (readOnly || + (hidden && typeof hidden === 'number') || + exclusionFields.includes(fieldname)) + ) { + return; + } + + if (fieldtype === FieldType.Table && childtype) { + tableTypes.push([childtype, fieldname]); + return; + } + + fields.push({ + label, + fieldname, + doctype, + options, + fieldtype, + parentField, + required: Boolean(required ?? false), + }); + } + ); + + return [fields, tableTypes]; +} + +function getTemplateFields(doctype: string): TemplateField[] { + const fields: TemplateField[] = []; + if (!doctype) { + return []; + } + const doctypes: string[][] = [[doctype]]; + while (doctypes.length > 0) { + const dt = doctypes.pop(); + if (!dt) { + break; + } + + const [templateFields, tableTypes] = getFilteredDocFields(dt); + fields.push(...templateFields); + doctypes.push(...tableTypes); + } + return fields; +} + +function getLabelFieldMap(templateFields: TemplateField[]): Map { + const map: Map = {}; + + templateFields.reduce((acc, tf) => { + const key = tf.label as string; + acc[key] = tf.fieldname; + return acc; + }, map); + + return map; +} + +function getTemplate(templateFields: TemplateField[]): string { + const labels = templateFields.map(({ label }) => `"${label}"`).join(','); + return [labels, ''].join('\n'); +} + +export class Importer { + doctype: string; + templateFields: TemplateField[]; + map: Map; + template: string; + indices: number[] = []; + parsedLabels: string[] = []; + parsedValues: string[][] = []; + assignedMap: Map = {}; // target: import + requiredMap: Map = {}; + labelTemplateFieldMap: LabelTemplateFieldMap = {}; + shouldSubmit: boolean = false; + labelIndex: number = -1; + csv: string[][] = []; + + constructor(doctype: string) { + this.doctype = doctype; + this.templateFields = getTemplateFields(doctype); + this.map = getLabelFieldMap(this.templateFields); + this.template = getTemplate(this.templateFields); + this.assignedMap = this.assignableLabels.reduce((acc: Map, k) => { + acc[k] = ''; + return acc; + }, {}); + this.requiredMap = this.templateFields.reduce((acc: Map, k) => { + acc[k.label] = k.required; + return acc; + }, {}); + this.labelTemplateFieldMap = this.templateFields.reduce( + (acc: LabelTemplateFieldMap, k) => { + acc[k.label] = k; + return acc; + }, + {} + ); + } + + get assignableLabels() { + const req: string[] = []; + const nreq: string[] = []; + Object.keys(this.map).forEach((k) => { + if (this.requiredMap[k]) { + req.push(k); + return; + } + + nreq.push(k); + }); + + return [...req, ...nreq]; + } + + get unassignedLabels() { + const assigned = Object.keys(this.assignedMap).map( + (k) => this.assignedMap[k] + ); + return this.parsedLabels.filter((l) => !assigned.includes(l)); + } + + get columnLabels() { + const req: string[] = []; + const nreq: string[] = []; + + this.assignableLabels.forEach((k) => { + if (!this.assignedMap[k]) { + return; + } + + if (this.requiredMap[k]) { + req.push(k); + return; + } + + nreq.push(k); + }); + + return [...req, ...nreq]; + } + + get assignedMatrix() { + this.indices = this.columnLabels + .map((k) => this.assignedMap[k]) + .filter(Boolean) + .map((k) => this.parsedLabels.indexOf(k as string)); + + const rows = this.parsedValues.length; + const cols = this.columnLabels.length; + + const matrix = []; + for (let i = 0; i < rows; i++) { + const row = []; + for (let j = 0; j < cols; j++) { + const ix = this.indices[j]; + const value = this.parsedValues[i][ix] ?? ''; + row.push(value); + } + matrix.push(row); + } + + return matrix; + } + + dropRow(i: number) { + this.parsedValues = this.parsedValues.filter((_, ix) => i !== ix); + } + + updateValue(value: string, i: number, j: number) { + this.parsedValues[i][this.indices[j]] = value ?? ''; + } + + selectFile(text: string): boolean { + this.csv = parseCSV(text); + try { + this.initialize(0, true); + } catch (err) { + return false; + } + return true; + } + + initialize(labelIndex: number, force: boolean) { + if ( + (typeof labelIndex !== 'number' && !labelIndex) || + (labelIndex === this.labelIndex && !force) + ) { + return; + } + + const source = this.csv.map((row) => [...row]); + this.labelIndex = labelIndex; + this.parsedLabels = source[labelIndex]; + this.parsedValues = source.slice(labelIndex + 1); + this.setAssigned(); + } + + setAssigned() { + const labels = [...this.parsedLabels]; + + for (const k of Object.keys(this.assignedMap)) { + const l = this.assignedMap[k] as string; + if (!labels.includes(l)) { + this.assignedMap[k] = ''; + } + } + + labels.forEach((l) => { + if (this.assignedMap[l] !== '') { + return; + } + + this.assignedMap[l] = l; + }); + } + + getDocs(): Map[] { + const fields = this.columnLabels.map((k) => this.labelTemplateFieldMap[k]); + const nameIndex = fields.findIndex(({ fieldname }) => fieldname === 'name'); + + const docMap: ObjectMap = {}; + + for (let r = 0; r < this.assignedMatrix.length; r++) { + const row = this.assignedMatrix[r]; + const cts: ObjectMap = {}; + const name = row[nameIndex]; + + docMap[name] ??= {}; + + for (let f = 0; f < fields.length; f++) { + const field = fields[f]; + const value = formatValue(row[f], field.fieldtype); + + if (field.parentField) { + cts[field.parentField] ??= {}; + cts[field.parentField][field.fieldname] = value; + continue; + } + + docMap[name][field.fieldname] = value; + } + + for (const k of Object.keys(cts)) { + docMap[name][k] ??= []; + (docMap[name][k] as Map[]).push(cts[k]); + } + } + + // return docObjs; + return Object.keys(docMap).map((k) => docMap[k]); + } + + async importData(): Promise { + const status: Status = { success: false, names: [], message: '' }; + const shouldDeleteName = await isNameAutoSet(this.doctype); + + for (const docObj of this.getDocs()) { + if (shouldDeleteName) { + delete docObj.name; + } + + const doc = frappe.getNewDoc(this.doctype); + await doc.set(docObj); + + try { + await doc.insert(); + if (this.shouldSubmit) { + await doc.submit(); + } + } catch (err) { + const messages = [ + frappe.t`Could not import ${this.doctype} ${doc.name}.`, + ]; + + const message = (err as Error).message; + if (message?.includes('UNIQUE constraint failed')) { + messages.push(frappe.t`${doc.name} already exists.`); + } else if (message) { + messages.push(message); + } + + if (status.names.length) { + messages.push( + frappe.t`The following ${ + status.names.length + } entries were created: ${status.names.join(', ')}` + ); + } + + status.message = messages.join(' '); + return status; + } + + status.names.push(doc.name); + } + + status.success = true; + return status; + } + + addRow() { + const emptyRow = Array(this.columnLabels.length).fill(''); + this.parsedValues.push(emptyRow); + } +} diff --git a/src/messages.js b/src/messages.js index fcb599d1..3ec6db53 100644 --- a/src/messages.js +++ b/src/messages.js @@ -26,6 +26,7 @@ export const IPC_ACTIONS = { GET_LANGUAGE_MAP: 'get-language-map', CHECK_FOR_UPDATES: 'check-for-updates', GET_COA_LIST: 'get-coa-list', + GET_FILE: 'get-file', }; // ipcMain.send(...) diff --git a/src/pages/DataImport.vue b/src/pages/DataImport.vue new file mode 100644 index 00000000..2068e64c --- /dev/null +++ b/src/pages/DataImport.vue @@ -0,0 +1,577 @@ + + diff --git a/src/pages/DataImport/index.vue b/src/pages/DataImport/index.vue deleted file mode 100644 index de9bdc5b..00000000 --- a/src/pages/DataImport/index.vue +++ /dev/null @@ -1,163 +0,0 @@ - - diff --git a/src/pages/Settings/Settings.vue b/src/pages/Settings/Settings.vue index fff11dca..6d1c4481 100644 --- a/src/pages/Settings/Settings.vue +++ b/src/pages/Settings/Settings.vue @@ -1,4 +1,6 @@