diff --git a/frappe/backends/database.js b/frappe/backends/database.js new file mode 100644 index 00000000..cda95769 --- /dev/null +++ b/frappe/backends/database.js @@ -0,0 +1,830 @@ +const frappe = require('frappejs'); +const Observable = require('frappejs/utils/observable'); +const CacheManager = require('frappejs/utils/cacheManager'); +const Knex = require('knex'); + +module.exports = class Database extends Observable { + constructor() { + super(); + this.initTypeMap(); + this.connectionParams = {}; + this.cache = new CacheManager(); + } + + connect() { + this.knex = Knex(this.connectionParams); + this.knex.on('query-error', (error) => { + error.type = this.getError(error); + }); + this.executePostDbConnect(); + } + + close() { + // + } + + async migrate() { + for (let doctype in frappe.models) { + // check if controller module + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + if (!meta.isSingle) { + if (await this.tableExists(baseDoctype)) { + await this.alterTable(baseDoctype); + } else { + await this.createTable(baseDoctype); + } + } + } + await this.commit(); + await this.initializeSingles(); + } + + async initializeSingles() { + let singleDoctypes = frappe + .getModels((model) => model.isSingle) + .map((model) => model.name); + + for (let doctype of singleDoctypes) { + if (await this.singleExists(doctype)) { + const singleValues = await this.getSingleFieldsToInsert(doctype); + singleValues.forEach(({ fieldname, value }) => { + let singleValue = frappe.newDoc({ + doctype: 'SingleValue', + parent: doctype, + fieldname, + value, + }); + singleValue.insert(); + }); + continue; + } + let meta = frappe.getMeta(doctype); + if (meta.fields.every((df) => df.default == null)) { + continue; + } + let defaultValues = meta.fields.reduce((doc, df) => { + if (df.default != null) { + doc[df.fieldname] = df.default; + } + return doc; + }, {}); + await this.updateSingle(doctype, defaultValues); + } + } + + async singleExists(doctype) { + let res = await this.knex('SingleValue') + .count('parent as count') + .where('parent', doctype) + .first(); + return res.count > 0; + } + + async getSingleFieldsToInsert(doctype) { + const existingFields = ( + await frappe.db + .knex('SingleValue') + .where({ parent: doctype }) + .select('fieldname') + ).map(({ fieldname }) => fieldname); + + return frappe + .getMeta(doctype) + .fields.map(({ fieldname, default: value }) => ({ + fieldname, + value, + })) + .filter( + ({ fieldname, value }) => + !existingFields.includes(fieldname) && value !== undefined + ); + } + + tableExists(table) { + return this.knex.schema.hasTable(table); + } + + async createTable(doctype, tableName = null) { + let fields = this.getValidFields(doctype); + return await this.runCreateTableQuery(tableName || doctype, fields); + } + + runCreateTableQuery(doctype, fields) { + return this.knex.schema.createTable(doctype, (table) => { + for (let field of fields) { + this.buildColumnForTable(table, field); + } + }); + } + + async alterTable(doctype) { + // get columns + let diff = await this.getColumnDiff(doctype); + let newForeignKeys = await this.getNewForeignKeys(doctype); + + return this.knex.schema + .table(doctype, (table) => { + if (diff.added.length) { + for (let field of diff.added) { + this.buildColumnForTable(table, field); + } + } + + if (diff.removed.length) { + this.removeColumns(doctype, diff.removed); + } + }) + .then(() => { + if (newForeignKeys.length) { + return this.addForeignKeys(doctype, newForeignKeys); + } + }); + } + + buildColumnForTable(table, field) { + let columnType = this.getColumnType(field); + if (!columnType) { + // In case columnType is "Table" + // childTable links are handled using the childTable's "parent" field + return; + } + + let column = table[columnType](field.fieldname); + + // primary key + if (field.fieldname === 'name') { + column.primary(); + } + + // default value + if (!!field.default && !(field.default instanceof Function)) { + column.defaultTo(field.default); + } + + // required + if ( + (!!field.required && !(field.required instanceof Function)) || + field.fieldtype === 'Currency' + ) { + column.notNullable(); + } + + // link + if (field.fieldtype === 'Link' && field.target) { + let meta = frappe.getMeta(field.target); + table + .foreign(field.fieldname) + .references('name') + .inTable(meta.getBaseDocType()) + .onUpdate('CASCADE') + .onDelete('RESTRICT'); + } + } + + async getColumnDiff(doctype) { + const tableColumns = await this.getTableColumns(doctype); + const validFields = this.getValidFields(doctype); + const diff = { added: [], removed: [] }; + + for (let field of validFields) { + if ( + !tableColumns.includes(field.fieldname) && + this.getColumnType(field) + ) { + diff.added.push(field); + } + } + + const validFieldNames = validFields.map((field) => field.fieldname); + for (let column of tableColumns) { + if (!validFieldNames.includes(column)) { + diff.removed.push(column); + } + } + + return diff; + } + + async removeColumns(doctype, removed) { + for (let column of removed) { + await this.runRemoveColumnQuery(doctype, column); + } + } + + async getNewForeignKeys(doctype) { + let foreignKeys = await this.getForeignKeys(doctype); + let newForeignKeys = []; + let meta = frappe.getMeta(doctype); + for (let field of meta.getValidFields({ withChildren: false })) { + if ( + field.fieldtype === 'Link' && + !foreignKeys.includes(field.fieldname) + ) { + newForeignKeys.push(field); + } + } + return newForeignKeys; + } + + async addForeignKeys(doctype, newForeignKeys) { + for (let field of newForeignKeys) { + this.addForeignKey(doctype, field); + } + } + + async getForeignKeys(doctype, field) { + return []; + } + + async getTableColumns(doctype) { + return []; + } + + async get(doctype, name = null, fields = '*') { + let meta = frappe.getMeta(doctype); + let doc; + if (meta.isSingle) { + doc = await this.getSingle(doctype); + doc.name = doctype; + } else { + if (!name) { + throw new frappe.errors.ValueError('name is mandatory'); + } + doc = await this.getOne(doctype, name, fields); + } + if (!doc) { + return; + } + await this.loadChildren(doc, meta); + return doc; + } + + async loadChildren(doc, meta) { + // load children + let tableFields = meta.getTableFields(); + for (let field of tableFields) { + doc[field.fieldname] = await this.getAll({ + doctype: field.childtype, + fields: ['*'], + filters: { parent: doc.name }, + orderBy: 'idx', + order: 'asc', + }); + } + } + + async getSingle(doctype) { + let values = await this.getAll({ + doctype: 'SingleValue', + fields: ['fieldname', 'value'], + filters: { parent: doctype }, + orderBy: 'fieldname', + order: 'asc', + }); + let doc = {}; + for (let row of values) { + doc[row.fieldname] = row.value; + } + return doc; + } + + /** + * Get list of values from the singles table. + * @param {...string | Object} fieldnames list of fieldnames to get the values of + * @returns {Array} array of {parent, value, fieldname}. + * @example + * Database.getSingleValues('internalPrecision'); + * // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }] + * @example + * Database.getSingleValues({fieldname:'internalPrecision', parent: 'SystemSettings'}); + * // returns [{ fieldname: 'internalPrecision', parent: 'SystemSettings', value: '12' }] + */ + async getSingleValues(...fieldnames) { + fieldnames = fieldnames.map((fieldname) => { + if (typeof fieldname === 'string') { + return { fieldname }; + } + return fieldname; + }); + + let builder = frappe.db.knex('SingleValue'); + builder = builder.where(fieldnames[0]); + + fieldnames.slice(1).forEach(({ fieldname, parent }) => { + if (typeof parent === 'undefined') { + builder = builder.orWhere({ fieldname }); + } else { + builder = builder.orWhere({ fieldname, parent }); + } + }); + + let values = []; + try { + values = await builder.select('fieldname', 'value', 'parent'); + } catch (error) { + if (error.message.includes('no such table')) { + return []; + } + throw error; + } + + return values.map((value) => { + const fields = frappe.getMeta(value.parent).fields; + return this.getDocFormattedDoc(fields, values); + }); + } + + async getOne(doctype, name, fields = '*') { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + + const doc = await this.knex + .select(fields) + .from(baseDoctype) + .where('name', name) + .first(); + + if (!doc) { + return doc; + } + + return this.getDocFormattedDoc(meta.fields, doc); + } + + getDocFormattedDoc(fields, doc) { + // format for usage, not going into the db + const docFields = Object.keys(doc); + const filteredFields = fields.filter(({ fieldname }) => + docFields.includes(fieldname) + ); + + const formattedValues = filteredFields.reduce((d, field) => { + const { fieldname } = field; + d[fieldname] = this.getDocFormattedValues(field, doc[fieldname]); + return d; + }, {}); + + return Object.assign(doc, formattedValues); + } + + getDocFormattedValues(field, value) { + // format for usage, not going into the db + try { + if (field.fieldtype === 'Currency') { + return frappe.pesa(value); + } + } catch (err) { + err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${ + field.fieldname + }', label: '${field.label}'`; + throw err; + } + return value; + } + + triggerChange(doctype, name) { + this.trigger(`change:${doctype}`, { name }, 500); + this.trigger(`change`, { doctype, name }, 500); + // also trigger change for basedOn doctype + let meta = frappe.getMeta(doctype); + if (meta.basedOn) { + this.triggerChange(meta.basedOn, name); + } + } + + async insert(doctype, doc) { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + doc = this.applyBaseDocTypeFilters(doctype, doc); + + // insert parent + if (meta.isSingle) { + await this.updateSingle(doctype, doc); + } else { + await this.insertOne(baseDoctype, doc); + } + + // insert children + await this.insertChildren(meta, doc, baseDoctype); + + this.triggerChange(doctype, doc.name); + + return doc; + } + + async insertChildren(meta, doc, doctype) { + let tableFields = meta.getTableFields(); + for (let field of tableFields) { + let idx = 0; + for (let child of doc[field.fieldname] || []) { + this.prepareChild(doctype, doc.name, child, field, idx); + await this.insertOne(field.childtype, child); + idx++; + } + } + } + + insertOne(doctype, doc) { + let fields = this.getValidFields(doctype); + + if (!doc.name) { + doc.name = frappe.getRandomString(); + } + + let formattedDoc = this.getFormattedDoc(fields, doc); + return this.knex(doctype).insert(formattedDoc); + } + + async update(doctype, doc) { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + doc = this.applyBaseDocTypeFilters(doctype, doc); + + // update parent + if (meta.isSingle) { + await this.updateSingle(doctype, doc); + } else { + await this.updateOne(baseDoctype, doc); + } + + // insert or update children + await this.updateChildren(meta, doc, baseDoctype); + + this.triggerChange(doctype, doc.name); + + return doc; + } + + async updateChildren(meta, doc, doctype) { + let tableFields = meta.getTableFields(); + for (let field of tableFields) { + let added = []; + for (let child of doc[field.fieldname] || []) { + this.prepareChild(doctype, doc.name, child, field, added.length); + if (await this.exists(field.childtype, child.name)) { + await this.updateOne(field.childtype, child); + } else { + await this.insertOne(field.childtype, child); + } + added.push(child.name); + } + await this.runDeleteOtherChildren(field, doc.name, added); + } + } + + updateOne(doctype, doc) { + let validFields = this.getValidFields(doctype); + let fieldsToUpdate = Object.keys(doc).filter((f) => f !== 'name'); + let fields = validFields.filter((df) => + fieldsToUpdate.includes(df.fieldname) + ); + let formattedDoc = this.getFormattedDoc(fields, doc); + + return this.knex(doctype) + .where('name', doc.name) + .update(formattedDoc) + .then(() => { + let cacheKey = `${doctype}:${doc.name}`; + if (this.cache.hexists(cacheKey)) { + for (let fieldname in formattedDoc) { + let value = formattedDoc[fieldname]; + this.cache.hset(cacheKey, fieldname, value); + } + } + }); + } + + runDeleteOtherChildren(field, parent, added) { + // delete other children + return this.knex(field.childtype) + .where('parent', parent) + .andWhere('name', 'not in', added) + .delete(); + } + + async updateSingle(doctype, doc) { + let meta = frappe.getMeta(doctype); + await this.deleteSingleValues(doctype); + for (let field of meta.getValidFields({ withChildren: false })) { + let value = doc[field.fieldname]; + if (value != null) { + let singleValue = frappe.newDoc({ + doctype: 'SingleValue', + parent: doctype, + fieldname: field.fieldname, + value: value, + }); + await singleValue.insert(); + } + } + } + + deleteSingleValues(name) { + return this.knex('SingleValue').where('parent', name).delete(); + } + + async rename(doctype, oldName, newName) { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + await this.knex(baseDoctype) + .update({ name: newName }) + .where('name', oldName) + .then(() => { + this.clearValueCache(doctype, oldName); + }); + await frappe.db.commit(); + + this.triggerChange(doctype, newName); + } + + prepareChild(parenttype, parent, child, field, idx) { + if (!child.name) { + child.name = frappe.getRandomString(); + } + child.parent = parent; + child.parenttype = parenttype; + child.parentfield = field.fieldname; + child.idx = idx; + } + + getValidFields(doctype) { + return frappe.getMeta(doctype).getValidFields({ withChildren: false }); + } + + getFormattedDoc(fields, doc) { + // format for storage, going into the db + let formattedDoc = {}; + fields.map((field) => { + let value = doc[field.fieldname]; + formattedDoc[field.fieldname] = this.getFormattedValue(field, value); + }); + return formattedDoc; + } + + getFormattedValue(field, value) { + // format for storage, going into the db + const type = typeof value; + if (field.fieldtype === 'Currency') { + let currency = value; + + if (type === 'number' || type === 'string') { + currency = frappe.pesa(value); + } + + const currencyValue = currency.store; + if (typeof currencyValue !== 'string') { + throw new Error( + `invalid currencyValue '${currencyValue}' of type '${typeof currencyValue}' on converting from '${value}' of type '${type}'` + ); + } + + return currencyValue; + } + + if (value instanceof Date) { + if (field.fieldtype === 'Date') { + // date + return value.toISOString().substr(0, 10); + } else { + // datetime + return value.toISOString(); + } + } else if (field.fieldtype === 'Link' && !value) { + // empty value must be null to satisfy + // foreign key constraint + return null; + } else { + return value; + } + } + + applyBaseDocTypeFilters(doctype, doc) { + let meta = frappe.getMeta(doctype); + if (meta.filters) { + for (let fieldname in meta.filters) { + let value = meta.filters[fieldname]; + if (typeof value !== 'object') { + doc[fieldname] = value; + } + } + } + return doc; + } + + async deleteMany(doctype, names) { + for (const name of names) { + await this.delete(doctype, name); + } + } + + async delete(doctype, name) { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + await this.deleteOne(baseDoctype, name); + + // delete children + let tableFields = frappe.getMeta(doctype).getTableFields(); + for (let field of tableFields) { + await this.deleteChildren(field.childtype, name); + } + + this.triggerChange(doctype, name); + } + + async deleteOne(doctype, name) { + return this.knex(doctype) + .where('name', name) + .delete() + .then(() => { + this.clearValueCache(doctype, name); + }); + } + + deleteChildren(parenttype, parent) { + return this.knex(parenttype).where('parent', parent).delete(); + } + + async exists(doctype, name) { + return (await this.getValue(doctype, name)) ? true : false; + } + + async getValue(doctype, filters, fieldname = 'name') { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + if (typeof filters === 'string') { + filters = { name: filters }; + } + if (meta.filters) { + Object.assign(filters, meta.filters); + } + + let row = await this.getAll({ + doctype: baseDoctype, + fields: [fieldname], + filters: filters, + start: 0, + limit: 1, + orderBy: 'name', + order: 'asc', + }); + return row.length ? row[0][fieldname] : null; + } + + async setValue(doctype, name, fieldname, value) { + return await this.setValues(doctype, name, { + [fieldname]: value, + }); + } + + async setValues(doctype, name, fieldValuePair) { + let doc = Object.assign({}, fieldValuePair, { name }); + return this.updateOne(doctype, doc); + } + + async getCachedValue(doctype, name, fieldname) { + let value = this.cache.hget(`${doctype}:${name}`, fieldname); + if (value == null) { + value = await this.getValue(doctype, name, fieldname); + } + return value; + } + + async getAll({ + doctype, + fields, + filters, + start, + limit, + groupBy, + orderBy = 'creation', + order = 'desc', + } = {}) { + let meta = frappe.getMeta(doctype); + let baseDoctype = meta.getBaseDocType(); + if (!fields) { + fields = meta.getKeywordFields(); + fields.push('name'); + } + if (typeof fields === 'string') { + fields = [fields]; + } + if (meta.filters) { + filters = Object.assign({}, filters, meta.filters); + } + + let builder = this.knex.select(fields).from(baseDoctype); + + this.applyFiltersToBuilder(builder, filters); + + if (orderBy) { + builder.orderBy(orderBy, order); + } + + if (groupBy) { + builder.groupBy(groupBy); + } + + if (start) { + builder.offset(start); + } + + if (limit) { + builder.limit(limit); + } + + const docs = await builder; + return docs.map((doc) => this.getDocFormattedDoc(meta.fields, doc)); + } + + applyFiltersToBuilder(builder, filters) { + // {"status": "Open"} => `status = "Open"` + + // {"status": "Open", "name": ["like", "apple%"]} + // => `status="Open" and name like "apple%" + + // {"date": [">=", "2017-09-09", "<=", "2017-11-01"]} + // => `date >= 2017-09-09 and date <= 2017-11-01` + + let filtersArray = []; + + for (let field in filters) { + let value = filters[field]; + let operator = '='; + let comparisonValue = value; + + if (Array.isArray(value)) { + operator = value[0]; + comparisonValue = value[1]; + operator = operator.toLowerCase(); + + if (operator === 'includes') { + operator = 'like'; + } + + if (operator === 'like' && !comparisonValue.includes('%')) { + comparisonValue = `%${comparisonValue}%`; + } + } + + filtersArray.push([field, operator, comparisonValue]); + + if (Array.isArray(value) && value.length > 2) { + // multiple conditions + let operator = value[2]; + let comparisonValue = value[3]; + filtersArray.push([field, operator, comparisonValue]); + } + } + + filtersArray.map((filter) => { + const [field, operator, comparisonValue] = filter; + if (operator === '=') { + builder.where(field, comparisonValue); + } else { + builder.where(field, operator, comparisonValue); + } + }); + } + + run(query, params) { + // run query + return this.sql(query, params); + } + + sql(query, params) { + // run sql + return this.knex.raw(query, params); + } + + async commit() { + try { + await this.sql('commit'); + } catch (e) { + if (e.type !== frappe.errors.CannotCommitError) { + throw e; + } + } + } + + clearValueCache(doctype, name) { + let cacheKey = `${doctype}:${name}`; + this.cache.hclear(cacheKey); + } + + getColumnType(field) { + return this.typeMap[field.fieldtype]; + } + + getError(err) { + return frappe.errors.DatabaseError; + } + + initTypeMap() { + this.typeMap = {}; + } + + executePostDbConnect() { + frappe.initializeMoneyMaker(); + } +}; diff --git a/frappe/backends/http.js b/frappe/backends/http.js new file mode 100644 index 00000000..8753fa38 --- /dev/null +++ b/frappe/backends/http.js @@ -0,0 +1,230 @@ +const frappe = require('frappejs'); +const Observable = require('frappejs/utils/observable'); +const triggerEvent = name => frappe.events.trigger(`http:${name}`); + +module.exports = class HTTPClient extends Observable { + constructor({ server, protocol = 'http' }) { + super(); + + this.server = server; + this.protocol = protocol; + frappe.config.serverURL = this.getURL(); + + // if the backend is http, then always client! + frappe.isServer = false; + + this.initTypeMap(); + } + + connect() { + + } + + async insert(doctype, doc) { + doc.doctype = doctype; + let filesToUpload = this.getFilesToUpload(doc); + let url = this.getURL('/api/resource', doctype); + + const responseDoc = await this.fetch(url, { + method: 'POST', + body: JSON.stringify(doc) + }); + + await this.uploadFilesAndUpdateDoc(filesToUpload, doctype, responseDoc); + + return responseDoc; + } + + async get(doctype, name) { + name = encodeURIComponent(name); + let url = this.getURL('/api/resource', doctype, name); + return await this.fetch(url, { + method: 'GET', + headers: this.getHeaders() + }) + } + + async getAll({ doctype, fields, filters, start, limit, sortBy, order }) { + let url = this.getURL('/api/resource', doctype); + + url = url + '?' + frappe.getQueryString({ + fields: JSON.stringify(fields), + filters: JSON.stringify(filters), + start: start, + limit: limit, + sortBy: sortBy, + order: order + }); + + return await this.fetch(url, { + method: 'GET', + }); + } + + async update(doctype, doc) { + doc.doctype = doctype; + let filesToUpload = this.getFilesToUpload(doc); + let url = this.getURL('/api/resource', doctype, doc.name); + + const responseDoc = await this.fetch(url, { + method: 'PUT', + body: JSON.stringify(doc) + }); + + await this.uploadFilesAndUpdateDoc(filesToUpload, doctype, responseDoc); + + return responseDoc; + } + + async delete(doctype, name) { + let url = this.getURL('/api/resource', doctype, name); + + return await this.fetch(url, { + method: 'DELETE', + }); + } + + async deleteMany(doctype, names) { + let url = this.getURL('/api/resource', doctype); + + return await this.fetch(url, { + method: 'DELETE', + body: JSON.stringify(names) + }); + } + + async exists(doctype, name) { + return (await this.getValue(doctype, name, 'name')) ? true : false; + } + + async getValue(doctype, name, fieldname) { + let url = this.getURL('/api/resource', doctype, name, fieldname); + + return (await this.fetch(url, { + method: 'GET', + })).value; + } + + async fetch(url, args) { + triggerEvent('ajaxStart'); + + args.headers = this.getHeaders(); + let response = await frappe.fetch(url, args); + + triggerEvent('ajaxStop'); + + if (response.status === 200) { + let data = await response.json(); + return data; + } + + if (response.status === 401) { + triggerEvent('unauthorized'); + } + + throw Error(await response.text()); + } + + getFilesToUpload(doc) { + const meta = frappe.getMeta(doc.doctype); + const fileFields = meta.getFieldsWith({ fieldtype: 'File' }); + const filesToUpload = []; + + if (fileFields.length > 0) { + fileFields.forEach(df => { + const files = doc[df.fieldname] || []; + if (files.length) { + filesToUpload.push({ + fieldname: df.fieldname, + files: files + }) + } + delete doc[df.fieldname]; + }); + } + + return filesToUpload; + } + + async uploadFilesAndUpdateDoc(filesToUpload, doctype, doc) { + if (filesToUpload.length > 0) { + // upload files + for (const fileToUpload of filesToUpload) { + const files = await this.uploadFiles(fileToUpload.files, doctype, doc.name, fileToUpload.fieldname); + doc[fileToUpload.fieldname] = files[0].name; + } + } + } + + async uploadFiles(fileList, doctype, name, fieldname) { + let url = this.getURL('/api/upload', doctype, name, fieldname); + + let formData = new FormData(); + for (const file of fileList) { + formData.append('files', file, file.name); + } + + let response = await frappe.fetch(url, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + if (response.status !== 200) { + throw Error(data.error); + } + return data; + } + + getURL(...parts) { + return this.protocol + '://' + this.server + (parts || []).join('/'); + } + + getHeaders() { + const headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + if (frappe.session && frappe.session.token) { + headers.token = frappe.session.token; + }; + return headers; + } + + initTypeMap() { + this.typeMap = { + 'AutoComplete': true + , 'Currency': true + , 'Int': true + , 'Float': true + , 'Percent': true + , 'Check': true + , 'Small Text': true + , 'Long Text': true + , 'Code': true + , 'Text Editor': true + , 'Date': true + , 'Datetime': true + , 'Time': true + , 'Text': true + , 'Data': true + , 'Link': true + , 'DynamicLink': true + , 'Password': true + , 'Select': true + , 'Read Only': true + , 'File': true + , 'Attach': true + , 'Attach Image': true + , 'Signature': true + , 'Color': true + , 'Barcode': true + , 'Geolocation': true + } + } + + close() { + + } + +} diff --git a/frappe/backends/mysql.js b/frappe/backends/mysql.js new file mode 100644 index 00000000..891a6539 --- /dev/null +++ b/frappe/backends/mysql.js @@ -0,0 +1,210 @@ +const frappe = require('frappejs'); +const mysql = require('mysql'); +const Database = require('./database'); +const debug = false; + + +module.exports = class mysqlDatabase extends Database{ + constructor({ db_name, username, password, host }) { + super(); + this.db_name = db_name; + this.username = username; + this.password = password; + this.host = host; + this.init_typeMap(); + } + + connect(db_name) { + if (db_name) { + this.db_name = db_name; + } + return new Promise(resolve => { + this.conn = new mysql.createConnection({ + host : this.host, + user : this.username, + password : this.password, + database : this.db_name + }); + () => { + if (debug) { + this.conn.on('trace', (trace) => console.log(trace)); + } + }; + resolve(); + }); + + } + + async tableExists(table) { + + const name = await this.sql(`SELECT table_name + FROM information_schema.tables + WHERE table_schema = '${this.db_name}' + AND table_name = '${table}'`); + return (name && name.length) ? true : false; + } + + async runCreateTableQuery(doctype, columns, values){ + const query = `CREATE TABLE IF NOT EXISTS ${doctype} ( + ${columns.join(", ")})`; + + return await this.run(query, values); + } + + + updateColumnDefinition(df, columns, indexes) { + columns.push(`${df.fieldname} ${this.typeMap[df.fieldtype]} ${df.required && !df.default ? "not null" : ""} ${df.default ? `default '${df.default}'` : ""}`); + } + + async getTableColumns(doctype) { + return (await this.sql(`SHOW COLUMNS FROM ${doctype}`)).map(d => d.Field); + } + + + async runAddColumnQuery(doctype, fields) { + await this.run(`ALTER TABLE ${doctype} ADD COLUMN ${this.get_column_definition(doctype)}`); + } + + getOne(doctype, name, fields = '*') { + + fields = this.prepareFields(fields); + + return new Promise((resolve, reject) => { + this.conn.get(`select ${fields} from ${doctype} + where name = ?`, name, + (err, row) => { + resolve(row || {}); + }); + }); + } + + async insertOne(doctype, doc) { + let fields = this.get_keys(doctype); + let placeholders = fields.map(d => '?').join(', '); + + if (!doc.name) { + doc.name = frappe.getRandomString(); + } + + return await this.run(`insert into ${doctype} + (${fields.map(field => field.fieldname).join(", ")}) + values (${placeholders})`, this.getFormattedValues(fields, doc)); + } + + async updateOne(doctype, doc) { + let fields = this.getKeys(doctype); + let assigns = fields.map(field => `${field.fieldname} = ?`); + let values = this.getFormattedValues(fields, doc); + + // additional name for where clause + values.push(doc.name); + + return await this.run(`update ${doctype} + set ${assigns.join(", ")} where name=?`, values); + } + + async runDeleteOtherChildren(field, added) { + await this.run(`delete from ${field.childtype} + where + parent = ? and + name not in (${added.slice(1).map(d => '?').join(', ')})`, added); + } + + async deleteOne(doctype, name) { + return await this.run(`delete from ${doctype} where name=?`, name); + } + + async deleteChildren(parenttype, parent) { + await this.run(`delete from ${parent} where parent=?`, parent); + } + + + getAll({ doctype, fields, filters, start, limit, order_by = 'modified', order = 'desc' } = {}) { + if (!fields) { + fields = frappe.getMeta(doctype).getKeywordFields(); + } + return new Promise((resolve, reject) => { + let conditions = this.getFilterConditions(filters); + + this.conn.all(`select ${fields.join(", ")} + from ${doctype} + ${conditions.conditions ? "where" : ""} ${conditions.conditions} + ${order_by ? ("order by " + order_by) : ""} ${order_by ? (order || "asc") : ""} + ${limit ? ("limit " + limit) : ""} ${start ? ("offset " + start) : ""}`, conditions.values, + (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); + } + + run(query, params) { + // TODO promisify + return new Promise((resolve, reject) => { + this.conn.query(query, params, (err) => { + if (err) { + if (debug) { + console.error(err); + } + reject(err); + } else { + resolve(); + } + }); + }); + } + + sql(query, params) { + return new Promise((resolve) => { + this.conn.query(query, params, (err, rows) => { + resolve(rows); + }); + }); + } + + async commit() { + try { + await this.run('commit'); + } catch (e) { + if (e.errno !== 1) { + throw e; + } + } + } + + + init_typeMap() { + this.typeMap = { + 'AutoComplete': 'VARCHAR(140)' + , 'Currency': 'real' + , 'Int': 'INT' + , 'Float': 'decimal(18,6)' + , 'Percent': 'real' + , 'Check': 'INT(1)' + , 'Small Text': 'text' + , 'Long Text': 'text' + , 'Code': 'text' + , 'Text Editor': 'text' + , 'Date': 'DATE' + , 'Datetime': 'DATETIME' + , 'Time': 'TIME' + , 'Text': 'text' + , 'Data': 'VARCHAR(140)' + , 'Link': ' varchar(140)' + , 'DynamicLink': 'text' + , 'Password': 'varchar(140)' + , 'Select': 'VARCHAR(140)' + , 'Read Only': 'varchar(140)' + , 'File': 'text' + , 'Attach': 'text' + , 'Attach Image': 'text' + , 'Signature': 'text' + , 'Color': 'text' + , 'Barcode': 'text' + , 'Geolocation': 'text' + } + } +} diff --git a/frappe/backends/sqlite.js b/frappe/backends/sqlite.js new file mode 100644 index 00000000..7d7911ed --- /dev/null +++ b/frappe/backends/sqlite.js @@ -0,0 +1,120 @@ +const frappe = require('frappejs'); +const Database = require('./database'); + +class SqliteDatabase extends Database { + constructor({ dbPath }) { + super(); + this.dbPath = dbPath; + this.connectionParams = { + client: 'sqlite3', + connection: { + filename: this.dbPath, + }, + pool: { + afterCreate(conn, done) { + conn.run('PRAGMA foreign_keys=ON'); + done(); + }, + }, + useNullAsDefault: true, + asyncStackTraces: process.env.NODE_ENV === 'development', + }; + } + + async addForeignKeys(doctype, newForeignKeys) { + await this.sql('PRAGMA foreign_keys=OFF'); + await this.sql('BEGIN TRANSACTION'); + + const tempName = 'TEMP' + doctype; + + // create temp table + await this.createTable(doctype, tempName); + + // copy from old to new table + await this.knex(tempName).insert(this.knex.select().from(doctype)); + + // drop old table + await this.knex.schema.dropTable(doctype); + + // rename new table + await this.knex.schema.renameTable(tempName, doctype); + + await this.sql('COMMIT'); + await this.sql('PRAGMA foreign_keys=ON'); + } + + removeColumns() { + // pass + } + + async getTableColumns(doctype) { + return (await this.sql(`PRAGMA table_info(${doctype})`)).map((d) => d.name); + } + + async getForeignKeys(doctype) { + return (await this.sql(`PRAGMA foreign_key_list(${doctype})`)).map( + (d) => d.from + ); + } + + initTypeMap() { + // prettier-ignore + this.typeMap = { + 'AutoComplete': 'text', + 'Currency': 'text', + 'Int': 'integer', + 'Float': 'float', + 'Percent': 'float', + 'Check': 'integer', + 'Small Text': 'text', + 'Long Text': 'text', + 'Code': 'text', + 'Text Editor': 'text', + 'Date': 'text', + 'Datetime': 'text', + 'Time': 'text', + 'Text': 'text', + 'Data': 'text', + 'Link': 'text', + 'DynamicLink': 'text', + 'Password': 'text', + 'Select': 'text', + 'Read Only': 'text', + 'File': 'text', + 'Attach': 'text', + 'AttachImage': 'text', + 'Signature': 'text', + 'Color': 'text', + 'Barcode': 'text', + 'Geolocation': 'text' + }; + } + + getError(err) { + let errorType = frappe.errors.DatabaseError; + if (err.message.includes('FOREIGN KEY')) { + errorType = frappe.errors.LinkValidationError; + } + if (err.message.includes('SQLITE_ERROR: cannot commit')) { + errorType = frappe.errors.CannotCommitError; + } + if (err.message.includes('SQLITE_CONSTRAINT: UNIQUE constraint failed:')) { + errorType = frappe.errors.DuplicateEntryError; + } + return errorType; + } + + async prestigeTheTable(tableName, tableRows) { + // Alter table hacx for sqlite in case of schema change. + const tempName = `__${tableName}`; + await this.knex.schema.dropTableIfExists(tempName); + await this.knex.raw('PRAGMA foreign_keys=OFF'); + await this.createTable(tableName, tempName); + await this.knex.batchInsert(tempName, tableRows); + await this.knex.schema.dropTable(tableName); + await this.knex.schema.renameTable(tempName, tableName); + await this.knex.raw('PRAGMA foreign_keys=ON'); + } +} + +module.exports = SqliteDatabase; diff --git a/frappe/common/errors.js b/frappe/common/errors.js new file mode 100644 index 00000000..9771c444 --- /dev/null +++ b/frappe/common/errors.js @@ -0,0 +1,101 @@ +const frappe = require('frappejs'); + +class BaseError extends Error { + constructor(statusCode, message) { + super(message); + this.name = 'BaseError'; + this.statusCode = statusCode; + this.message = message; + } +} + +class ValidationError extends BaseError { + constructor(message) { + super(417, message); + this.name = 'ValidationError'; + } +} + +class NotFoundError extends BaseError { + constructor(message) { + super(404, message); + this.name = 'NotFoundError'; + } +} + +class ForbiddenError extends BaseError { + constructor(message) { + super(403, message); + this.name = 'ForbiddenError'; + } +} + +class DuplicateEntryError extends ValidationError { + constructor(message) { + super(message); + this.name = 'DuplicateEntryError'; + } +} + +class LinkValidationError extends ValidationError { + constructor(message) { + super(message); + this.name = 'LinkValidationError'; + } +} + +class MandatoryError extends ValidationError { + constructor(message) { + super(message); + this.name = 'MandatoryError'; + } +} + +class DatabaseError extends BaseError { + constructor(message) { + super(500, message); + this.name = 'DatabaseError'; + } +} + +class CannotCommitError extends DatabaseError { + constructor(message) { + super(message); + this.name = 'CannotCommitError'; + } +} + +class ValueError extends ValidationError {} +class Conflict extends ValidationError {} +class InvalidFieldError extends ValidationError {} + +function throwError(message, error = 'ValidationError') { + const errorClass = { + ValidationError: ValidationError, + NotFoundError: NotFoundError, + ForbiddenError: ForbiddenError, + ValueError: ValueError, + Conflict: Conflict + }; + const err = new errorClass[error](message); + frappe.events.trigger('throw', { message, stackTrace: err.stack }); + throw err; +} + +frappe.throw = throwError; + +module.exports = { + BaseError, + ValidationError, + ValueError, + Conflict, + NotFoundError, + ForbiddenError, + DuplicateEntryError, + LinkValidationError, + DatabaseError, + CannotCommitError, + MandatoryError, + InvalidFieldError, + throw: throwError +}; diff --git a/frappe/common/index.js b/frappe/common/index.js new file mode 100644 index 00000000..50e71fba --- /dev/null +++ b/frappe/common/index.js @@ -0,0 +1,15 @@ +const utils = require('../utils'); +const format = require('../utils/format'); +const errors = require('./errors'); +const BaseDocument = require('frappejs/model/document'); +const BaseMeta = require('frappejs/model/meta'); + +module.exports = { + initLibs(frappe) { + Object.assign(frappe, utils); + Object.assign(frappe, format); + frappe.errors = errors; + frappe.BaseDocument = BaseDocument; + frappe.BaseMeta = BaseMeta; + }, +}; diff --git a/frappe/index.js b/frappe/index.js new file mode 100644 index 00000000..efcf938e --- /dev/null +++ b/frappe/index.js @@ -0,0 +1,372 @@ +const Observable = require('./utils/observable'); +const utils = require('./utils'); +const { getMoneyMaker } = require('pesa'); +const { + DEFAULT_INTERNAL_PRECISION, + DEFAULT_DISPLAY_PRECISION, +} = require('./utils/consts'); + +module.exports = { + initializeAndRegister(customModels = {}, force = false) { + this.init(force); + const common = require('frappejs/common'); + this.registerLibs(common); + const coreModels = require('frappejs/models'); + this.registerModels(coreModels); + this.registerModels(customModels); + }, + + async initializeMoneyMaker(currency) { + currency ??= 'XXX'; + + // to be called after db initialization + const values = + (await frappe.db?.getSingleValues( + { + fieldname: 'internalPrecision', + parent: 'SystemSettings', + }, + { + fieldname: 'displayPrecision', + parent: 'SystemSettings', + } + )) ?? []; + + let { internalPrecision: precision, displayPrecision: display } = + values.reduce((acc, { fieldname, value }) => { + acc[fieldname] = value; + return acc; + }, {}); + + if (typeof precision === 'undefined') { + precision = DEFAULT_INTERNAL_PRECISION; + } + + if (typeof precision === 'string') { + precision = parseInt(precision); + } + + if (typeof display === 'undefined') { + display = DEFAULT_DISPLAY_PRECISION; + } + + if (typeof display === 'string') { + display = parseInt(display); + } + + this.pesa = getMoneyMaker({ currency, precision, display }); + }, + + init(force) { + if (this._initialized && !force) return; + this.initConfig(); + this.initGlobals(); + this.docs = new Observable(); + this.events = new Observable(); + this._initialized = true; + }, + + initConfig() { + this.config = { + serverURL: '', + backend: 'sqlite', + port: 8000, + }; + }, + + initGlobals() { + this.metaCache = {}; + this.models = {}; + this.forms = {}; + this.views = {}; + this.flags = {}; + this.methods = {}; + this.errorLog = []; + // temp params while calling routes + this.params = {}; + }, + + registerLibs(common) { + // add standard libs and utils to frappe + common.initLibs(this); + }, + + registerModels(models) { + // register models from app/models/index.js + for (let doctype in models) { + let metaDefinition = models[doctype]; + if (!metaDefinition.name) { + throw new Error(`Name is mandatory for ${doctype}`); + } + if (metaDefinition.name !== doctype) { + throw new Error( + `Model name mismatch for ${doctype}: ${metaDefinition.name}` + ); + } + let fieldnames = (metaDefinition.fields || []) + .map((df) => df.fieldname) + .sort(); + let duplicateFieldnames = utils.getDuplicates(fieldnames); + if (duplicateFieldnames.length > 0) { + throw new Error( + `Duplicate fields in ${doctype}: ${duplicateFieldnames.join(', ')}` + ); + } + + this.models[doctype] = metaDefinition; + } + }, + + getModels(filterFunction) { + let models = []; + for (let doctype in this.models) { + models.push(this.models[doctype]); + } + return filterFunction ? models.filter(filterFunction) : models; + }, + + registerView(view, name, module) { + if (!this.views[view]) this.views[view] = {}; + this.views[view][name] = module; + }, + + registerMethod({ method, handler }) { + this.methods[method] = handler; + if (this.app) { + // add to router if client-server + this.app.post( + `/api/method/${method}`, + this.asyncHandler(async function (request, response) { + let data = await handler(request.body); + if (data === undefined) { + data = {}; + } + return response.json(data); + }) + ); + } + }, + + async call({ method, args }) { + if (this.isServer) { + if (this.methods[method]) { + return await this.methods[method](args); + } else { + throw new Error(`${method} not found`); + } + } + + let url = `/api/method/${method}`; + let response = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(args || {}), + }); + return await response.json(); + }, + + addToCache(doc) { + if (!this.docs) return; + + // add to `docs` cache + if (doc.doctype && doc.name) { + if (!this.docs[doc.doctype]) { + this.docs[doc.doctype] = {}; + } + this.docs[doc.doctype][doc.name] = doc; + + // singles available as first level objects too + if (doc.doctype === doc.name) { + this[doc.name] = doc; + } + + // propogate change to `docs` + doc.on('change', (params) => { + this.docs.trigger('change', params); + }); + } + }, + + removeFromCache(doctype, name) { + try { + delete this.docs[doctype][name]; + } catch (e) { + console.warn(`Document ${doctype} ${name} does not exist`); + } + }, + + isDirty(doctype, name) { + return ( + (this.docs && + this.docs[doctype] && + this.docs[doctype][name] && + this.docs[doctype][name]._dirty) || + false + ); + }, + + getDocFromCache(doctype, name) { + if (this.docs && this.docs[doctype] && this.docs[doctype][name]) { + return this.docs[doctype][name]; + } + }, + + getMeta(doctype) { + if (!this.metaCache[doctype]) { + let model = this.models[doctype]; + if (!model) { + throw new Error(`${doctype} is not a registered doctype`); + } + let metaClass = model.metaClass || this.BaseMeta; + this.metaCache[doctype] = new metaClass(model); + } + + return this.metaCache[doctype]; + }, + + async getDoc(doctype, name, options = {skipDocumentCache: false}) { + let doc = options.skipDocumentCache ? null : this.getDocFromCache(doctype, name); + if (!doc) { + doc = new (this.getDocumentClass(doctype))({ + doctype: doctype, + name: name, + }); + await doc.load(); + this.addToCache(doc); + } + return doc; + }, + + getDocumentClass(doctype) { + const meta = this.getMeta(doctype); + return meta.documentClass || this.BaseDocument; + }, + + async getSingle(doctype) { + return await this.getDoc(doctype, doctype); + }, + + async getDuplicate(doc) { + const newDoc = await this.getNewDoc(doc.doctype); + for (let field of this.getMeta(doc.doctype).getValidFields()) { + if (['name', 'submitted'].includes(field.fieldname)) continue; + if (field.fieldtype === 'Table') { + newDoc[field.fieldname] = (doc[field.fieldname] || []).map((d) => { + let newd = Object.assign({}, d); + newd.name = ''; + return newd; + }); + } else { + newDoc[field.fieldname] = doc[field.fieldname]; + } + } + return newDoc; + }, + + getNewDoc(doctype) { + let doc = this.newDoc({ doctype: doctype }); + doc._notInserted = true; + doc.name = frappe.getRandomString(); + this.addToCache(doc); + return doc; + }, + + async newCustomDoc(fields) { + let doc = new this.BaseDocument({ isCustom: 1, fields }); + doc._notInserted = true; + doc.name = this.getRandomString(); + this.addToCache(doc); + return doc; + }, + + createMeta(fields) { + let meta = new this.BaseMeta({ isCustom: 1, fields }); + return meta; + }, + + newDoc(data) { + let doc = new (this.getDocumentClass(data.doctype))(data); + doc.setDefaults(); + return doc; + }, + + async insert(data) { + return await this.newDoc(data).insert(); + }, + + async syncDoc(data) { + let doc; + if (await this.db.exists(data.doctype, data.name)) { + doc = await this.getDoc(data.doctype, data.name); + Object.assign(doc, data); + await doc.update(); + } else { + doc = this.newDoc(data); + await doc.insert(); + } + }, + + // only for client side + async login(email, password) { + if (email === 'Administrator') { + this.session = { + user: 'Administrator', + }; + return; + } + + let response = await fetch(this.getServerURL() + '/api/login', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, password }), + }); + + if (response.status === 200) { + const res = await response.json(); + + this.session = { + user: email, + token: res.token, + }; + + return res; + } + + return response; + }, + + async signup(email, fullName, password) { + let response = await fetch(this.getServerURL() + '/api/signup', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, fullName, password }), + }); + + if (response.status === 200) { + return await response.json(); + } + + return response; + }, + + getServerURL() { + return this.config.serverURL || ''; + }, + + close() { + this.db.close(); + + if (this.server) { + this.server.close(); + } + }, +}; diff --git a/frappe/model/document.js b/frappe/model/document.js new file mode 100644 index 00000000..fbaa8eeb --- /dev/null +++ b/frappe/model/document.js @@ -0,0 +1,725 @@ +const frappe = require('frappejs'); +const Observable = require('frappejs/utils/observable'); +const naming = require('./naming'); +const { isPesa } = require('../utils/index'); +const { DEFAULT_INTERNAL_PRECISION } = require('../utils/consts'); + +module.exports = class BaseDocument extends Observable { + constructor(data) { + super(); + this.fetchValuesCache = {}; + this.flags = {}; + this.setup(); + this.setValues(data); + } + + setup() { + // add listeners + } + + setValues(data) { + for (let fieldname in data) { + let value = data[fieldname]; + if (fieldname.startsWith('_')) { + // private property + this[fieldname] = value; + } else if (Array.isArray(value)) { + for (let row of value) { + this.push(fieldname, row); + } + } else { + this[fieldname] = value; + } + } + // set unset fields as null + for (let field of this.meta.getValidFields()) { + // check for null or undefined + if (this[field.fieldname] == null) { + this[field.fieldname] = null; + } + } + } + + get meta() { + if (this.isCustom) { + this._meta = frappe.createMeta(this.fields); + } + if (!this._meta) { + this._meta = frappe.getMeta(this.doctype); + } + return this._meta; + } + + async getSettings() { + if (!this._settings) { + this._settings = await frappe.getSingle(this.meta.settings); + } + return this._settings; + } + + // set value and trigger change + async set(fieldname, value) { + if (typeof fieldname === 'object') { + const valueDict = fieldname; + for (let fieldname in valueDict) { + await this.set(fieldname, valueDict[fieldname]); + } + return; + } + + if (this[fieldname] !== value) { + this._dirty = true; + // if child is dirty, parent is dirty too + if (this.meta.isChild && this.parentdoc) { + this.parentdoc._dirty = true; + } + + if (Array.isArray(value)) { + this[fieldname] = []; + value.forEach((row, i) => { + this.append(fieldname, row); + row.idx = i; + }); + } else { + await this.validateField(fieldname, value); + this[fieldname] = value; + } + + // always run applyChange from the parentdoc + if (this.meta.isChild && this.parentdoc) { + await this.applyChange(fieldname); + await this.parentdoc.applyChange(this.parentfield); + } else { + await this.applyChange(fieldname); + } + } + } + + async applyChange(fieldname) { + await this.applyFormula(fieldname); + this.roundFloats(); + await this.trigger('change', { + doc: this, + changed: fieldname, + }); + } + + setDefaults() { + for (let field of this.meta.fields) { + if (this[field.fieldname] == null) { + let defaultValue = getPreDefaultValues(field.fieldtype); + + if (typeof field.default === 'function') { + defaultValue = field.default(this); + } else if (field.default !== undefined) { + defaultValue = field.default; + } + + if (field.fieldtype === 'Currency' && !isPesa(defaultValue)) { + defaultValue = frappe.pesa(defaultValue); + } + + this[field.fieldname] = defaultValue; + } + } + + if (this.meta.basedOn && this.meta.filters) { + this.setValues(this.meta.filters); + } + } + + castValues() { + for (let field of this.meta.fields) { + let value = this[field.fieldname]; + if (value == null) { + continue; + } + if (['Int', 'Check'].includes(field.fieldtype)) { + value = parseInt(value, 10); + } else if (field.fieldtype === 'Float') { + value = parseFloat(value); + } else if (field.fieldtype === 'Currency' && !isPesa(value)) { + value = frappe.pesa(value); + } + this[field.fieldname] = value; + } + } + + setKeywords() { + let keywords = []; + for (let fieldname of this.meta.getKeywordFields()) { + keywords.push(this[fieldname]); + } + this.keywords = keywords.join(', '); + } + + append(key, document = {}) { + // push child row and trigger change + this.push(key, document); + this._dirty = true; + this.applyChange(key); + } + + push(key, document = {}) { + // push child row without triggering change + if (!this[key]) { + this[key] = []; + } + this[key].push(this._initChild(document, key)); + } + + _initChild(data, key) { + if (data instanceof BaseDocument) { + return data; + } + + data.doctype = this.meta.getField(key).childtype; + data.parent = this.name; + data.parenttype = this.doctype; + data.parentfield = key; + data.parentdoc = this; + + if (!data.idx) { + data.idx = (this[key] || []).length; + } + + if (!data.name) { + data.name = frappe.getRandomString(); + } + + const childDoc = new BaseDocument(data); + childDoc.setDefaults(); + return childDoc; + } + + validateInsert() { + this.validateMandatory(); + this.validateFields(); + } + + validateMandatory() { + let checkForMandatory = [this]; + let tableFields = this.meta.fields.filter((df) => df.fieldtype === 'Table'); + tableFields.map((df) => { + let rows = this[df.fieldname]; + checkForMandatory = [...checkForMandatory, ...rows]; + }); + + let missingMandatory = checkForMandatory + .map((doc) => getMissingMandatory(doc)) + .filter(Boolean); + + if (missingMandatory.length > 0) { + let fields = missingMandatory.join('\n'); + let message = frappe._('Value missing for {0}', fields); + throw new frappe.errors.MandatoryError(message); + } + + function getMissingMandatory(doc) { + let mandatoryFields = doc.meta.fields.filter((df) => { + if (df.required instanceof Function) { + return df.required(doc); + } + return df.required; + }); + let message = mandatoryFields + .filter((df) => { + let value = doc[df.fieldname]; + if (df.fieldtype === 'Table') { + return value == null || value.length === 0; + } + return value == null || value === ''; + }) + .map((df) => { + return `"${df.label}"`; + }) + .join(', '); + + if (message && doc.meta.isChild) { + let parentfield = doc.parentdoc.meta.getField(doc.parentfield); + message = `${parentfield.label} Row ${doc.idx + 1}: ${message}`; + } + + return message; + } + } + + async validateFields() { + let fields = this.meta.fields; + for (let field of fields) { + await this.validateField(field.fieldname, this.get(field.fieldname)); + } + } + + async validateField(key, value) { + let field = this.meta.getField(key); + if (!field) { + throw new frappe.errors.InvalidFieldError(`Invalid field ${key}`); + } + if (field.fieldtype == 'Select') { + this.meta.validateSelect(field, value); + } + if (field.validate && value != null) { + let validator = null; + if (typeof field.validate === 'object') { + validator = this.getValidateFunction(field.validate); + } + if (typeof field.validate === 'function') { + validator = field.validate; + } + if (validator) { + await validator(value, this); + } + } + } + + getValidateFunction(validator) { + let functions = { + email(value) { + let isValid = /(.+)@(.+){2,}\.(.+){2,}/.test(value); + if (!isValid) { + throw new frappe.errors.ValidationError(`Invalid email: ${value}`); + } + }, + phone(value) { + let isValid = /[+]{0,1}[\d ]+/.test(value); + if (!isValid) { + throw new frappe.errors.ValidationError(`Invalid phone: ${value}`); + } + }, + }; + + return functions[validator.type]; + } + + getValidDict() { + let data = {}; + for (let field of this.meta.getValidFields()) { + let value = this[field.fieldname]; + if (Array.isArray(value)) { + value = value.map((doc) => + doc.getValidDict ? doc.getValidDict() : doc + ); + } + data[field.fieldname] = value; + } + return data; + } + + setStandardValues() { + // set standard values on server-side only + if (frappe.isServer) { + if (this.isSubmittable && this.submitted == null) { + this.submitted = 0; + } + + let now = new Date().toISOString(); + if (!this.owner) { + this.owner = frappe.session.user; + } + + if (!this.creation) { + this.creation = now; + } + + this.updateModified(); + } + } + + updateModified() { + if (frappe.isServer) { + let now = new Date().toISOString(); + this.modifiedBy = frappe.session.user; + this.modified = now; + } + } + + async load() { + let data = await frappe.db.get(this.doctype, this.name); + if (data && data.name) { + this.syncValues(data); + if (this.meta.isSingle) { + this.setDefaults(); + this.castValues(); + } + await this.loadLinks(); + } else { + throw new frappe.errors.NotFoundError( + `Not Found: ${this.doctype} ${this.name}` + ); + } + } + + async loadLinks() { + this._links = {}; + let inlineLinks = this.meta.fields.filter((df) => df.inline); + for (let df of inlineLinks) { + await this.loadLink(df.fieldname); + } + } + + async loadLink(fieldname) { + this._links = this._links || {}; + let df = this.meta.getField(fieldname); + if (this[df.fieldname]) { + this._links[df.fieldname] = await frappe.getDoc( + df.target, + this[df.fieldname] + ); + } + } + + getLink(fieldname) { + return this._links ? this._links[fieldname] : null; + } + + syncValues(data) { + this.clearValues(); + this.setValues(data); + this._dirty = false; + this.trigger('change', { + doc: this, + }); + } + + clearValues() { + let toClear = ['_dirty', '_notInserted'].concat( + this.meta.getValidFields().map((df) => df.fieldname) + ); + for (let key of toClear) { + this[key] = null; + } + } + + setChildIdx() { + // renumber children + for (let field of this.meta.getValidFields()) { + if (field.fieldtype === 'Table') { + for (let i = 0; i < (this[field.fieldname] || []).length; i++) { + this[field.fieldname][i].idx = i; + } + } + } + } + + async compareWithCurrentDoc() { + if (frappe.isServer && !this.isNew()) { + let currentDoc = await frappe.db.get(this.doctype, this.name); + + // check for conflict + if (currentDoc && this.modified != currentDoc.modified) { + throw new frappe.errors.Conflict( + frappe._('Document {0} {1} has been modified after loading', [ + this.doctype, + this.name, + ]) + ); + } + + if (this.submitted && !this.meta.isSubmittable) { + throw new frappe.errors.ValidationError( + frappe._('Document type {1} is not submittable', [this.doctype]) + ); + } + + // set submit action flag + this.flags = {}; + if (this.submitted && !currentDoc.submitted) { + this.flags.submitAction = true; + } + + if (currentDoc.submitted && !this.submitted) { + this.flags.revertAction = true; + } + } + } + + async applyFormula(fieldname) { + if (!this.meta.hasFormula()) { + return false; + } + + let doc = this; + let changed = false; + + // children + for (let tablefield of this.meta.getTableFields()) { + let formulaFields = frappe + .getMeta(tablefield.childtype) + .getFormulaFields(); + if (formulaFields.length) { + const value = this[tablefield.fieldname] || []; + for (let row of value) { + for (let field of formulaFields) { + if (shouldApplyFormula(field, row)) { + let val = await this.getValueFromFormula(field, row); + let previousVal = row[field.fieldname]; + if (val !== undefined && previousVal !== val) { + row[field.fieldname] = val; + changed = true; + } + } + } + } + } + } + + // parent or child row + for (let field of this.meta.getFormulaFields()) { + if (shouldApplyFormula(field, doc)) { + let previousVal = doc[field.fieldname]; + let val = await this.getValueFromFormula(field, doc); + if (val !== undefined && previousVal !== val) { + doc[field.fieldname] = val; + changed = true; + } + } + } + + return changed; + + function shouldApplyFormula(field, doc) { + if (field.readOnly) { + return true; + } + if ( + fieldname && + field.formulaDependsOn && + field.formulaDependsOn.includes(fieldname) + ) { + return true; + } + + if (!frappe.isServer || frappe.isElectron) { + if (doc[field.fieldname] == null || doc[field.fieldname] == '') { + return true; + } + } + return false; + } + } + + async getValueFromFormula(field, doc) { + let value; + + if (doc.meta.isChild) { + value = await field.formula(doc, doc.parentdoc); + } else { + value = await field.formula(doc); + } + + if (value === undefined) { + return; + } + + if ('Float' === field.fieldtype) { + value = this.round(value, field); + } + + if (field.fieldtype === 'Table' && Array.isArray(value)) { + value = value.map((row) => { + let doc = this._initChild(row, field.fieldname); + doc.roundFloats(); + return doc; + }); + } + + return value; + } + + roundFloats() { + let fields = this.meta + .getValidFields() + .filter((df) => ['Float', 'Table'].includes(df.fieldtype)); + + for (let df of fields) { + let value = this[df.fieldname]; + if (value == null) { + continue; + } + // child + if (Array.isArray(value)) { + value.map((row) => row.roundFloats()); + continue; + } + // field + let roundedValue = this.round(value, df); + if (roundedValue && value !== roundedValue) { + this[df.fieldname] = roundedValue; + } + } + } + + async setName() { + await naming.setName(this); + } + + async commit() { + // re-run triggers + this.setKeywords(); + this.setChildIdx(); + await this.applyFormula(); + await this.trigger('validate'); + } + + async insert() { + await this.setName(); + this.setStandardValues(); + await this.commit(); + await this.validateInsert(); + await this.trigger('beforeInsert'); + + let oldName = this.name; + const data = await frappe.db.insert(this.doctype, this.getValidDict()); + this.syncValues(data); + + if (oldName !== this.name) { + frappe.removeFromCache(this.doctype, oldName); + } + + await this.trigger('afterInsert'); + await this.trigger('afterSave'); + + return this; + } + + async update(...args) { + if (args.length) { + await this.set(...args); + } + await this.compareWithCurrentDoc(); + await this.commit(); + await this.trigger('beforeUpdate'); + + // before submit + if (this.flags.submitAction) await this.trigger('beforeSubmit'); + if (this.flags.revertAction) await this.trigger('beforeRevert'); + + // update modifiedBy and modified + this.updateModified(); + + const data = await frappe.db.update(this.doctype, this.getValidDict()); + this.syncValues(data); + + await this.trigger('afterUpdate'); + await this.trigger('afterSave'); + + // after submit + if (this.flags.submitAction) await this.trigger('afterSubmit'); + if (this.flags.revertAction) await this.trigger('afterRevert'); + + return this; + } + + async insertOrUpdate() { + if (this._notInserted) { + return await this.insert(); + } else { + return await this.update(); + } + } + + async delete() { + await this.trigger('beforeDelete'); + await frappe.db.delete(this.doctype, this.name); + await this.trigger('afterDelete'); + } + + async submitOrRevert(isSubmit) { + const wasSubmitted = this.submitted; + this.submitted = isSubmit; + try { + await this.update(); + } catch (e) { + this.submitted = wasSubmitted; + throw e; + } + } + + async submit() { + this.cancelled = 0; + await this.submitOrRevert(1); + } + + async revert() { + await this.submitOrRevert(0); + } + + async rename(newName) { + await this.trigger('beforeRename'); + await frappe.db.rename(this.doctype, this.name, newName); + this.name = newName; + await this.trigger('afterRename'); + } + + // trigger methods on the class if they match + // with the trigger name + async trigger(event, params) { + if (this[event]) { + await this[event](params); + } + await super.trigger(event, params); + } + + // helper functions + getSum(tablefield, childfield, convertToFloat = true) { + const sum = (this[tablefield] || []) + .map((d) => { + const value = d[childfield] ?? 0; + if (!isPesa(value)) { + try { + return frappe.pesa(value); + } catch (err) { + err.message += ` value: '${value}' of type: ${typeof value}, fieldname: '${tablefield}', childfield: '${childfield}'`; + throw err; + } + } + return value; + }) + .reduce((a, b) => a.add(b), frappe.pesa(0)); + + if (convertToFloat) { + return sum.float; + } + return sum; + } + + getFrom(doctype, name, fieldname) { + if (!name) return ''; + return frappe.db.getCachedValue(doctype, name, fieldname); + } + + round(value, df = null) { + if (typeof df === 'string') { + df = this.meta.getField(df); + } + const precision = + frappe.SystemSettings.internalPrecision ?? DEFAULT_INTERNAL_PRECISION; + return frappe.pesa(value).clip(precision).float; + } + + isNew() { + return this._notInserted; + } + + getFieldMetaMap() { + return this.meta.fields.reduce((obj, meta) => { + obj[meta.fieldname] = meta; + return obj; + }, {}); + } +}; + +function getPreDefaultValues(fieldtype) { + switch (fieldtype) { + case 'Table': + return []; + case 'Currency': + return frappe.pesa(0.0); + case 'Int': + case 'Float': + return 0; + default: + return null; + } +} diff --git a/frappe/model/index.js b/frappe/model/index.js new file mode 100644 index 00000000..415a5005 --- /dev/null +++ b/frappe/model/index.js @@ -0,0 +1,114 @@ +const cloneDeep = require('lodash/cloneDeep'); + +module.exports = { + extend: (base, target, options = {}) => { + base = cloneDeep(base); + const fieldsToMerge = (target.fields || []).map(df => df.fieldname); + const fieldsToRemove = options.skipFields || []; + const overrideProps = options.overrideProps || []; + for (let prop of overrideProps) { + if (base.hasOwnProperty(prop)) { + delete base[prop]; + } + } + + let mergeFields = (baseFields, targetFields) => { + let fields = cloneDeep(baseFields); + fields = fields + .filter(df => !fieldsToRemove.includes(df.fieldname)) + .map(df => { + if (fieldsToMerge.includes(df.fieldname)) { + let copy = cloneDeep(df); + return Object.assign( + copy, + targetFields.find(tdf => tdf.fieldname === df.fieldname) + ); + } + return df; + }); + let fieldsAdded = fields.map(df => df.fieldname); + let fieldsToAdd = targetFields.filter( + df => !fieldsAdded.includes(df.fieldname) + ); + return fields.concat(fieldsToAdd); + }; + + let fields = mergeFields(base.fields, target.fields || []); + let out = Object.assign(base, target); + out.fields = fields; + + return out; + }, + commonFields: [ + { + fieldname: 'name', + fieldtype: 'Data', + required: 1 + } + ], + submittableFields: [ + { + fieldname: 'submitted', + fieldtype: 'Check', + required: 1 + } + ], + parentFields: [ + { + fieldname: 'owner', + fieldtype: 'Data', + required: 1 + }, + { + fieldname: 'modifiedBy', + fieldtype: 'Data', + required: 1 + }, + { + fieldname: 'creation', + fieldtype: 'Datetime', + required: 1 + }, + { + fieldname: 'modified', + fieldtype: 'Datetime', + required: 1 + }, + { + fieldname: 'keywords', + fieldtype: 'Text' + } + ], + childFields: [ + { + fieldname: 'idx', + fieldtype: 'Int', + required: 1 + }, + { + fieldname: 'parent', + fieldtype: 'Data', + required: 1 + }, + { + fieldname: 'parenttype', + fieldtype: 'Data', + required: 1 + }, + { + fieldname: 'parentfield', + fieldtype: 'Data', + required: 1 + } + ], + treeFields: [ + { + fieldname: 'lft', + fieldtype: 'Int' + }, + { + fieldname: 'rgt', + fieldtype: 'Int' + } + ] +}; diff --git a/frappe/model/meta.js b/frappe/model/meta.js new file mode 100644 index 00000000..2c43da80 --- /dev/null +++ b/frappe/model/meta.js @@ -0,0 +1,326 @@ +const BaseDocument = require('./document'); +const frappe = require('frappejs'); +const model = require('./index'); +const indicatorColor = require('frappejs/ui/constants/indicators'); + +module.exports = class BaseMeta extends BaseDocument { + constructor(data) { + if (data.basedOn) { + let config = frappe.models[data.basedOn]; + Object.assign(data, config, { + name: data.name, + label: data.label, + filters: data.filters + }); + } + super(data); + this.setDefaultIndicators(); + if (this.setupMeta) { + this.setupMeta(); + } + if (!this.titleField) { + this.titleField = 'name'; + } + } + + setValues(data) { + Object.assign(this, data); + this.processFields(); + } + + processFields() { + // add name field + if (!this.fields.find(df => df.fieldname === 'name') && !this.isSingle) { + this.fields = [ + { + label: frappe._('ID'), + fieldname: 'name', + fieldtype: 'Data', + required: 1, + readOnly: 1 + } + ].concat(this.fields); + } + + this.fields = this.fields.map(df => { + // name field is always required + if (df.fieldname === 'name') { + df.required = 1; + } + + return df; + }); + } + + hasField(fieldname) { + return this.getField(fieldname) ? true : false; + } + + getField(fieldname) { + if (!this._field_map) { + this._field_map = {}; + for (let field of this.fields) { + this._field_map[field.fieldname] = field; + } + } + return this._field_map[fieldname]; + } + + /** + * Get fields filtered by filters + * @param {Object} filters + * + * Usage: + * meta = frappe.getMeta('ToDo') + * dataFields = meta.getFieldsWith({ fieldtype: 'Data' }) + */ + getFieldsWith(filters) { + return this.fields.filter(df => { + let match = true; + for (const key in filters) { + const value = filters[key]; + match = df[key] === value; + } + return match; + }); + } + + getLabel(fieldname) { + let df = this.getField(fieldname); + return df.getLabel || df.label; + } + + getTableFields() { + if (this._tableFields === undefined) { + this._tableFields = this.fields.filter( + field => field.fieldtype === 'Table' + ); + } + return this._tableFields; + } + + getFormulaFields() { + if (this._formulaFields === undefined) { + this._formulaFields = this.fields.filter(field => field.formula); + } + return this._formulaFields; + } + + hasFormula() { + if (this._hasFormula === undefined) { + this._hasFormula = false; + if (this.getFormulaFields().length) { + this._hasFormula = true; + } else { + for (let tablefield of this.getTableFields()) { + if (frappe.getMeta(tablefield.childtype).getFormulaFields().length) { + this._hasFormula = true; + break; + } + } + } + } + return this._hasFormula; + } + + getBaseDocType() { + return this.basedOn || this.name; + } + + async set(fieldname, value) { + this[fieldname] = value; + await this.trigger(fieldname); + } + + get(fieldname) { + return this[fieldname]; + } + + getValidFields({ withChildren = true } = {}) { + if (!this._validFields) { + this._validFields = []; + this._validFieldsWithChildren = []; + + const _add = field => { + this._validFields.push(field); + this._validFieldsWithChildren.push(field); + }; + + // fields validation + this.fields.forEach((df, i) => { + if (!df.fieldname) { + throw new frappe.errors.ValidationError( + `DocType ${this.name}: "fieldname" is required for field at index ${i}` + ); + } + if (!df.fieldtype) { + throw new frappe.errors.ValidationError( + `DocType ${this.name}: "fieldtype" is required for field "${df.fieldname}"` + ); + } + }); + + const doctypeFields = this.fields.map(field => field.fieldname); + + // standard fields + for (let field of model.commonFields) { + if ( + frappe.db.typeMap[field.fieldtype] && + !doctypeFields.includes(field.fieldname) + ) { + _add(field); + } + } + + if (this.isSubmittable) { + _add({ + fieldtype: 'Check', + fieldname: 'submitted', + label: frappe._('Submitted') + }); + } + + if (this.isChild) { + // child fields + for (let field of model.childFields) { + if ( + frappe.db.typeMap[field.fieldtype] && + !doctypeFields.includes(field.fieldname) + ) { + _add(field); + } + } + } else { + // parent fields + for (let field of model.parentFields) { + if ( + frappe.db.typeMap[field.fieldtype] && + !doctypeFields.includes(field.fieldname) + ) { + _add(field); + } + } + } + + if (this.isTree) { + // tree fields + for (let field of model.treeFields) { + if ( + frappe.db.typeMap[field.fieldtype] && + !doctypeFields.includes(field.fieldname) + ) { + _add(field); + } + } + } + + // doctype fields + for (let field of this.fields) { + let include = frappe.db.typeMap[field.fieldtype]; + + if (include) { + _add(field); + } + + // include tables if (withChildren = True) + if (!include && field.fieldtype === 'Table') { + this._validFieldsWithChildren.push(field); + } + } + } + + if (withChildren) { + return this._validFieldsWithChildren; + } else { + return this._validFields; + } + } + + getKeywordFields() { + if (!this._keywordFields) { + this._keywordFields = this.keywordFields; + if (!(this._keywordFields && this._keywordFields.length && this.fields)) { + this._keywordFields = this.fields + .filter(field => field.fieldtype !== 'Table' && field.required) + .map(field => field.fieldname); + } + if (!(this._keywordFields && this._keywordFields.length)) { + this._keywordFields = ['name']; + } + } + return this._keywordFields; + } + + getQuickEditFields() { + if (this.quickEditFields) { + return this.quickEditFields.map(fieldname => this.getField(fieldname)); + } + return this.getFieldsWith({ required: 1 }); + } + + validateSelect(field, value) { + let options = field.options; + if (!options) return; + if (!field.required && value == null) { + return; + } + + let validValues = options; + + if (typeof options === 'string') { + // values given as string + validValues = options.split('\n'); + } + if (typeof options[0] === 'object') { + // options as array of {label, value} pairs + validValues = options.map(o => o.value); + } + if (!validValues.includes(value)) { + throw new frappe.errors.ValueError( + // prettier-ignore + `DocType ${this.name}: Invalid value "${value}" for "${field.label}". Must be one of ${options.join(', ')}` + ); + } + return value; + } + + async trigger(event, params = {}) { + Object.assign(params, { + doc: this, + name: event + }); + + await super.trigger(event, params); + } + + setDefaultIndicators() { + if (!this.indicators) { + if (this.isSubmittable) { + this.indicators = { + key: 'submitted', + colors: { + 0: indicatorColor.GRAY, + 1: indicatorColor.BLUE + } + }; + } + } + } + + getIndicatorColor(doc) { + if (frappe.isDirty(this.name, doc.name)) { + return indicatorColor.ORANGE; + } else { + if (this.indicators) { + let value = doc[this.indicators.key]; + if (value) { + return this.indicators.colors[value] || indicatorColor.GRAY; + } else { + return indicatorColor.GRAY; + } + } else { + return indicatorColor.GRAY; + } + } + } +}; diff --git a/frappe/model/naming.js b/frappe/model/naming.js new file mode 100644 index 00000000..85753b9d --- /dev/null +++ b/frappe/model/naming.js @@ -0,0 +1,88 @@ +const frappe = require('frappejs'); +const { getRandomString } = require('frappejs/utils'); + +module.exports = { + async setName(doc) { + if (frappe.isServer) { + // if is server, always name again if autoincrement or other + if (doc.meta.naming === 'autoincrement') { + doc.name = await this.getNextId(doc.doctype); + return; + } + + if (doc.meta.settings) { + const numberSeries = (await doc.getSettings()).numberSeries; + if(numberSeries) { + doc.name = await this.getSeriesNext(numberSeries); + } + } + } + + if (doc.name) { + return; + } + + // name === doctype for Single + if (doc.meta.isSingle) { + doc.name = doc.meta.name; + return; + } + + // assign a random name by default + // override doc to set a name + if (!doc.name) { + doc.name = getRandomString(); + } + }, + + async getNextId(doctype) { + // get the last inserted row + let lastInserted = await this.getLastInserted(doctype); + let name = 1; + if (lastInserted) { + let lastNumber = parseInt(lastInserted.name); + if (isNaN(lastNumber)) lastNumber = 0; + name = lastNumber + 1; + } + return (name + '').padStart(9, '0'); + }, + + async getLastInserted(doctype) { + const lastInserted = await frappe.db.getAll({ + doctype: doctype, + fields: ['name'], + limit: 1, + order_by: 'creation', + order: 'desc' + }); + return (lastInserted && lastInserted.length) ? lastInserted[0] : null; + }, + + async getSeriesNext(prefix) { + let series; + try { + series = await frappe.getDoc('NumberSeries', prefix); + } catch (e) { + if (!e.statusCode || e.statusCode !== 404) { + throw e; + } + await this.createNumberSeries(prefix); + series = await frappe.getDoc('NumberSeries', prefix); + } + let next = await series.next() + return prefix + next; + }, + + async createNumberSeries(prefix, setting, start=1000) { + if (!(await frappe.db.exists('NumberSeries', prefix))) { + const series = frappe.newDoc({doctype: 'NumberSeries', name: prefix, current: start}); + await series.insert(); + + if (setting) { + const settingDoc = await frappe.getSingle(setting); + settingDoc.numberSeries = series.name; + await settingDoc.update(); + } + } + } +} diff --git a/frappe/model/runPatches.js b/frappe/model/runPatches.js new file mode 100644 index 00000000..3da2d29d --- /dev/null +++ b/frappe/model/runPatches.js @@ -0,0 +1,26 @@ +const frappe = require('frappejs'); + +module.exports = async function runPatches(patchList) { + const patchesAlreadyRun = ( + await frappe.db.knex('PatchRun').select('name') + ).map(({ name }) => name); + + for (let patch of patchList) { + if (patchesAlreadyRun.includes(patch.patchName)) { + continue; + } + + await runPatch(patch); + } +}; + +async function runPatch({ patchName, patchFunction }) { + try { + await patchFunction(); + const patchRun = frappe.getNewDoc('PatchRun'); + patchRun.name = patchName; + await patchRun.insert(); + } catch (error) { + console.error(`could not run ${patchName}`, error); + } +} diff --git a/frappe/models/doctype/File/File.js b/frappe/models/doctype/File/File.js new file mode 100644 index 00000000..889de80a --- /dev/null +++ b/frappe/models/doctype/File/File.js @@ -0,0 +1,67 @@ +module.exports = { + name: 'File', + doctype: 'DocType', + isSingle: 0, + keywordFields: [ + 'name', + 'filename' + ], + fields: [ + { + fieldname: 'name', + label: 'File Path', + fieldtype: 'Data', + required: 1, + }, + { + fieldname: 'filename', + label: 'File Name', + fieldtype: 'Data', + required: 1, + }, + { + fieldname: 'mimetype', + label: 'MIME Type', + fieldtype: 'Data', + }, + { + fieldname: 'size', + label: 'File Size', + fieldtype: 'Int', + }, + { + fieldname: 'referenceDoctype', + label: 'Reference DocType', + fieldtype: 'Data', + }, + { + fieldname: 'referenceName', + label: 'Reference Name', + fieldtype: 'Data', + }, + { + fieldname: 'referenceField', + label: 'Reference Field', + fieldtype: 'Data', + }, + ], + layout: [ + { + columns: [ + { fields: ['filename'] }, + ] + }, + { + columns: [ + { fields: ['mimetype'] }, + { fields: ['size'] }, + ] + }, + { + columns: [ + { fields: ['referenceDoctype'] }, + { fields: ['referenceName'] }, + ] + }, + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/NumberSeries/NumberSeries.js b/frappe/models/doctype/NumberSeries/NumberSeries.js new file mode 100644 index 00000000..7498d3c2 --- /dev/null +++ b/frappe/models/doctype/NumberSeries/NumberSeries.js @@ -0,0 +1,22 @@ +module.exports = { + "name": "NumberSeries", + "documentClass": require('./NumberSeriesDocument.js'), + "doctype": "DocType", + "isSingle": 0, + "isChild": 0, + "keywordFields": [], + "fields": [ + { + "fieldname": "name", + "label": "Prefix", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "current", + "label": "Current", + "fieldtype": "Int", + "required": 1 + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js b/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js new file mode 100644 index 00000000..704e3edc --- /dev/null +++ b/frappe/models/doctype/NumberSeries/NumberSeriesDocument.js @@ -0,0 +1,15 @@ +const BaseDocument = require('frappejs/model/document'); + +module.exports = class NumberSeries extends BaseDocument { + validate() { + if (this.current===null || this.current===undefined) { + this.current = 0; + } + } + async next() { + this.validate(); + this.current++; + await this.update(); + return this.current; + } +} \ No newline at end of file diff --git a/frappe/models/doctype/PatchRun/PatchRun.js b/frappe/models/doctype/PatchRun/PatchRun.js new file mode 100644 index 00000000..3ecad237 --- /dev/null +++ b/frappe/models/doctype/PatchRun/PatchRun.js @@ -0,0 +1,10 @@ +module.exports = { + name: 'PatchRun', + fields: [ + { + fieldname: 'name', + fieldtype: 'Data', + label: 'Name' + } + ] +}; diff --git a/frappe/models/doctype/PrintFormat/PrintFormat.js b/frappe/models/doctype/PrintFormat/PrintFormat.js new file mode 100644 index 00000000..124f58d6 --- /dev/null +++ b/frappe/models/doctype/PrintFormat/PrintFormat.js @@ -0,0 +1,31 @@ +module.exports = { + name: "PrintFormat", + label: "Print Format", + doctype: "DocType", + isSingle: 0, + isChild: 0, + keywordFields: [], + fields: [ + { + fieldname: "name", + label: "Name", + fieldtype: "Data", + required: 1 + }, + { + fieldname: "for", + label: "For", + fieldtype: "Data", + required: 1 + }, + { + fieldname: "template", + label: "Template", + fieldtype: "Code", + required: 1, + options: { + mode: 'text/html' + } + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/Role/Role.js b/frappe/models/doctype/Role/Role.js new file mode 100644 index 00000000..2f46c13e --- /dev/null +++ b/frappe/models/doctype/Role/Role.js @@ -0,0 +1,15 @@ +module.exports = { + "name": "Role", + "doctype": "DocType", + "isSingle": 0, + "isChild": 0, + "keywordFields": [], + "fields": [ + { + "fieldname": "name", + "label": "Name", + "fieldtype": "Data", + "required": 1 + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/Session/Session.js b/frappe/models/doctype/Session/Session.js new file mode 100644 index 00000000..956456f2 --- /dev/null +++ b/frappe/models/doctype/Session/Session.js @@ -0,0 +1,21 @@ +module.exports = { + "name": "Session", + "doctype": "DocType", + "isSingle": 0, + "isChild": 0, + "keywordFields": [], + "fields": [ + { + "fieldname": "username", + "label": "Username", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "password", + "label": "Password", + "fieldtype": "Password", + "required": 1 + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/SingleValue/SingleValue.js b/frappe/models/doctype/SingleValue/SingleValue.js new file mode 100644 index 00000000..0ed40479 --- /dev/null +++ b/frappe/models/doctype/SingleValue/SingleValue.js @@ -0,0 +1,27 @@ +module.exports = { + "name": "SingleValue", + "doctype": "DocType", + "isSingle": 0, + "isChild": 0, + "keywordFields": [], + "fields": [ + { + "fieldname": "parent", + "label": "Parent", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "fieldname", + "label": "Fieldname", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "value", + "label": "Value", + "fieldtype": "Data", + "required": 1 + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/SystemSettings/SystemSettings.js b/frappe/models/doctype/SystemSettings/SystemSettings.js new file mode 100644 index 00000000..b77bfb04 --- /dev/null +++ b/frappe/models/doctype/SystemSettings/SystemSettings.js @@ -0,0 +1,118 @@ +const { DateTime } = require('luxon'); +const { _ } = require('frappejs/utils'); +const { + DEFAULT_DISPLAY_PRECISION, + DEFAULT_INTERNAL_PRECISION, + DEFAULT_LOCALE, +} = require('../../../utils/consts'); + +let dateFormatOptions = (() => { + let formats = [ + 'dd/MM/yyyy', + 'MM/dd/yyyy', + 'dd-MM-yyyy', + 'MM-dd-yyyy', + 'yyyy-MM-dd', + 'd MMM, y', + 'MMM d, y', + ]; + + let today = DateTime.local(); + + return formats.map((format) => { + return { + label: today.toFormat(format), + value: format, + }; + }); +})(); + +module.exports = { + name: 'SystemSettings', + label: 'System Settings', + doctype: 'DocType', + isSingle: 1, + isChild: 0, + keywordFields: [], + fields: [ + { + fieldname: 'dateFormat', + label: 'Date Format', + fieldtype: 'Select', + options: dateFormatOptions, + default: 'MMM d, y', + required: 1, + description: _('Sets the app-wide date display format.'), + }, + { + fieldname: 'locale', + label: 'Locale', + fieldtype: 'Data', + default: DEFAULT_LOCALE, + description: _('Set the local code, this is used for number formatting.'), + }, + { + fieldname: 'displayPrecision', + label: 'Display Precision', + fieldtype: 'Int', + default: DEFAULT_DISPLAY_PRECISION, + required: 1, + minValue: 0, + maxValue: 9, + validate(value, doc) { + if (value >= 0 && value <= 9) { + return; + } + throw new frappe.errors.ValidationError( + _('Display Precision should have a value between 0 and 9.') + ); + }, + description: _('Sets how many digits are shown after the decimal point.'), + }, + { + fieldname: 'internalPrecision', + label: 'Internal Precision', + fieldtype: 'Int', + minValue: 0, + default: DEFAULT_INTERNAL_PRECISION, + description: _( + 'Sets the internal precision used for monetary calculations. Above 6 should be sufficient for most currencies.' + ), + }, + { + fieldname: 'hideGetStarted', + label: 'Hide Get Started', + fieldtype: 'Check', + default: 0, + description: _( + 'Hides the Get Started section from the sidebar. Change will be visible on restart or refreshing the app.' + ), + }, + { + fieldname: 'autoUpdate', + label: 'Auto Update', + fieldtype: 'Check', + default: 1, + description: _( + 'Automatically checks for updates and download them if available. The update will be applied after you restart the app.' + ), + }, + { + fieldname: 'autoReportErrors', + label: 'Auto Report Errors', + fieldtype: 'Check', + default: 0, + description: _( + 'Automatically report all errors. User will still be notified when an error pops up.' + ), + }, + ], + quickEditFields: [ + 'dateFormat', + 'locale', + 'displayPrecision', + 'hideGetStarted', + 'autoUpdate', + 'autoReportErrors', + ], +}; diff --git a/frappe/models/doctype/ToDo/ToDo.js b/frappe/models/doctype/ToDo/ToDo.js new file mode 100644 index 00000000..3dad8d04 --- /dev/null +++ b/frappe/models/doctype/ToDo/ToDo.js @@ -0,0 +1,60 @@ +const { BLUE, GREEN } = require('frappejs/ui/constants/indicators'); + +module.exports = { + name: 'ToDo', + label: 'To Do', + naming: 'autoincrement', + isSingle: 0, + keywordFields: ['subject', 'description'], + titleField: 'subject', + indicators: { + key: 'status', + colors: { + Open: BLUE, + Closed: GREEN + } + }, + fields: [ + { + fieldname: 'subject', + label: 'Subject', + placeholder: 'Subject', + fieldtype: 'Data', + required: 1 + }, + { + fieldname: 'status', + label: 'Status', + fieldtype: 'Select', + options: ['Open', 'Closed'], + default: 'Open', + required: 1 + }, + { + fieldname: 'description', + label: 'Description', + fieldtype: 'Text' + } + ], + + quickEditFields: ['status', 'description'], + + actions: [ + { + label: 'Close', + condition: doc => doc.status !== 'Closed', + action: async doc => { + await doc.set('status', 'Closed'); + await doc.update(); + } + }, + { + label: 'Re-Open', + condition: doc => doc.status !== 'Open', + action: async doc => { + await doc.set('status', 'Open'); + await doc.update(); + } + } + ] +}; diff --git a/frappe/models/doctype/ToDo/ToDoList.js b/frappe/models/doctype/ToDo/ToDoList.js new file mode 100644 index 00000000..9611ab78 --- /dev/null +++ b/frappe/models/doctype/ToDo/ToDoList.js @@ -0,0 +1,7 @@ +const BaseList = require('frappejs/client/view/list'); + +module.exports = class ToDoList extends BaseList { + getFields(list) { + return ['name', 'subject', 'status']; + } +} diff --git a/frappe/models/doctype/User/User.js b/frappe/models/doctype/User/User.js new file mode 100644 index 00000000..d4dd5306 --- /dev/null +++ b/frappe/models/doctype/User/User.js @@ -0,0 +1,43 @@ +module.exports = { + "name": "User", + "doctype": "DocType", + "isSingle": 0, + "isChild": 0, + "keywordFields": [ + "name", + "fullName" + ], + "fields": [ + { + "fieldname": "name", + "label": "Email", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "password", + "label": "Password", + "fieldtype": "Password", + "required": 1, + "hidden": 1, + }, + { + "fieldname": "fullName", + "label": "Full Name", + "fieldtype": "Data", + "required": 1 + }, + { + "fieldname": "roles", + "label": "Roles", + "fieldtype": "Table", + "childtype": "UserRole" + }, + { + "fieldname": "userId", + "label": "User ID", + "fieldtype": "Data", + "hidden": 1 + } + ] +} \ No newline at end of file diff --git a/frappe/models/doctype/UserRole/UserRole.js b/frappe/models/doctype/UserRole/UserRole.js new file mode 100644 index 00000000..a98a5052 --- /dev/null +++ b/frappe/models/doctype/UserRole/UserRole.js @@ -0,0 +1,15 @@ +module.exports = { + "name": "UserRole", + "doctype": "DocType", + "isSingle": 0, + "isChild": 1, + "keywordFields": [], + "fields": [ + { + "fieldname": "role", + "label": "Role", + "fieldtype": "Link", + "target": "Role" + } + ] +} \ No newline at end of file diff --git a/frappe/models/index.js b/frappe/models/index.js new file mode 100644 index 00000000..df5752a1 --- /dev/null +++ b/frappe/models/index.js @@ -0,0 +1,13 @@ +module.exports = { + NumberSeries: require('./doctype/NumberSeries/NumberSeries.js'), + PrintFormat: require('./doctype/PrintFormat/PrintFormat.js'), + Role: require('./doctype/Role/Role.js'), + Session: require('./doctype/Session/Session.js'), + SingleValue: require('./doctype/SingleValue/SingleValue.js'), + SystemSettings: require('./doctype/SystemSettings/SystemSettings.js'), + ToDo: require('./doctype/ToDo/ToDo.js'), + User: require('./doctype/User/User.js'), + UserRole: require('./doctype/UserRole/UserRole.js'), + File: require('./doctype/File/File.js'), + PatchRun: require('./doctype/PatchRun/PatchRun.js') +}; diff --git a/frappe/utils/cacheManager.js b/frappe/utils/cacheManager.js new file mode 100644 index 00000000..52e949b1 --- /dev/null +++ b/frappe/utils/cacheManager.js @@ -0,0 +1,41 @@ +class CacheManager { + constructor() { + this.keyValueCache = {}; + this.hashCache = {}; + } + + getValue(key) { + return this.keyValueCache[key]; + } + + setValue(key, value) { + this.keyValueCache[key] = value; + } + + clearValue(key) { + this.keyValueCache[key] = null; + } + + hget(hashName, key) { + return (this.hashCache[hashName] || {})[key]; + } + + hset(hashName, key, value) { + this.hashCache[hashName] = this.hashCache[hashName] || {}; + this.hashCache[hashName][key] = value; + } + + hclear(hashName, key) { + if (key) { + (this.hashCache[hashName] || {})[key] = null; + } else { + this.hashCache[hashName] = {}; + } + } + + hexists(hashName) { + return this.hashCache[hashName] != null; + } +} + +module.exports = CacheManager; diff --git a/frappe/utils/consts.js b/frappe/utils/consts.js new file mode 100644 index 00000000..df3432cf --- /dev/null +++ b/frappe/utils/consts.js @@ -0,0 +1,3 @@ +export const DEFAULT_INTERNAL_PRECISION = 11; +export const DEFAULT_DISPLAY_PRECISION = 2; +export const DEFAULT_LOCALE = 'en-IN'; diff --git a/frappe/utils/format.js b/frappe/utils/format.js new file mode 100644 index 00000000..731d5c64 --- /dev/null +++ b/frappe/utils/format.js @@ -0,0 +1,117 @@ +const luxon = require('luxon'); +const frappe = require('frappejs'); +const { DEFAULT_DISPLAY_PRECISION, DEFAULT_LOCALE } = require('./consts'); + +module.exports = { + format(value, df, doc) { + if (!df) { + return value; + } + + if (typeof df === 'string') { + df = { fieldtype: df }; + } + + if (df.fieldtype === 'Currency') { + const currency = getCurrency(df, doc); + value = formatCurrency(value, currency); + } else if (df.fieldtype === 'Date') { + let dateFormat; + if (!frappe.SystemSettings) { + dateFormat = 'yyyy-MM-dd'; + } else { + dateFormat = frappe.SystemSettings.dateFormat; + } + + if (typeof value === 'string') { + // ISO String + value = luxon.DateTime.fromISO(value); + } else if (Object.prototype.toString.call(value) === '[object Date]') { + // JS Date + value = luxon.DateTime.fromJSDate(value); + } + + value = value.toFormat(dateFormat); + if (value === 'Invalid DateTime') { + value = ''; + } + } else if (df.fieldtype === 'Check') { + typeof parseInt(value) === 'number' + ? (value = parseInt(value)) + : (value = Boolean(value)); + } else { + if (value === null || value === undefined) { + value = ''; + } else { + value = value + ''; + } + } + return value; + }, + formatCurrency, + formatNumber, +}; + +function formatCurrency(value, currency) { + let valueString; + try { + valueString = formatNumber(value); + } catch (err) { + err.message += ` value: '${value}', type: ${typeof value}`; + throw err; + } + + const currencySymbol = frappe.currencySymbols[currency]; + if (currencySymbol) { + return currencySymbol + ' ' + valueString; + } + + return valueString; +} + +function formatNumber(value) { + const numberFormatter = getNumberFormatter(); + if (typeof value === 'number') { + return numberFormatter.format(value); + } + + if (value.round) { + return numberFormatter.format(value.round()); + } + + const formattedNumber = numberFormatter.format(value); + if (formattedNumber === 'NaN') { + throw Error( + `invalid value passed to formatNumber: '${value}' of type ${typeof value}` + ); + } + + return formattedNumber; +} + +function getNumberFormatter() { + if (frappe.currencyFormatter) { + return frappe.currencyFormatter; + } + + const locale = frappe.SystemSettings.locale ?? DEFAULT_LOCALE; + const display = + frappe.SystemSettings.displayPrecision ?? DEFAULT_DISPLAY_PRECISION; + + return (frappe.currencyFormatter = Intl.NumberFormat(locale, { + style: 'decimal', + minimumFractionDigits: display, + })); +} + +function getCurrency(df, doc) { + if (!(doc && df.getCurrency)) { + return df.currency || frappe.AccountingSettings.currency || ''; + } + + if (doc.meta && doc.meta.isChild) { + return df.getCurrency(doc, doc.parentdoc); + } + + return df.getCurrency(doc); +} diff --git a/frappe/utils/index.js b/frappe/utils/index.js new file mode 100644 index 00000000..d7573af0 --- /dev/null +++ b/frappe/utils/index.js @@ -0,0 +1,103 @@ +const { pesa } = require('pesa'); +const { T, t } = require('./translation'); + +Array.prototype.equals = function (array) { + return ( + this.length == array.length && + this.every(function (item, i) { + return item == array[i]; + }) + ); +}; + +function slug(str) { + return str + .replace(/(?:^\w|[A-Z]|\b\w)/g, function (letter, index) { + return index == 0 ? letter.toLowerCase() : letter.toUpperCase(); + }) + .replace(/\s+/g, ''); +} + +function getRandomString() { + return Math.random().toString(36).substr(3); +} + +async function sleep(seconds) { + return new Promise((resolve) => { + setTimeout(resolve, seconds * 1000); + }); +} + +function getQueryString(params) { + if (!params) return ''; + let parts = []; + for (let key in params) { + if (key != null && params[key] != null) { + parts.push( + encodeURIComponent(key) + '=' + encodeURIComponent(params[key]) + ); + } + } + return parts.join('&'); +} + +function asyncHandler(fn) { + return (req, res, next) => + Promise.resolve(fn(req, res, next)).catch((err) => { + console.log(err); + // handle error + res.status(err.statusCode || 500).send({ error: err.message }); + }); +} + +/** + * Returns array from 0 to n - 1 + * @param {Number} n + */ +function range(n) { + return Array(n) + .fill() + .map((_, i) => i); +} + +function unique(list, key = (it) => it) { + var seen = {}; + return list.filter((item) => { + var k = key(item); + return seen.hasOwnProperty(k) ? false : (seen[k] = true); + }); +} + +function getDuplicates(array) { + let duplicates = []; + for (let i in array) { + let previous = array[i - 1]; + let current = array[i]; + + if (current === previous) { + if (!duplicates.includes(current)) { + duplicates.push(current); + } + } + } + return duplicates; +} + +function isPesa(value) { + return value instanceof pesa().constructor; +} + +module.exports = { + _: t, + t, + T, + slug, + getRandomString, + sleep, + getQueryString, + asyncHandler, + range, + unique, + getDuplicates, + isPesa, +}; diff --git a/frappe/utils/noop.js b/frappe/utils/noop.js new file mode 100644 index 00000000..bd375ffa --- /dev/null +++ b/frappe/utils/noop.js @@ -0,0 +1 @@ +module.exports = function () { return function () {}; }; \ No newline at end of file diff --git a/frappe/utils/observable.js b/frappe/utils/observable.js new file mode 100644 index 00000000..00124d47 --- /dev/null +++ b/frappe/utils/observable.js @@ -0,0 +1,128 @@ +module.exports = class Observable { + constructor() { + this._observable = { + isHot: {}, + eventQueue: {}, + listeners: {}, + onceListeners: {} + } + } + + // getter, setter stubs, so Observable can be used as a simple Document + get(key) { + return this[key]; + } + + set(key, value) { + this[key] = value; + this.trigger('change', { + doc: this, + fieldname: key + }); + } + + on(event, listener) { + this._addListener('listeners', event, listener); + if (this._observable.socketClient) { + this._observable.socketClient.on(event, listener); + } + } + + // remove listener + off(event, listener) { + for (let type of ['listeners', 'onceListeners']) { + let index = this._observable[type][event] && this._observable[type][event].indexOf(listener); + if (index) { + this._observable[type][event].splice(index, 1); + } + } + } + + once(event, listener) { + this._addListener('onceListeners', event, listener); + } + + async trigger(event, params, throttle = false) { + if (throttle) { + if (this._throttled(event, params, throttle)) return; + params = [params] + } + + await this._executeTriggers(event, params); + } + + async _executeTriggers(event, params) { + let response = await this._triggerEvent('listeners', event, params); + if (response === false) return false; + + response = await this._triggerEvent('onceListeners', event, params); + if (response === false) return false; + + // emit via socket + if (this._observable.socketServer) { + this._observable.socketServer.emit(event, params); + } + + // clear once-listeners + if (this._observable.onceListeners && this._observable.onceListeners[event]) { + delete this._observable.onceListeners[event]; + } + + } + + clearListeners() { + this._observable.listeners = {}; + this._observable.onceListeners = {}; + } + + bindSocketClient(socket) { + // also send events with sockets + this._observable.socketClient = socket; + } + + bindSocketServer(socket) { + // also send events with sockets + this._observable.socketServer = socket; + } + + _throttled(event, params, throttle) { + if (this._observable.isHot[event]) { + // hot, add to queue + if (!this._observable.eventQueue[event]) this._observable.eventQueue[event] = []; + this._observable.eventQueue[event].push(params); + + // aleady hot, quit + return true; + } + this._observable.isHot[event] = true; + + // cool-off + setTimeout(() => { + this._observable.isHot[event] = false; + + // flush queue + if (this._observable.eventQueue[event]) { + let _queuedParams = this._observable.eventQueue[event]; + this._observable.eventQueue[event] = null; + this._executeTriggers(event, _queuedParams); + } + }, throttle); + + return false; + } + + _addListener(type, event, listener) { + if (!this._observable[type][event]) { + this._observable[type][event] = []; + } + this._observable[type][event].push(listener); + } + + async _triggerEvent(type, event, params) { + if (this._observable[type][event]) { + for (let listener of this._observable[type][event]) { + await listener(params); + } + } + } +} diff --git a/frappe/utils/translation.js b/frappe/utils/translation.js new file mode 100644 index 00000000..87a9bda3 --- /dev/null +++ b/frappe/utils/translation.js @@ -0,0 +1,80 @@ +import { ValueError } from '../common/errors'; + +function stringReplace(str, args) { + if (!Array.isArray(args)) { + args = [args]; + } + + if (str == undefined) return str; + + let unkeyed_index = 0; + return str.replace(/\{(\w*)\}/g, (match, key) => { + if (key === '') { + key = unkeyed_index; + unkeyed_index++; + } + if (key == +key) { + return args[key] !== undefined ? args[key] : match; + } + }); +} + +class TranslationString { + constructor(...args) { + this.args = args; + } + + get s() { + return this.toString(); + } + + ctx(context) { + this.context = context; + return this; + } + + #translate(segment) { + // TODO: implement translation backend + return segment; + } + + #stitch() { + if (typeof this.args[0] === 'string') { + return stringReplace(this.args[0], this.args.slice(1)); + } + + if (!(this.args[0] instanceof Array)) { + throw new ValueError( + `invalid args passed to TranslationString ${ + this.args + } of type ${typeof this.args[0]}` + ); + } + + const strList = this.args[0]; + const argList = this.args.slice(1); + return strList + .map((s, i) => this.#translate(s) + (argList[i] ?? '')) + .join(''); + } + + toString() { + return this.#stitch(); + } + + toJSON() { + return this.#stitch(); + } + + valueOf() { + return this.#stitch(); + } +} + +export function T(...args) { + return new TranslationString(...args); +} + +export function t(...args) { + return new TranslationString(...args).s; +}