2
0
mirror of https://github.com/frappe/books.git synced 2024-12-23 19:39:07 +00:00

refactor: inline frappejs code into books

This commit is contained in:
18alantom 2022-01-21 02:08:09 +05:30
commit f602caff81
33 changed files with 4094 additions and 0 deletions

830
frappe/backends/database.js Normal file
View File

@ -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<Object>} 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();
}
};

230
frappe/backends/http.js Normal file
View File

@ -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() {
}
}

210
frappe/backends/mysql.js Normal file
View File

@ -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'
}
}
}

120
frappe/backends/sqlite.js Normal file
View File

@ -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;

101
frappe/common/errors.js Normal file
View File

@ -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
};

15
frappe/common/index.js Normal file
View File

@ -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;
},
};

372
frappe/index.js Normal file
View File

@ -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();
}
},
};

725
frappe/model/document.js Normal file
View File

@ -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;
}
}

114
frappe/model/index.js Normal file
View File

@ -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'
}
]
};

326
frappe/model/meta.js Normal file
View File

@ -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;
}
}
}
};

88
frappe/model/naming.js Normal file
View File

@ -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();
}
}
}
}

View File

@ -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);
}
}

View File

@ -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'] },
]
},
]
}

View File

@ -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
}
]
}

View File

@ -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;
}
}

View File

@ -0,0 +1,10 @@
module.exports = {
name: 'PatchRun',
fields: [
{
fieldname: 'name',
fieldtype: 'Data',
label: 'Name'
}
]
};

View File

@ -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'
}
}
]
}

View File

@ -0,0 +1,15 @@
module.exports = {
"name": "Role",
"doctype": "DocType",
"isSingle": 0,
"isChild": 0,
"keywordFields": [],
"fields": [
{
"fieldname": "name",
"label": "Name",
"fieldtype": "Data",
"required": 1
}
]
}

View File

@ -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
}
]
}

View File

@ -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
}
]
}

View File

@ -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',
],
};

View File

@ -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();
}
}
]
};

View File

@ -0,0 +1,7 @@
const BaseList = require('frappejs/client/view/list');
module.exports = class ToDoList extends BaseList {
getFields(list) {
return ['name', 'subject', 'status'];
}
}

View File

@ -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
}
]
}

View File

@ -0,0 +1,15 @@
module.exports = {
"name": "UserRole",
"doctype": "DocType",
"isSingle": 0,
"isChild": 1,
"keywordFields": [],
"fields": [
{
"fieldname": "role",
"label": "Role",
"fieldtype": "Link",
"target": "Role"
}
]
}

13
frappe/models/index.js Normal file
View File

@ -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')
};

View File

@ -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;

3
frappe/utils/consts.js Normal file
View File

@ -0,0 +1,3 @@
export const DEFAULT_INTERNAL_PRECISION = 11;
export const DEFAULT_DISPLAY_PRECISION = 2;
export const DEFAULT_LOCALE = 'en-IN';

117
frappe/utils/format.js Normal file
View File

@ -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);
}

103
frappe/utils/index.js Normal file
View File

@ -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,
};

1
frappe/utils/noop.js Normal file
View File

@ -0,0 +1 @@
module.exports = function () { return function () {}; };

128
frappe/utils/observable.js Normal file
View File

@ -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);
}
}
}
}

View File

@ -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;
}