Source

common/dataModel.js

/**
 * 
 * Represents a **Model**.
 * 
 * The models are the central elements to structure the data of your applications.
 * The jargon can be slightly different depending on the frameworks, therefore, let's ensure we're talking the same language when it comes to **Model**, **Field**, **Record**, and **Collection**.
 * 
 * **Model**:
 * - a model is a representation of a real-world entity: a user, a car, an invoice, a task, a project, a dream, a quark
 * - a model defines the properties of the entity, but **is not** that entity: a model defines what is a car, but is not a car
 * - a model **must** define the properties of the entity. Ex: firstName, lastName, email
 * - a model **can** have custom methods. Ex: invite(), sendEmail(), ...
 * 
 * **Field**:
 * - a field is a single property of a model. Ex: firstName
 * - a field has an id. Ex: firstName, lastName, bwxJF4yz
 * - a field has a label. Ex: First name, Last name
 * - a field has a type. Ex: text, number, date, select
 * 
 * **Record**:
 * - a record is an entity created thanks to the model definition
 * - a record has data. Ex: firstName: **Bob**, lastName: **Wilson**, email: **bob@wilson.com**
 * - a record inherits the model's methods. Ex: myUser.invite(), myUser.sendMail()
 * 
 * **Collection**:
 * - a collection is an array of multiple records
 * - a collection acts as a proxy for model's data, with specific projection, filtering, sorting, grouping, paging...
 * - it means multiple collections can be bound to the same model
 * - a collection is useful to cache a specific set of records and represent them into the UI
 * - in KissJS, each record of a collection is not just a JSON object, but it's a Record's instance, so you can directly use its methods
 * 
 * <img src="../../resources/doc/KissJS - Data model.png">
 * 
 * In KissJS:
 * - a model needs basic properties like **id**, **name**, **items**
 * - a model needs to know its **singular** and its **plural** name: man / men, child / children, box / boxes, spy / spies 
 * - a model **can** have custom **methods**: sendEmail(), archive(), processTask()
 * - to be able to represent the model visually in KissJS applications, it has an **icon** and a **color** property
 * - to classify the models semantically, KissJS **can** have meta-data like **tags** and **domains**
 * 
 * You define a model passing its names (singular and plural) and its items:
 * ```
 * let spyModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {label: "Spy code number"},
 *      {label: "Spy real name"}
 *  ]
 * })
 * ```
 * 
 * Important note about KissJS conventions:
 *  - we define model **items** instead of **fields**
 *  - fields have a **label** instead of a **name**
 * 
 * This is because KissJS shares a single convention for both the **model** and its direct UI representation: the **form**:
 * - forms have **items** (which can be fields, panels, buttons, images...)
 * - panels contain **fields** or other items (like **buttons**)
 * - fields have a **label**
 * - panels have a **title**
 * 
 * It means you can also define a model like this:
 * ```
 * let spyModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {
 *          type: "panel",
 *          title: "General informations",
 *          items: [
 *              {label: "Spy code number"},
 *              {label: "Spy fake name"}
 *           ]
 *      },
 *      {
 *          type: "panel",
 *          title: "Secret informations",
 *          items: [
 *              {label: "Spy real name"},
 *              {label: "Last mission date"}
 *          ]
 *      }
 *  ]
 * })
 * ```
 * This model will be automatically represented by a form with 2 sections.
 * 
 * If you just give a label to a field, its **id** will be randomly generated.
 * To keep control over your field ids, just add them to the field definition:
 * ```
 * let newModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {id: "code", label: "Spy code number"},
 *      {id: "name", label: "Spy real name"}
 *  ]
 * })
 * ```
 * The default type is "text", but you can of course define the field's type and **validation** rules:
 * - using **validation** property and a regex
 * - using **validationType** property for pre-defined rules (alpha, alphanumeric, email, url, ip)
 * ```
 * let newModel = new kiss.data.Model({
 *  name: "spy",
 *  namePlural: "spies",
 *  items: [
 *      {id: "code", label: "Spy code number", type: "text", validation: /^\d{3}$/}, // Force 3 digits, like "007"
 *      {id: "name", label: "Spy real name", type: "text"},
 *      {id: "missions", label: "Number of missions", type: "number"},
 *      {id: "lastMission", label: "Last mission date", type: "date"},
 *      {id: "secretEmail", label: "Spy secret mailbox", type: "text", validationType: "email"}, // Predefined validation type
 *      {id: "isActive", label: "Is this spy still active?", type: "checkbox"}
 *  ]
 * })
 * ```
 * The field **type** is used to define how the field is rendered into the UI, and its **data type** is implicit.
 * In the previous example, the **checkbox** type has implicitly a **boolean** data type.
 * 
 * Once a model has been defined, you can **create** model instances.
 * In KissJS, a model instance is called a **Record**:
 * 
 * ```
 * let userModel = kiss.app.models.user
 * let userRecord = userModel.create({firstName: "Bob", lastName: "Wilson"})
 * ```
 * 
 * **Fields**
 * 
 * To define a field, the minimum is to have a field **label**:
 * ```
 * {label: "First name"}
 * ```
 * 
 * If you just set a label, KissJS will automatically:
 * - generate an id, using [kiss.tools.shortUid()](kiss.tools.html#.shortUid)
 * - set the type to **text**
 * 
 * If you prefer having control over your field ids, do:
 * ```
 * {id: "firstName", label: "First name"}
 * ```
 * 
 * **Field types**
 * 
 * Because KissJS is primarily a **UI library**, it is designed to make it easier to display the model in the user interface.
 * It's a very opiniated architecture choice.
 * The point here is to keep the focus on the UI and not the underlying structure.
 * Doing that way allows us to just throw a model definition to KissJS and it will automagically generate the UI as a form.
 * 
 * **Basic field types:**
 * 
 * Field type | Data type | Displayed as
 * --- | --- | ---
 * text | text | input field
 * textarea | text | textarea field
 * number | number | number field
 * date | ISO 8601 extended. Example: 2021-04-01T23:20:15Z | date picker
 * checkbox | boolean | checkbox
 * select | string[] | single or multi-value dropdown list
 * rating | number | a rating field with stars (or other icons)
 * slider | number | a slider field
 * icon | string | a single icon field
 * iconPicker | string | an icon picker (using Font Awesome icons at the moment)
 * color | string | a single color field
 * colorPicker | string | a color picker
 * attachment | object | file upload control
 * 
 * **Special field types (non mandatory extensions):**
 * 
 * Field type | Data type | Displayed as
 * --- | --- | ---
 * aiTextarea | text | textarea field with AI suggestions
 * aiImage | object | file upload control with AI image generation
 * directory | string[] | dropdown list to select users and/or groups
 * link | * | list to show one or multiple records and create relationships between tables
 * lookup | * | computed field that lookup a value from a single foreign linked record
 * summary | * | computed field that makes a summary of multiple foreign linked records (a sum, a percentage, a concatenation...)
 * 
 * **Roadmap for news field types:**
 * - automatic number
 * - address (with search / completion and map)
 * - checkbox group (= just another UI for Select field with "multiple: true" option)
 * - radio group (= just another UI for Select field with "multiple: false" option)
 * - image (= just another UI for the attachment field)
 * 
 * **Methods:**
 * 
 * By default, every instanciated record will have default methods:
 * - save
 * - read
 * - update
 * - delete
 * 
 * But you can also define custom methods as well (just ensure their name doesn't conflict with default CRUD methods):
 * ```
 * let contactModel = new kiss.data.Model({
 *  name: "contact",
 *  namePlural: "contacts",
 *  items: [
 *      {id: "name", label: "Contact name", type: "text"},
 *      {id: "email", label: "Email", type: "text", validationType: "email"}
 *  ],
 *  methods: {
 *      sendEmail: function (subject, message) {
 *          yourSmtpService.sendMessage(this.email, subject, message)
 *      }
 *  }
 * })
 * 
 * // Instanciate a new record
 * let newContact = contactModel.create({name: "Bob", email: "bob@wilson.com"})
 * 
 * // Using its custom method
 * newContact.sendEmail("Urgent", "Do that")
 * ```
 * 
 * @param {object} config - model configuration
 * @param {string} [config.mode] - "memory" | "offline" | "online"
 * @param {string} [config.id]
 * @param {string} [config.templateId] - id of the original template model (used to keep track of the source model)
 * @param {string} [config.name] - Name of the model: Lead
 * @param {string} [config.namePlural] - Plural name: Leads
 * @param {object[]} config.items - Array for field definitions
 * @param {object} config.acl - model's acl (Access Control List)
 * @param {object} config.methods - model's methods
 * @param {object} [config.features] - model's features (workflow, comments, ...)
 * @param {string} [config.icon] - The Font Awesome icon class. Example: "fas fa-check"
 * @param {string} [config.color] - Hexa color. Ex: "#00aaee"
 * @param {string[]} [config.tags] - Ex: ["Leads", "Sales", "CRM", "HRM"]
 * @param {string[]} [config.domains] - Ex: ["banking", "insurance"]
 * 
 * @example
 * // Register a new model
 * let leadModel = new kiss.data.Model({
 *  name: "lead",
 *  namePlural: "leads",
 * 
 *  icon: "fas fa-user",
 *  color: "#00aaee",
 * 
 *  // Define model fiels
 *  items: [
 *      {
 *          label: "Name",
 *          id: "name",
 *          type: "text"
 *      },
 *      {
 *          primary: true, // Primary key field
 *          label: "Email",
 *          id: "email",
 *          type: "text",
 *          validationType: "email"
 *      },
 *      {
 *          label: "Category",
 *          id: "category",
 *          type: "select",
 *          options: [
 *              {
 *                  label: "National"
 *                  value: "NAT",
 *                  color: "#00aaee"
 *              },
 *              {
 *                  label: "International",
 *                  value: "INT",
 *                  color: "#aa00ee"
 *              }
 *          ]
 *      }
 *  ],
 * 
 *  // Define model methods
 *  methods: {
 *      // Get all the pending deals for this lead
 *      getPendingDeals: async function() {
 *          return await kiss.app.collections["deal"].find({
 *              filter: {
 *                  $and: [
 *                      {leadId: this.id},
 *                      {status: "pending"}
 *                  ]
 *              }
 *          })
 *      }
 *  }
 * })
 * 
 * // Your can create a new instance like this
 * let myLead = leadModel.create({name: "Bob Wilson", email: "bob@wilson.com", category: "INT"})
 * 
 * // Creating a new instance happens in memory. You have to save it manually with
 * await myLead.save()
 * 
 * // Updating an instance using default CRUD methods
 * await myLead.update({name: "Bob Wilson Junior"})
 * 
 * // Calling a custom method
 * let pendingDeals = await myLead.getPendingDeals()
 * 
 * // Deleting an instance
 * await myLead.delete()
 * 
 */
kiss.data.Model = class {

    constructor(config) {
        // log(`kiss.data.Model - Defining model <${config.name}>`, 0, config)

        // Define collection's database (memory, offline, online)
        this.mode = config.mode || kiss.db.mode
        this.db = kiss.db[this.mode]

        // Basic model properties
        this.id = config.id || this.namePlural || this.name || uid()
        this.accountId = config.accountId
        this.name = (config.name || this.id).toTitleCase()
        this.namePlural = (config.namePlural || this.id).toTitleCase()
        this.icon = config.icon || "fas fa-th"
        this.color = config.color || "#00aaee"
        this.backgroundColor = config.backgroundColor || "#ffffff"
        this.fullscreen = !!config.fullscreen
        this.align = config.align || "center"
        this.tags = config.tags || []
        this.domains = config.domains || []
        this.methods = config.methods || {}
        this.features = config.features
        this.splitBy = config.splitBy

        // Dynamic models get their acl rules from the generic "dynamicModel" definition
        this.acl = (kiss.tools.isUid(this.id) && kiss.app.models.dynamicModel) ? kiss.app.models.dynamicModel.acl : config.acl
        this.acl = this.acl || {}

        // Model access fields
        this.authenticatedCanCreate = config.authenticatedCanCreate
        this.authenticatedCanRead = config.authenticatedCanRead
        this.authenticatedCanUpdate = config.authenticatedCanUpdate
        this.authenticatedCanDelete = config.authenticatedCanDelete
        this.ownerCanManage = config.ownerCanManage
        this.accessCreate = config.accessCreate
        this.accessRead = config.accessRead
        this.accessUpdate = config.accessUpdate
        this.accessDelete = config.accessDelete
        this.accessManage = config.accessManage

        // Public forms
        this.public = !!config.public
        this.publicFormWidth = config.publicFormWidth
        this.publicFormMargin = config.publicFormMargin
        this.publicFormHeader = config.publicFormHeader
        this.publicEmailTo = config.publicEmailTo
        this.publicFormActionId = config.publicFormActionId

        // Keep the id of the original application it was created in
        if (config.applicationId) this.applicationId = config.applicationId

        // Keep the id of the original template used to generate this model
        if (config.templateId) this.templateId = config.templateId

        // Self-register the Model into the kiss.app object
        kiss.app.models[this.id] = this

        // Init items, fields, computed fields, record factory
        this._initItems(config.items)
            ._initFields()
            ._initACLFields()
            ._initComputedFields()
            ._initRecordFactory()

        // Init client methods: master collection, subscriptions
        if (kiss.isClient) {
            this._initMasterCollection()
                ._initSubscriptions()
        }

        // Init server methods: set accepted fields
        if (kiss.isServer) {
            this._initAcceptedFields()
        }

        return this
    }

    /**
     * Init the model's ACL fields
     * 
     * This is only used server-side to define the fields that holds ACL entries (users and/or groups).
     * When a user or group is deleted, the ACL entries in the model's fields are updated accordingly.
     * This is done by kiss.directory.cleanupAllUserReferences
     * 
     * @returns this
     */
    _initACLFields() {
        if (kiss.isClient) return this

        this.aclFields = this.fields.filter(field => field.isACL)
        if (this.aclFields.length > 0) kiss.acl.addFields(this, this.aclFields)
        return this
    }

    /**
     * Create a Record class specific to this Model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initRecordFactory() {
        this.recordFactory = kiss.data.RecordFactory(this.id)
        return this
    }

    /**
     * Create and register a default collection for the model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initMasterCollection() {
        this.collection = new kiss.data.Collection({
            id: this.id,
            mode: this.mode,
            model: this,
            isMaster: true, // The default model's collection is flagged as the "master" collection
            sort: [{
                [this.getPrimaryKeyField().id]: "asc" // Sort on the primary key field by default
            }]
        })
        return this
    }

    /**
     * Subscribe the model to react to changes
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initSubscriptions() {
        const modelId = this.id.toUpperCase()

        this.subscriptions = [
            // React to model updates
            subscribe("EVT_DB_UPDATE:MODEL", (msgData) => {
                if (this.id == msgData.id) {
                    Object.assign(this, msgData.data)

                    if (msgData.data.hasOwnProperty("items")) {
                        this._initItems(this.items)
                        this._initFields()
                    }
                }
            }),

            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                if (msgData.data && msgData.data[0] && msgData.data[0].modelId == "model" && msgData.data[0].recordId == this.id) {
                    Object.assign(this, msgData.data[0].updates)
                }
            }),

            // React to database mutations on records built from this model
            subscribe("EVT_DB_INSERT:" + modelId, this._notifyUser),
            subscribe("EVT_DB_UPDATE:" + modelId, this._notifyUser),
            subscribe("EVT_DB_DELETE:" + modelId, this._notifyUser),
            subscribe("EVT_DB_UPDATE_BULK", (msgData) => {
                let isUpdated = false
                for (let operation of msgData.data) {
                    if (operation.modelId == this.id) {
                        isUpdated = true
                        break
                    }
                }
                if (isUpdated) this._notifyUser(msgData)
            }),

            // Hardly ever happens, so we put this in standby at the moment to limit the number of subscriptions:
            // subscribe("EVT_DB_INSERT_MANY:" + modelId, this._notifyUser),
            // subscribe("EVT_DB_UPDATE_MANY:" + modelId, this._notifyUser),
            // subscribe("EVT_DB_DELETE_MANY:" + modelId, this._notifyUser)
        ]
        return this
    }

    /**
     * Create a new Record from this model
     * 
     * **This does not save the record automatically**: to save the record into the database, use the **save()** method of the created record.
     * 
     * @param {object} [recordData] - The new record's data
     * @param {boolean} [inherit] - If true, create a blank record then assign recordData to it
     * @returns {object} The new Record object
     * 
     * @example
     * userModel = kiss.app.models["user"]
     * let Bob = userModel.create({firstName: "Bob", lastName: "Wilson"})
     * await Bob.save()
     */
    create(recordData, inherit) {
        return new this.recordFactory(recordData, inherit)
    }

    /**
     * Create a record using field labels as keys
     * 
     * @param {object} record
     * @returns The record
     * 
     * @example
     * userModel = kiss.app.models["user"]
     * let Bob = userModel.createFromLabels({"First name": "Bob", "Last name": "Wilson"})
     * await Bob.save()
     */
    createFromLabels(record) {
        let newRecord = {}

        Object.keys(record).forEach(fieldLabel => {
            const field = this.getFieldByLabel(fieldLabel)
            const value = record[fieldLabel]
            newRecord[field.id] = value
        })
        return this.create(newRecord)
    }

    /**
     * Check the permission (client-side) to perform an action on the model.
     * 
     * @param {string} action - "update" | "delete"
     * @returns {boolean} true if the permission is granted
     */
    async checkPermission(action) {
        const record = kiss.app.collections.model.records.get(this.id)
        const hasPermission = await kiss.acl.check({
            action,
            record
        })

        if (!hasPermission) {
            createNotification(txtTitleCase("#not authorized"))
            return false
        }

        return true
    }

    /**
     * Initialize the model's fields
     * 
     * Fields is a subset of items, containing only the fields.
     * 
     * The model fields are deduced from:
     * - the model's items
     * - the model's plugins, which can add custom fields to the model
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initFields() {
        const modelFields = this.getFields()
        const featureFields = this.getFeatureFields()
        const systemFields = this.getSystemFields()
        this.fields = modelFields.concat(featureFields).concat(systemFields)

        // Adjust field labels for the client UI
        if (kiss.isClient) {
            this.fields.forEach(field => {
                if (field.label && field.label.startsWith("#")) {
                    field.label = txtTitleCase(field.label)
                }
            })
        }

        return this
    }

    /**
     * Initialize the model's items
     * 
     * - set an id
     * - cast <select> field options to objects if they're given as string
     * 
     * Important:
     * Items contains all the UI components required to render a record as a form.
     * This includes "non-field" informations like panels, buttons, images, html...
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initItems(items) {
        if (kiss.isClient) {
            this._initClientItems(items)
        } else {
            this._initServerItems(items)
        }
        return this
    }

    /**
     * Initialize the model's items for the CLIENT
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initClientItems(items, read, update) {
        if (!items) return []

        const userACL = kiss.session.getACL()
        items = items.filter(item => item != null)

        items.forEach(item => {
            if (!item.id) item.id = kiss.tools.shortUid()

            if (item.items) {
                // Section
                const canRead = kiss.tools.intersects(item.accessRead, userACL) || !item.accessRead
                const canUpdate = kiss.tools.intersects(item.accessUpdate, userACL) || !item.accessUpdate

                item.acl = item.acl || {}

                // item.acl.read = item.acl.hasOwnProperty("read") ? item.acl.read : !!canRead
                // item.acl.update = item.acl.hasOwnProperty("update") ? item.acl.update : !!canUpdate

                item.acl.read = !!canRead
                item.acl.update = !!canUpdate

                this._initClientItems(item.items, canRead, canUpdate)
            } else {
                // Fields or widgets
                item.acl = item.acl || {}

                // item.acl.read = item.acl.hasOwnProperty("read") ? item.acl.read : read
                // item.acl.update = item.acl.hasOwnProperty("update") ? item.acl.update : update

                item.acl.read = read
                item.acl.update = update

                if (item.type == "select") {
                    if (!item.options) return
                    item.options = item.options.map(option => {
                        if (typeof option == "object") return option
                        return {
                            value: option
                        }
                    })
                }
            }
        })

        this.items = items
        return this
    }

    /**
     * Initialize the model's items for the SERVER
     * 
     * - set an id
     * - cast <select> field options to objects if they're given as string
     * 
     * Important:
     * Items contains all the UI components required to render a record as a form.
     * This includes "non-field" informations like panels, buttons, images, html...
     * 
     * @private
     * @ignore
     * @param {object[]} items
     * @returns this
     */
    _initServerItems(items) {
        if (!items) return []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if (!item.id) item.id = kiss.tools.shortUid()

            if (item.items) {
                // Section
                this._initServerItems(item.items)
            } else {
                // Fields or widgets
                if (item.type == "select") {
                    if (!item.options) return
                    item.options = item.options.map(option => {
                        if (typeof option == "object") return option
                        return {
                            value: option
                        }
                    })
                }
            }
        })

        this.items = items
        return this
    }

    /**
     * Save the model's items
     */
    async saveItems() {
        const modelRecord = kiss.app.collections.model.getRecord(this.id)
        await modelRecord.update({
            items: this.items
        })
    }

    /**
     * Get a field by id
     * 
     * Note: if the field is not found, the method tries to find the field by its label
     * 
     * @param {string} fieldId
     * @returns {object} The field definition
     * 
     * @example
     * let myField = myModel.getField("xD12z4ml00z")
     * 
     * // Returns...
     * {
     *      id: "yearlyIncome",
     *      label: "Yearly income",
     *      type: "number",
     *      precision: 2,
     *      formula: "{{Monthly income}} * 12",
     * }
     */
    getField(fieldId) {
        const field = this.fields.find(field => field.id == fieldId)
        if (field) return field
        return this.getFieldByLabel(fieldId)
    }

    /**
     * Get the primary field of this model
     * 
     * @returns {object} The primary field, or the model's 1st field if it wasn't found
     */
    getPrimaryKeyField() {
        const fields = this.fields
        const primaryKeyField = fields.find(field => field.primary == true)
        if (primaryKeyField) return primaryKeyField
        return fields[0]
    }

    /**
     * Get the first field matching a label.
     * 
     * Note:
     * - if the field label is not found, it defaults to searching the field id
     * - deleted field are not taken into consideration
     * 
     * @param {string} fieldLabel
     * @returns {object} The field definition
     * 
     * @example
     * let myField = myModel.getFieldByLabel("Project name")
     */
    getFieldByLabel(fieldLabel) {
        const fields = this.fields.filter(field => !field.deleted)

        let field = fields.find(field => field.label && field.label.toLowerCase() == fieldLabel.toLowerCase())
        if (field) return field

        field = fields.find(field => field.id.toLowerCase() == fieldLabel.toLowerCase())
        return field
    }

    /**
     * Get the model's fields
     * 
     * In KissJS, the model can be directly defined by a complex form with multiple sections and sub items.
     * This method explores the tree and returns only the items which are fields.
     * 
     * Important: deleted fields are also returned, with a flag deleted = true
     * 
     * @returns {object[]} Array of field definitions
     */
    getFields(containerItems) {
        let fields = []
        let items = containerItems || this.items || []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if ((kiss.global.fieldTypes.map(type => type.value).indexOf(item.type) != -1) || (item.dataType != null)) {
                fields.push(item)
            } else {
                if (item.items) {
                    fields.push(this.getFields(item.items))
                }
            }
        })

        return fields.flat()
    }

    /**
     * Get the model's sections
     * 
     * In KissJS, the model can be directly defined by a complex form with multiple sections and sub items.
     * This method explores the tree and returns only the items which are sections.
     * 
     * @returns {object[]} Array of sections definitions
     */
    getSections() {
        let sections = []
        let items = this.items || []

        items = items.filter(item => item != null)
        items.forEach(item => {
            if (item.items) sections.push(item)
        })

        return sections
    }

    /**
     * Get the accepted fields of the model.
     * This includes model's fields, plus default system fields:
     * - id
     * - createdAt
     * - createdBy
     * - updatedAt
     * - updatedBy
     * - deletedAt
     * - deletedBy
     * - accessRead
     * - accessUpdate
     * - accessDelete
     * 
     * @private
     * @ignore
     * @returns this
     */
    _initAcceptedFields() {
        const defaultAcceptedFields = ["id", "createdAt", "createdBy", "updatedAt", "updatedBy", "deletedAt", "deletedBy", "accessRead", "accessUpdate", "accessDelete", "accessManage"]
        const acceptedFields = this.fields.map(field => field.id)
        this.acceptedFields = defaultAcceptedFields.concat(acceptedFields)
        return this
    }

    /**
     * Initialize the system fields
     * 
     * @returns {object[]} Array of system fields
     */
    getSystemFields() {
        return [{
                id: "createdAt",
                label: "#createdAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "createdBy",
                label: "#createdBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "updatedAt",
                label: "#updatedAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "updatedBy",
                label: "#updatedBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "deletedAt",
                label: "#deletedAt",
                type: "date",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            },
            {
                id: "deletedBy",
                label: "#deletedBy",
                type: "directory",
                dataType: Date,
                isSystem: true,
                readOnly: true,
                hidden: true,
                acl: {
                    update: false
                }
            }
        ]
    }

    /**
     * Get the fields brought by the model's active plugins
     * 
     * @returns {object[]} Array of field definitions
     */
    getFeatureFields() {
        if (kiss.isServer) return []

        let featureFields = []
        if (this.features) {
            Object.keys(this.features)
                .filter(featureId => this.features[featureId].active)
                .forEach(featureId => {
                    const plugin = kiss.plugins.get(featureId)
                    if (!plugin) return

                    const texts = plugin.texts || {}

                    if (plugin && plugin.fields) {
                        plugin.fields.forEach(field => {
                            field.isFromPlugin = true
                            field.pluginId = featureId
                            field.label = txtTitleCase(field.label, texts)
                            featureFields.push(field)
                        })
                    }
                })
        }
        return featureFields
    }

    /**
     * Returns the model's fields using the datatable format
     * 
     * @ignore
     * @returns {object[]} Array of columns
     */
    getFieldsAsColumns() {
        let columns = this.fields
            .filter(field => field.label)
            .map(field => {
                if (field.deleted) return null

                let columnConfig = {
                    id: field.id,
                    type: this.getFieldType(field),
                    title: (!field.label) ? txtTitleCase(field.id) : ((field.label.startsWith("#")) ? txtTitleCase(field.label) : field.label.toTitleCase())
                }

                // Flag columns coming from plugins
                if (field.isFromPlugin) {
                    columnConfig.isFromPlugin = true
                    columnConfig.pluginId = field.pluginId
                    columnConfig.title = txtTitleCase(field.label)
                }

                // Flag system columns
                if (field.isSystem) {
                    columnConfig.isSystem = true
                    columnConfig.title = txtTitleCase(field.label)
                    columnConfig.hidden = (field.hidden !== false)
                }

                return columnConfig
            })
            .filter(column => column != null)

        return columns
    }

    /**
     * Get all the views that display this model and sync their fields
     * 
     * @ignore
     */
    async syncViewsWithModelFields() {
        const viewsToUpdate = this.getViews()
        for (let viewRecord of viewsToUpdate) {
            await viewRecord.syncWithModelFields()
        }
    }

    /**
     * Get visible fields (= non deleted)
     * 
     * @returns {object[]} Array of field definitions
     */
    getActiveFields() {
        return this.fields.filter(field => !field.deleted)
    }

    /**
     * Get only the fields of specific types
     * 
     * @param {string|string[]} types - Single type or array of types. Ex: "text", "textarea", "number", or ["date", "checkbox", "select"]
     * @returns {object[]} The fields of the required type, or []
     */
    getFieldsByType(types) {
        const fields = this.fields
        if (Array.isArray(types) && types.length > 0) {
            return fields.filter(field => types.includes(field.type))
        } else {
            return fields.filter(field => field.type == types)
        }
    }

    /**
     * Get the fields which can be used for sorting
     * 
     * @returns {object[]} The list of sortable fields
     */
    getSortableFields() {
        return this.fields.filter(field => field.type != "password" && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true)
    }

    /**
     * Get the fields which can be used for grouping
     * 
     * @returns {object[]} The list of groupable fields
     */
    getGroupableFields() {
        // return this.fields.filter(field => field.multiple != true && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true)
        return this.fields.filter(field => field.type != "password" && field.type != "link" && field.type != "attachment" && field.label && field.deleted != true)
    }

    /**
     * Get the fields which can be used for filtering
     * 
     * @returns {object[]} The list of filterable fields
     */
    getFilterableFields() {
        return this.fields.filter(field => field.type != "link" && field.label && field.deleted != true)
    }

    /**
     * Get the fields which can be used inside a formula
     * @returns {object[]} The list of formula fields
     */
    getFormulaFields() {
        return this.fields.filter(field => {
            return field.type != "link" &&
                field.type != "attachment" &&
                field.label &&
                field.deleted != true &&
                field.isSystem != true &&
                field.isFromPlugin != true
        })
    }

    /**
     * Search inside a model which field links to a foreign model
     * 
     * @param {string} foreignModelId - Foreign model id
     * @returns {object} The <link> field that links to the foreign model
     */
    getLinkField(foreignModelId) {
        const fields = this.fields
        for (let field of fields) {
            if (field.type == "link") {
                if (field.link.modelId == foreignModelId) return field
            }
        }
        return null
    }

    /**
     * Get the fields which can be used for batch operations.
     * 
     * @returns {object[]} The list of fields
     */
    getBatchableFields() {
        return this.fields.filter(field => {
            return field.deleted != true &&
                field.isSystem != true &&
                // field.isFromPlugin != true &&
                field.computed != true &&
                !field.sourceFor &&
                field.type != "lookup" &&
                field.type != "summary" &&
                field.type != "attachment" &&
                field.type != "password" &&
                field.type != "selectViewColumn" &&
                field.type != "selectViewColumns" &&
                field.type != "link" &&
                field.type != "aiImage"
        })
    }

    /**
     * Generate link records between 2 models when their 2 given fields are equal.
     * 
     * Note: it does **not** save the links into the database.
     * It's up to the caller function to decide what to do with the links (create them or cancel)
     * 
     * @param {object} config
     * @param {object} config.foreignModelId
     * @param {object} config.sourceLinkFieldId
     * @param {object} config.sourceFieldId
     * @param {object} config.foreignLinkFieldId
     * @param {object} config.foreignFieldId
     * @returns {object[]} link records
     */
    async generateLinksToModel({
        foreignModelId,
        sourceLinkFieldId,
        sourceFieldId,
        foreignLinkFieldId,
        foreignFieldId
    }) {
        const localCollection = this.collection
        const foreignCollection = kiss.app.collections[foreignModelId]
        const localRecords = await localCollection.find({}, true)
        const foreignRecords = await foreignCollection.find({}, true)
        let links = []

        localRecords.forEach(localRecord => {
            foreignRecords.forEach(foreignRecord => {
                let localValue = localRecord[sourceFieldId]
                if (localValue == foreignRecord[foreignFieldId] && localValue !== "" && localValue !== undefined) {
                    links.push({
                        id: kiss.tools.uid(),
                        mX: this.id,
                        rX: localRecord.id,
                        fX: sourceLinkFieldId,
                        mY: foreignModelId,
                        rY: foreignRecord.id,
                        fY: foreignLinkFieldId,
                        auto: true // Tell it was created automatically. Can be used to rollback the process.
                    })
                }
            })
        })

        return links
    }

    /**
     * Delete all the links that were auto-generated for a given model.
     * 
     * @param {string} foreignModelId
     * @returns {integer} The number of deleted links
     */
    async deleteLinksToModel(foreignModelId) {
        const query = {
            $or: [{
                    mX: this.id,
                    mY: foreignModelId,
                    auto: true
                },
                {
                    mY: this.id,
                    mX: foreignModelId,
                    auto: true
                },
            ]
        }
        await kiss.app.collections.link.deleteMany(query)
    }

    /**
     * Connect the model to a foreign model using a <link> field.
     * 
     * To connect the 2 models, a symmetric <link> field is created in the foreign model.
     * 
     * @param {string} foreignModelId  - id of the foreign model to connect
     * @param {object} fieldSetup - Setup of the <link> field in the local model
     * @returns {object} The generated foreign <link> field
     */
    async connectToModel(foreignModelId, fieldSetup) {
        const foreignModel = kiss.app.models[foreignModelId]
        const foreignLinkFields = foreignModel.getFieldsByType("link")

        log("kiss.data.Model - Connecting model " + this.name + " to model " + foreignModel.name)

        let foreignLinkFieldId = null
        let existingLinkField = null
        let foreignLinkFieldConfig = {}

        // First, check if a deleted link field already points to the same model.
        // If yes, just restore the field instead of creating a new one.
        foreignLinkFields.forEach(linkField => {
            if (linkField.link.modelId == this.id) existingLinkField = linkField
        })

        if (existingLinkField != null) {
            // A link field already exists: we update it
            log("kiss.data.Model - connectToModel: updating existing link in the foreign model " + foreignModel.name, 2)

            delete existingLinkField.deleted
            existingLinkField.link.field = fieldSetup.label
            existingLinkField.link.fieldId = fieldSetup.id

            // Restore the field in case it was deleted
            existingLinkField.deleted = false
            existingLinkField.hidden = false

            await foreignModel.updateField(existingLinkField.id, existingLinkField)
            foreignLinkFieldId = existingLinkField.id

        } else {
            // There is no link field: we create it
            log("kiss.data.Model - connectToModel: adding a new link field in the foreign model " + foreignModel.name, 2)

            foreignLinkFieldConfig = {
                id: kiss.tools.shortUid(),
                label: this.namePlural,
                type: "link",

                // Layout parameters
                display: "inline-flex",
                width: "100%",
                fieldWidth: "100%",
                labelWidth: "100%",
                labelPosition: "top",
                labelAlign: "left",

                link: {
                    model: this.name,
                    modelId: this.id,
                    field: fieldSetup.label,
                    fieldId: fieldSetup.id
                }
            }

            await foreignModel.addField(foreignLinkFieldConfig)
            foreignLinkFieldId = foreignLinkFieldConfig.id

            log("kiss.data.Model - Foreign link field config:", 2, foreignLinkFieldConfig)
        }

        // Update the local link field
        fieldSetup.deleted = fieldSetup.hidden = false
        fieldSetup.link.fieldId = foreignLinkFieldId
        await this.updateField(fieldSetup.id, fieldSetup)

        // Update model relationships
        this._defineRelationships()
        foreignModel._defineRelationships()

        return foreignLinkFieldConfig
    }

    /**
     * Create a new field configuration.
     * This method also updates the views that are connected to the model.
     * 
     * @async
     * @param {object} config - New field config
     * @param {string} [sectionId] - Optional section id. If provided, adds the field at the end this section
     * @returns {boolean} true in case of success
     */
    async addField(config, sectionId) {

        // const hasPermission = await this.checkPermission("update")
        // if (!hasPermission) return false

        // Enforce field id
        if (!config.id) config.id = kiss.tools.shortUid()

        // If the model doesn't have any section, adds the field at the end
        if (!this.hasSections()) {
            this.items.push(config)
        } else {
            if (!sectionId) {
                // No specific section is given to add the field: it is added at the end of the last section
                const lastSection = this.items[this.items.length - 1]
                lastSection.items.push(config)
            } else {
                // A specific section is given: we append the field to it
                this.items
                    .find(section => section.id == sectionId)
                    .items
                    .push(config)
            }
        }

        // Recompute formulas
        this._initComputedFields()

        // Update the model's record which is stored in db
        await this.saveItems()

        // Update the fields
        this._initFields()

        // Computed fields need to be computed on every record
        if (config.computed) await this.updateFieldFormula(config.id)

        // Get all the views that display this model and update them
        await this.syncViewsWithModelFields()

        // For offline apps, re-compute relationships locally
        if (kiss.session.isOffline()) {
            this._defineRelationships()
        }

        // Reset the context
        kiss.context.addFieldToSectionId = null

        return true
    }

    /**
     * Check if the model has sections.
     * 
     * @returns {boolean}
     */
    hasSections() {
        const modelSections = this.items.filter(item => item.type == "panel")
        return (modelSections.length > 0)
    }

    /**
     * Update a field configuration.
     * This method also updates the views that are connected to the model.
     * 
     * @async
     * @param {string} fieldId 
     * @param {object} config - New field config
     * @param {boolean} shouldUpdateFormula - If true, re-compute the field value on every record of the collection
     * @returns {boolean} true in case of success
     */
    async updateField(fieldId, config, shouldUpdateFormula) {

        // const hasPermission = await this.checkPermission("update")
        // if (!hasPermission) return false

        // Update the model's field
        this._updateItemInTree(this, fieldId, config)

        // Recompute formulas
        this._initComputedFields()

        // Update the model's record which is stored in db
        await this.saveItems()

        // Update the fields
        this._initFields()

        // Computed field need to update their values (100% server-side process)
        if (shouldUpdateFormula) await this.updateFieldFormula(fieldId)

        // Get all the views that display this model and update them
        await this.syncViewsWithModelFields()

        // For offline apps, re-compute relationships locally
        if (kiss.session.isOffline()) {
            this._defineRelationships()
        }

        return true
    }

    /**
     * Recompute the computed field value on every record of the collection.
     * 
     * @async
     */
    async updateFieldFormula() {
        if (kiss.session.isOffline()) {
            await kiss.data.relations.updateAllDeep(this.id)
        } else {
            await kiss.ajax.request({
                showLoading: true,
                url: "/updateAllDeep",
                method: "post",
                body: JSON.stringify({
                    modelId: this.id
                })
            })
        }
    }

    /**
     * Delete a field configuration and update all the views that are connected to this model.
     * A primary field can't (and must not) be deleted.
     * 
     * @async
     * @param {string} fieldId
     * @returns {boolean} false if the field couldn't be deleted (primary field)
     */
    async deleteField(fieldId) {
        const field = this.getField(fieldId)

        log(`kiss.data.Model - deleteField: ${fieldId} / ${field.label}`)

        if (field.primary == true) {
            log("kiss.data.Model - deleteField: could not delete primary field", 3)
            return false
        }

        field.deleted = true
        await this.updateField(fieldId, field)

        // Update the fields property
        this._initFields()

        return true
    }

    /**
     * Creates the first form section, at the top of the form
     * 
     * @ignore
     * @param {object} config - Section configuration
     * @returns {string} The new section id
     */
    async createFirstSection(config) {
        const newSectionId = kiss.tools.shortUid()
        const newSection = {
            id: newSectionId,
            type: "panel",
            icon: config.icon || "far fa-file-alt",
            title: config.title || "Section",
            collapsible: true,
            collapsed: config.collapsed,
            colored: config.colored,
            acl: config.acl,
            items: this.items
        }
        this.items = [newSection]
        await this.saveItems()

        // Update items & fields
        this._initItems(this.items)
        this._initFields()

        return newSectionId
    }

    /**
     * Creates a new section just before the item passed as a parameter 
     * 
     * @ignore
     * @param {object} config - Section configuration
     * @param {string} breakItemId - Item id where to break the form
     * @returns {string|boolean} The new section id, or false if it did not succeed
     */
    async createSection(config, breakItemId) {
        const sections = this.items

        // Explore the model to find the section and break index
        let breakIndex
        let sectionIndex

        sections.forEach((section, index) => {
            const itemIndex = section.items.findIndex(item => item.id == breakItemId)
            if (itemIndex != -1) {
                breakIndex = itemIndex
                sectionIndex = index
            }
        })

        // Can't insert a section immediately after an existing section!
        if (breakIndex == 0) return false

        // Rebuild a new section after the break index
        const fieldsInPreviousSection = sections[sectionIndex].items.slice(0, breakIndex)
        const fieldsInNewSection = sections[sectionIndex].items.slice(breakIndex)
        sections[sectionIndex].items = fieldsInPreviousSection

        const newSectionId = kiss.tools.shortUid()
        const newSection = {
            id: newSectionId,
            type: "panel",
            icon: config.icon || "far fa-file-alt",
            title: config.title || "Section",
            collapsible: true,
            collapsed: config.collapsed,
            colored: config.colored,
            accessRead: config.accessRead,
            accessUpdate: config.accessUpdate,
            acl: config.acl,
            items: fieldsInNewSection
        }

        sections.splice(sectionIndex + 1, 0, newSection)

        this.items = sections
        await this.saveItems()

        // Update items & fields
        this._initItems(this.items)
        this._initFields()

        return newSectionId
    }

    /**
     * Check if an item is the first in its section.
     * (used to perform checks in the form builder)
     * 
     * @param {string} itemId
     * @returns {boolean}
     */
    isFirstItemInSection(itemId) {
        if (!this.hasSections()) return false

        let breakIndex
        const sections = this.items

        sections.forEach((section, index) => {
            const itemIndex = section.items.findIndex(item => item.id == itemId)
            if (itemIndex != -1) {
                breakIndex = itemIndex
            }
        })

        // Can't insert a section immediately after an existing section!
        if (breakIndex === 0) return true
        return false
    }

    /**
     * Update a section configuration
     * 
     * @async
     * @param {string} sectionId
     * @param {object} newSectionConfig 
     */
    async updateSection(sectionId, newSectionConfig) {
        // Update the model's field
        this._updateItemInTree(this, sectionId, newSectionConfig)

        // Update the model's record which is stored in db
        await this.saveItems()

        // Update items & fields
        this._initItems(this.items)
        this._initFields()
    }

    /**
     * Delete a section, and move its items into the previous section.
     * The method doesn't allow to delete the 1st section.
     * 
     * @ignore
     * @param {string} sectionId 
     * @returns {boolean} false if the section id could not be found
     */
    async deleteSection(sectionId) {
        for (let i = 0; i < this.items.length; i++) {
            const section = this.items[i]

            if (section.id == sectionId) {
                const fieldsToMove = section.items
                let previousSection = this.items[i - 1]
                let nextSection = this.items[i + 1]

                if (i == 0) {
                    if (nextSection) {
                        // Move items to next section, if there is one
                        nextSection.items.splice(0, 0, ...fieldsToMove)
                        this.items.splice(i, 1)
                    } else {
                        // Otherwise, there are no sections anymore
                        this.items = fieldsToMove
                    }
                } else {
                    // Move items to previous section
                    previousSection.items.splice(previousSection.items.length, 0, ...fieldsToMove)
                    this.items.splice(i, 1)
                }

                await this.saveItems()

                // Update items & fields
                this._initItems(this.items)
                this._initFields()

                return true
            }
        }
    }

    /**
     * Move a section from a position to another (move "up" or "down")
     * 
     * @ignore
     * @param {string} sectionId 
     * @param {string} direction - "up" | "down"
     * @returns {boolean} false if the section id could not be found
     */
    async moveSection(sectionId, direction) {
        const fromIndex = this.items.findIndex(section => section.id == sectionId)
        if (fromIndex == -1) return false

        const toIndex = (direction == "down") ? fromIndex + 1 : fromIndex - 1

        // Switch section positions
        const tempSection = this.items[fromIndex]
        this.items[fromIndex] = this.items[toIndex]
        this.items[toIndex] = tempSection

        await this.saveItems()
        return true
    }

    /**
     * Get a section by id
     * 
     * Note: if the section is not found, the method tries to find the section by its title
     * 
     * @param {string} fieldId
     * @returns {object} The section definition
     * 
     * @example
     * let mySection = myModel.getSection("General informations")
     * 
     * // Returns...
     * {
     *      id: "aE7x450",
     *      title: "General informations",
     *      items: [
     *          // ... Section items
     *      ]
     * }
     */
    getSection(sectionId) {
        let section = this.items.find(section => section.id == sectionId)
        if (section) return section
        return this.getSectionByTitle(sectionId)
    }

    /**
     * Get the first section matching a title.
     * 
     * Note: if the section title is not found, it defaults to searching the section id
     * 
     * @param {string} sectionTitle
     * @returns {object} The section definition
     * 
     * @example
     * let mySection = myModel.getSectionByTitle("General informations")
     */
    getSectionByTitle(sectionTitle) {
        let section = this.items.find(section => section.title && section.title.toLowerCase() == sectionTitle.toLowerCase())
        if (section) return section

        section = this.items.find(section => section.id.toLowerCase() == sectionTitle.toLowerCase())
        return section
    }

    /**
     * Get all the views that are connected to this model
     * 
     * @returns {Record[]} Array of records containing the view configurations
     */
    getViews() {
        const viewCollection = kiss.app.collections.view
        if (!viewCollection) return

        const viewRecords = viewCollection.records
        const modelViews = viewRecords.filter(view => view.modelId == this.id)
        return modelViews
    }

    /**
     * Get the views a user is allowed to access.
     * 
     * A user can see a view if:
     * - he is the account owner
     * - he is the view creator
     * - the view read access is allowed to all authenticated users
     * - he is mentionned in the field "accessRead"
     * 
     * @param {string} userId 
     * @returns {object[]} The list of authorized views
     */
    getViewsByUser(userId) {
        const views = this.getViews()
        const userACL = kiss.directory.getUserACL(userId)

        // Account owner always sees all the views
        if (kiss.session.isOwner) return views

        return views.filter(view => {
            return !!view.authenticatedCanRead == true || kiss.tools.intersects(view.accessRead, userACL) || view.createdBy == userId
        })
    }

    /**
     * Discover dynamically the relationships with foreign models.
     * 
     * Call this method once your models are loaded and available into kiss.app.models.
     * 
     * @private
     * @ignore
     * @returns {object} - Relationships with linked foreign models
     */
    _defineRelationships() {
        const modelProblems = []
        this.sourceFor = this.sourceFor || []
        const fields = this.fields.filter(field => !field.deleted)

        // Parse connections established by "link" fields
        fields.filter(field => field.type == "link").forEach(field => {
            try {
                let targetLinkModel = kiss.app.getModel(field.link.modelId || field.link.model)

                // Show the relationships in the console
                let hasMany = field.multiple
                let toModel = (hasMany) ? targetLinkModel.namePlural : targetLinkModel.name
                // log(`kiss.data.Model - ${this.name.padEnd(40, " ")} -> ${(hasMany) ? "N" : "1"} ${toModel.padEnd(40, " ")}` + " (link field: " + field.label + ")")

            } catch (err) {
                // Problem, the foreign model does not exist
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The link field <${this.name + " / " + field.label}> points to a foreign model that can't be found`)
            }
        })

        // Parse connections established by "lookup" fields
        fields.filter(field => field.type == "lookup").forEach(field => {
            try {
                // Get the field to lookup in the foreign model
                let lookupLinkField = this.getField(field.lookup.linkId || field.lookup.link)
                let lookupLinkedModel = kiss.app.models[lookupLinkField.link.modelId]
                let lookupSourceField = lookupLinkedModel.getField(field.lookup.fieldId || field.lookup.field)

                // The foreign model is a source for this one
                lookupLinkedModel.sourceFor = (lookupLinkedModel.sourceFor || []).concat(this.id).unique()

                // Link model => foreign model
                field.lookup.linkId = lookupLinkField.id
                field.lookup.fieldId = lookupSourceField.id
                field.lookup.type = lookupSourceField.type
                if (field.lookup.type == "number") field.precision = lookupSourceField.precision

                // Link foreign model => model
                lookupSourceField.sourceFor = lookupSourceField.sourceFor || []
                let newSource = {
                    modelId: this.id,
                    modelName: this.name,
                    fieldId: field.id,
                    fieldName: field.label,
                    type: "lookup"
                }

                if (!lookupSourceField.sourceFor.includesObject(newSource)) lookupSourceField.sourceFor.push(newSource)
                lookupSourceField.sourceFor = lookupSourceField.sourceFor.uniqueObject("fieldId")

            } catch (err) {
                // Problem, the foreign model does not exist
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The lookup field <${this.name + " / " + field.label}> points to a model that can't be found`)
            }
        })

        // Parse connections established by "summary" fields
        fields.filter(field => field.type == "summary").forEach(field => {
            try {
                // Get the field to summarize in the foreign model
                let summaryLinkField = this.getField(field.summary.linkId || field.summary.link)
                let summaryLinkModel = kiss.app.models[summaryLinkField.link.modelId]
                let summaryField = summaryLinkModel.getField(field.summary.field || field.summary.fieldId)

                // The foreign model is a source for this one
                summaryLinkModel.sourceFor = (summaryLinkModel.sourceFor || []).concat(this.id).unique()

                // Link model => foreign model
                field.summary.linkId = summaryLinkField.id
                field.summary.fieldId = summaryField.id
                field.summary.type = summaryField.type
                if (field.summary.type == "number") field.precision = summaryField.precision

                // Link foreign model => model
                summaryField.sourceFor = summaryField.sourceFor || []
                let newSource = {
                    modelId: this.id,
                    modelName: this.name,
                    fieldId: field.id,
                    fieldName: field.label,
                    type: "summary"
                }

                if (!summaryField.sourceFor.includesObject(newSource)) summaryField.sourceFor.push(newSource)
                summaryField.sourceFor = summaryField.sourceFor.uniqueObject("fieldId")

            } catch (err) {
                // Problem, the foreign model does not exist
                field.type = "text"
                modelProblems.push(`kiss.data.Model - The summary field <${this.name + " / " + field.label}> points to a model that can't be found`)
            }
        })

        // Clean the list of foreign models that depends on this one for computed fields
        this.sourceFor = this.sourceFor.unique()

        modelProblems.forEach(warning => log(warning))
    }

    /**
     * Export the model definition as JSON.
     * 
     * This is useful to be able to import/export pre-built application templates.
     */
    exportAsJSON() {
        return {
            id: this.id,
            name: this.name,
            namePlural: this.namePlural,
            language: kiss.language.current,
            icon: this.icon,
            color: this.color,
            fullscreen: !!this.fullscreen,
            items: this.items.map(section => {
                section.items = section.items.filter(item => !item.deleted).map(this._sanitizeFieldProperties)

                // Neutralize ACL
                section.accessRead = ["*"]
                section.accessUpdate = ["*"]
                return section
            }),
            features: this.features,

            // Neutralize ACL
            authenticatedCanCreate: true,
            authenticatedCanRead: true,
            authenticatedCanUpdate: true,
            authenticatedCanDelete: true,
            ownerCanManage: true,
            accessCreate: [],
            accessRead: [],
            accessUpdate: [],
            accessDelete: [],
            accessManage: []
        }
    }

    /**
     * Notify the user about a change that has been made by someone else
     * 
     * @private
     * @ignore
     * @param {object} msgData - The original pubsub message
     */
    _notifyUser(msgData) {
        if (msgData.userId == kiss.session.getUserId()) return

        let msgEvent
        let object

        if (msgData.channel == "EVT_DB_UPDATE_BULK") {
            msgEvent = txtTitleCase("#msg update")
            object = "#some data"
        } else {
            const event = msgData.channel.split(":")[0]
            const modelId = msgData.channel.split(":")[1]

            if (event.includes("INSERT")) msgEvent = txtTitleCase("#msg insert")
            else if (event.includes("UPDATE")) msgEvent = txtTitleCase("#msg update")
            else if (event.includes("DELETE")) msgEvent = txtTitleCase("#msg delete")

            if (kiss.tools.isUid(modelId.toLowerCase())) object = "#a record"
            else if (modelId == "VIEW") object = "#a view"
            else if (modelId == "MODEL") object = "#a model"
            else object = "#some data"
        }

        createNotification({
            message: txtTitleCase(
                msgEvent, null, {
                    user: kiss.directory.getEntryName(msgData.userId),
                    object: txt(object)
                }
            ),
            top: () => kiss.screen.current.height - 50,
            left: 10,
            height: "40px",
            padding: "0px",
            animation: "slideInUp",
            duration: 4000
        })
    }

    /**
     * Update an item in the nested model's config
     * 
     * @private
     * @ignore
     * @param {object} node - Root node to explore
     * @param {string} itemId - Id of the field to update
     * @param {object} config - New field config
     */
    _updateItemInTree(node, itemId, config) {
        if (node.id == itemId) {
            Object.assign(node, config)
        } else if (node.items) {
            for (let i = 0; i < node.items.length; i++) {
                this._updateItemInTree(node.items[i], itemId, config)
            }
        }
    }

    /**
     * Sanitize field properties before exporting Model (as JSON)
     * 
     * @private
     * @ignore
     * @param {object} field - Field JSON definition
     * @returns {object} The sanitized field definition
     */
    _sanitizeFieldProperties(field) {
        delete field.acl

        // Reset field formulas
        delete field.formulaSourceFields
        delete field.formulaSourceFieldIds

        if ((field.type == "lookup") || (field.type == "summary")) delete field.formula

        // Reset relations
        delete field.sourceFor
        if (field.type == "link") delete field.link.model

        // Reset DOM specific properties
        delete field.target

        // Sort props alphabetically
        let exportedField = {}
        Object.keys(field)
            .sortAlpha()
            .forEach(property => exportedField[property] = field[property])

        return exportedField
    }

    /**
     * Get a field type
     * 
     * Specific field types like "lookup" and "summary" have to be converted to the type of the fields they point to.
     * For example, if a "lookup" field is getting the value of a "number" field, the "real" field type is "number"
     * 
     * Warning:
     * - this method doesn't return the field data type.
     * - field type and field data type are 2 different things: a field which type is "checkbox" has a "boolean" data type.
     * 
     * @private
     * @ignore
     * @param {object} field
     * @returns {string} The field type: "text", "number", "date", "checkbox", "select"...
     */
    getFieldType(field) {
        if (field.type == "lookup") {
            return field.lookup.type
        } else if (field.type == "summary") {
            return field.summary.type
        } else {
            return field.type || "text"
        }
    }

    /**
     * Transform the fields "semantic" formulae into some formulae ready to be evaluated.
     * 
     * @private
     * @ignore
     * @returns this
     * 
     * @example
     * formula: {{income}} * 12
     */
    _initComputedFields() {
        this.computedFields = []
        const fields = this.getActiveFields()

        for (let i = 0; i < fields.length; i++) {
            let field = fields[i]

            if (field.computed) {
                // Add this field to the list of computed fields
                this.computedFields.push(field.id)

                // Keep in cache the field dependencies of the formula:
                // - field names
                field.formulaSourceFields = kiss.tools.findTags(field.formula)

                // - field ids
                field.formulaSourceFieldIds = (field.formulaSourceFieldIds || [])
                    .concat(
                        field.formulaSourceFields.map(sourceFieldName => {
                            let sourceField
                            const fieldIndex = Number(sourceFieldName)
                            const isFieldIndex = Number.isInteger(fieldIndex)

                            if (isFieldIndex) {
                                sourceField = fields[fieldIndex]
                            } else {
                                sourceField = this.getFieldByLabel(sourceFieldName)
                            }

                            if (sourceField) return sourceField.id
                            return sourceFieldName
                        })
                    )
                    .unique()
            }
        }
        return this
    }

    /**
     * Check if the field labels used in the formula are still valid.
     * If not, returns the list of invalid field labels.
     * 
     * @param {string} fieldId 
     * @returns {string[]} Array with the wrong field labels (empty if OK)
     */
    checkFormula(formula) {
        const tags = kiss.tools.findTags(formula)
        let errorFields = []

        tags.forEach(fieldLabel => {
            const field = this.getFieldByLabel(fieldLabel)
            if (!field) errorFields.push(fieldLabel)
        })

        return errorFields
    }
}

;