dobo 2.14.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extend/bajo/intl/en-US.json +1 -0
- package/extend/bajo/intl/id.json +1 -0
- package/extend/dobo/feature/created-at.js +4 -4
- package/extend/dobo/feature/dt.js +2 -2
- package/extend/dobo/feature/immutable.js +7 -6
- package/extend/dobo/feature/removed-at.js +6 -6
- package/extend/dobo/feature/unique.js +4 -4
- package/extend/dobo/feature/updated-at.js +6 -6
- package/extend/waibuMpa/route/attachment/@model/@id/@field/@file.js +1 -1
- package/index.js +65 -10
- package/lib/collect-models.js +16 -8
- package/lib/factory/action.js +6 -6
- package/lib/factory/connection.js +2 -2
- package/lib/factory/feature.js +2 -2
- package/lib/factory/model/_util.js +49 -60
- package/lib/factory/model/create-attachment.js +5 -5
- package/lib/factory/model/find-attachment.js +4 -4
- package/lib/factory/model/get-attachment.js +3 -3
- package/lib/factory/model/list-attachment.js +5 -5
- package/lib/factory/model/remove-attachment.js +2 -2
- package/lib/factory/model/sanitize-record.js +20 -1
- package/lib/factory/model.js +2 -2
- package/package.json +1 -1
- package/wiki/CHANGES.md +27 -0
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
"maxLimitWarning%s%s": "Records per page (%s rows) above the allowed threshold (%s rows)",
|
|
149
149
|
"hardCapWarning%s%s": "Max records returned (%s rows) above the allowed threshold (%s rows)",
|
|
150
150
|
"maxPageError%s%s": "Page number (%s) above the allowed threshold (%s)",
|
|
151
|
+
"duplicateRefKeys%s%s": "Duplicate reference keys found in '%s' (%s)",
|
|
151
152
|
"field": {
|
|
152
153
|
"id": "ID",
|
|
153
154
|
"code": "Kode",
|
package/extend/bajo/intl/id.json
CHANGED
|
@@ -146,6 +146,7 @@
|
|
|
146
146
|
"maxLimitWarning%s%s": "Data per halaman (%s baris) melampaui batas yang diijinkan (%s baris)",
|
|
147
147
|
"hardCapWarning%s%s": "Maksimum data yang dihasilkan (%s baris) melampaui batas yang diijinkan (%s baris)",
|
|
148
148
|
"maxPageError%s%s": "Nomor halaman (%s) melampaui batas yang diijinkan (%s)",
|
|
149
|
+
"duplicateRefKeys%s%s": "Ditemukan kunci referensi duplikat di '%s' (%s)",
|
|
149
150
|
"field": {
|
|
150
151
|
"id": "ID",
|
|
151
152
|
"code": "Kode",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
async function createdAt (opts = {}) {
|
|
2
|
-
opts.
|
|
2
|
+
opts.field = opts.field ?? 'createdAt'
|
|
3
3
|
opts.noOverwrite = opts.noOverwrite ?? false
|
|
4
4
|
return {
|
|
5
5
|
properties: [{
|
|
6
|
-
name: opts.
|
|
6
|
+
name: opts.field,
|
|
7
7
|
type: 'datetime',
|
|
8
8
|
index: true
|
|
9
9
|
}],
|
|
@@ -11,8 +11,8 @@ async function createdAt (opts = {}) {
|
|
|
11
11
|
name: 'beforeCreateRecord',
|
|
12
12
|
handler: async function (body, options) {
|
|
13
13
|
const { isSet } = this.app.lib.aneka
|
|
14
|
-
if (opts.noOverwrite) body[opts.
|
|
15
|
-
else if (!isSet(body[opts.
|
|
14
|
+
if (opts.noOverwrite) body[opts.field] = new Date()
|
|
15
|
+
else if (!isSet(body[opts.field])) body[opts.field] = new Date()
|
|
16
16
|
}
|
|
17
17
|
}]
|
|
18
18
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
async function dt (opts = {}) {
|
|
2
|
-
opts.
|
|
2
|
+
opts.field = opts.field ?? 'dt'
|
|
3
3
|
return {
|
|
4
4
|
properties: [{
|
|
5
|
-
name: opts.
|
|
5
|
+
name: opts.field ?? 'dt',
|
|
6
6
|
type: 'datetime',
|
|
7
7
|
required: opts.required ?? true,
|
|
8
8
|
index: opts.index ?? true
|
|
@@ -1,27 +1,28 @@
|
|
|
1
|
-
async function beforeRemoveRecord (id, opts) {
|
|
1
|
+
async function beforeRemoveRecord (id, opts, options) {
|
|
2
2
|
const { get } = this.app.lib._
|
|
3
|
+
if (get(options, 'req.user.interSiteAdmin')) return
|
|
3
4
|
const record = await this.driver.getRecord(this, id)
|
|
4
|
-
const immutable = get(record.data, opts.
|
|
5
|
+
const immutable = get(record.data, opts.field)
|
|
5
6
|
if (immutable) throw this.plugin.error('recordImmutable%s%s', id, this.name, { statusCode: 423 })
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
async function immutable (opts = {}) {
|
|
9
|
-
opts.
|
|
10
|
+
opts.field = opts.field ?? '_immutable'
|
|
10
11
|
return {
|
|
11
12
|
properties: {
|
|
12
|
-
name: opts.
|
|
13
|
+
name: opts.field,
|
|
13
14
|
type: 'boolean',
|
|
14
15
|
hidden: true
|
|
15
16
|
},
|
|
16
17
|
hooks: [{
|
|
17
18
|
name: 'beforeUpdateRecord',
|
|
18
19
|
handler: async function (id, body, options) {
|
|
19
|
-
await beforeRemoveRecord.call(this, id, opts)
|
|
20
|
+
await beforeRemoveRecord.call(this, id, opts, options)
|
|
20
21
|
}
|
|
21
22
|
}, {
|
|
22
23
|
name: 'beforeRemoveRecord',
|
|
23
24
|
handler: async function (id, options) {
|
|
24
|
-
await beforeRemoveRecord.call(this, id, opts)
|
|
25
|
+
await beforeRemoveRecord.call(this, id, opts, options)
|
|
25
26
|
}
|
|
26
27
|
}]
|
|
27
28
|
}
|
|
@@ -6,24 +6,24 @@ async function beforeFindRecord ({ filter = {} }, opts) {
|
|
|
6
6
|
if (filter.query.$and) q.$and.push(...filter.query.$and)
|
|
7
7
|
else q.$and.push(filter.query)
|
|
8
8
|
}
|
|
9
|
-
q.$and.push(set({}, opts.
|
|
9
|
+
q.$and.push(set({}, opts.field, null))
|
|
10
10
|
filter.query = q
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
async function afterGetRecord ({ id, record = {} }, opts) {
|
|
14
14
|
const { isEmpty } = this.app.lib._
|
|
15
|
-
if (!isEmpty(record.data[opts.
|
|
15
|
+
if (!isEmpty(record.data[opts.field])) throw this.error('recordNotFound%s%s', id, this.name, { statusCode: 404 })
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function beforeCreateRecord ({ body = {} }, opts) {
|
|
19
|
-
delete body[opts.
|
|
19
|
+
delete body[opts.field]
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
async function removedAt (opts = {}) {
|
|
23
|
-
opts.
|
|
23
|
+
opts.field = opts.field ?? '_removedAt'
|
|
24
24
|
return {
|
|
25
25
|
properties: {
|
|
26
|
-
name: opts.
|
|
26
|
+
name: opts.field,
|
|
27
27
|
type: 'datetime',
|
|
28
28
|
index: true,
|
|
29
29
|
hidden: true
|
|
@@ -52,7 +52,7 @@ async function removedAt (opts = {}) {
|
|
|
52
52
|
name: 'beforeRemoveRecord',
|
|
53
53
|
handler: async function (id, options) {
|
|
54
54
|
const { set } = this.app.lib._
|
|
55
|
-
const body = set({}, opts.
|
|
55
|
+
const body = set({}, opts.field, new Date())
|
|
56
56
|
const record = await this.driver.recordUpdate(this, id, body, { noResult: false })
|
|
57
57
|
options.record = { oldData: record.oldData }
|
|
58
58
|
}
|
|
@@ -2,11 +2,11 @@ import crypto from 'crypto'
|
|
|
2
2
|
|
|
3
3
|
async function unique (opts = {}) {
|
|
4
4
|
const { omit } = this.app.lib._
|
|
5
|
-
opts.
|
|
5
|
+
opts.field = opts.field ?? 'id'
|
|
6
6
|
opts.fields = opts.fields ?? []
|
|
7
7
|
return {
|
|
8
8
|
properties: [{
|
|
9
|
-
name: opts.
|
|
9
|
+
name: opts.field,
|
|
10
10
|
type: 'string',
|
|
11
11
|
maxLength: 32,
|
|
12
12
|
required: true,
|
|
@@ -16,12 +16,12 @@ async function unique (opts = {}) {
|
|
|
16
16
|
name: 'beforeCreateRecord',
|
|
17
17
|
level: 1000,
|
|
18
18
|
handler: async function (body, options) {
|
|
19
|
-
if (opts.fields.length === 0) opts.fields = omit(this.properties.map(prop => prop.name), [opts.
|
|
19
|
+
if (opts.fields.length === 0) opts.fields = omit(this.properties.map(prop => prop.name), [opts.field])
|
|
20
20
|
const item = {}
|
|
21
21
|
for (const f of opts.fields) {
|
|
22
22
|
item[f] = body[f]
|
|
23
23
|
}
|
|
24
|
-
body[opts.
|
|
24
|
+
body[opts.field] = crypto.createHash('md5').update(JSON.stringify(item)).digest('hex')
|
|
25
25
|
}
|
|
26
26
|
}]
|
|
27
27
|
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
async function updatedAt (opts = {}) {
|
|
2
2
|
const { isSet } = this.app.lib.aneka
|
|
3
|
-
opts.
|
|
3
|
+
opts.field = opts.field ?? 'updatedAt'
|
|
4
4
|
opts.noOverwrite = opts.noOverwrite ?? false
|
|
5
5
|
return {
|
|
6
6
|
properties: {
|
|
7
|
-
name: opts.
|
|
7
|
+
name: opts.field,
|
|
8
8
|
type: 'datetime',
|
|
9
9
|
index: true
|
|
10
10
|
},
|
|
11
11
|
hooks: [{
|
|
12
12
|
name: 'beforeCreateRecord',
|
|
13
13
|
handler: async function (body, options) {
|
|
14
|
-
if (opts.noOverwrite) body[opts.
|
|
15
|
-
else if (!isSet(body[opts.
|
|
14
|
+
if (opts.noOverwrite) body[opts.field] = new Date()
|
|
15
|
+
else if (!isSet(body[opts.field])) body[opts.field] = new Date()
|
|
16
16
|
}
|
|
17
17
|
}, {
|
|
18
18
|
name: 'beforeUpdateRecord',
|
|
19
19
|
handler: async function (id, body, options) {
|
|
20
|
-
if (opts.noOverwrite) body[opts.
|
|
21
|
-
else if (!isSet(body[opts.
|
|
20
|
+
if (opts.noOverwrite) body[opts.field] = new Date()
|
|
21
|
+
else if (!isSet(body[opts.field])) body[opts.field] = new Date()
|
|
22
22
|
}
|
|
23
23
|
}]
|
|
24
24
|
}
|
|
@@ -7,7 +7,7 @@ async function attachment (req, reply) {
|
|
|
7
7
|
const { fs } = this.app.lib
|
|
8
8
|
const mdl = this.app.dobo.getModel(req.params.model)
|
|
9
9
|
const type = req.query.type
|
|
10
|
-
const items = (await mdl.listAttachments({ id: req.params.id,
|
|
10
|
+
const items = (await mdl.listAttachments({ id: req.params.id, field: req.params.field, file: '*', type })) ?? []
|
|
11
11
|
let item
|
|
12
12
|
if (req.params.file === '_first') item = items[0]
|
|
13
13
|
else if (req.params.file === '_last') item = last(items)
|
package/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import nql from '@tryghost/nql'
|
|
1
2
|
import collectConnections from './lib/collect-connections.js'
|
|
2
3
|
import collectDrivers from './lib/collect-drivers.js'
|
|
3
4
|
import collectFeatures from './lib/collect-features.js'
|
|
@@ -81,7 +82,7 @@ const propertyType = {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
const commonPropertyTypes = ['name', 'type', 'required', 'rules', 'validator', 'ref', 'default', 'values', 'rulesMsg', 'immutable', 'feature']
|
|
85
|
+
const commonPropertyTypes = ['name', 'type', 'required', 'rules', 'validator', 'ref', 'default', 'values', 'rulesMsg', 'immutable', 'feature', 'format']
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
88
|
* Plugin factory
|
|
@@ -150,8 +151,8 @@ async function factory (pkgName) {
|
|
|
150
151
|
filter: {
|
|
151
152
|
limit: 25, // num of records per page
|
|
152
153
|
maxLimit: 200, // max num of records per page
|
|
153
|
-
maxPage:
|
|
154
|
-
sort: ['dt:-1', 'updatedAt:-1', '
|
|
154
|
+
maxPage: 400, // max allowed page number
|
|
155
|
+
sort: ['dt:-1', 'updatedAt:-1', 'createdAt:-1', 'ts:-1', 'username', 'name']
|
|
155
156
|
},
|
|
156
157
|
cache: {
|
|
157
158
|
ttlDur: '10s'
|
|
@@ -496,13 +497,10 @@ async function factory (pkgName) {
|
|
|
496
497
|
}
|
|
497
498
|
|
|
498
499
|
getDefaultValues = (options = {}) => {
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
const maxPage = get(options, 'req.site.setting.dobo.default.filter.maxPage', config.default.filter.maxPage)
|
|
504
|
-
const hardCap = get(options, 'req.site.setting.dobo.default.hardCap', config.default.hardCap)
|
|
505
|
-
const warnings = get(options, 'req.site.setting.dobo.default.warnings', config.default.warnings)
|
|
500
|
+
const key = 'default.filter'
|
|
501
|
+
let config = this.app.dobo.getConfig(key)
|
|
502
|
+
if (options.req) config = options.req.getSetting(`dobo:${key}`, config)
|
|
503
|
+
const { limit, maxLimit, maxPage, hardCap, warnings } = config
|
|
506
504
|
const t = options.req ? options.req.t : this.t
|
|
507
505
|
return { limit, maxLimit, hardCap, maxPage, warnings, t }
|
|
508
506
|
}
|
|
@@ -518,6 +516,63 @@ async function factory (pkgName) {
|
|
|
518
516
|
}
|
|
519
517
|
}
|
|
520
518
|
}
|
|
519
|
+
|
|
520
|
+
parseNql = (text) => {
|
|
521
|
+
const sanitized = text.split('+').map(item => {
|
|
522
|
+
const [key, ...rest] = item.split(':').map(i => i.trim())
|
|
523
|
+
let value = rest.join(':')
|
|
524
|
+
const neg = value[1] === '-' ? '-' : ''
|
|
525
|
+
if ((value[0] === '{' || value[1] === '{') && value[value.length - 1] === '}') {
|
|
526
|
+
if (value[0] === '-') value = value.slice(1)
|
|
527
|
+
const items = value.slice(1, -1).split(',')
|
|
528
|
+
value = `${neg}>=${items[0]}+${key}:${neg}<=${items[1]}`
|
|
529
|
+
}
|
|
530
|
+
return `${key}:${value}`
|
|
531
|
+
}).join('+')
|
|
532
|
+
return nql(sanitized).parse()
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
parseQuery = (query, silent = true) => {
|
|
536
|
+
const { isPlainObject, trim } = this.app.lib._
|
|
537
|
+
let result = {}
|
|
538
|
+
if (isPlainObject(query)) result = query
|
|
539
|
+
else {
|
|
540
|
+
query = trim(query)
|
|
541
|
+
try {
|
|
542
|
+
if (query.startsWith('{')) result = JSON.parse(query)
|
|
543
|
+
else result = this.parseNql(query)
|
|
544
|
+
} catch (err) {
|
|
545
|
+
if (silent) return {}
|
|
546
|
+
throw err
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
try {
|
|
550
|
+
const text = JSON.stringify(result)
|
|
551
|
+
if (text.includes('["__REGEXP__",')) result = this.reviveRegexInJson(text)
|
|
552
|
+
} catch (err) {}
|
|
553
|
+
return result
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
replaceRegexInJson = (input = {}, returnString = true) => {
|
|
557
|
+
const { isString } = this.app.lib._
|
|
558
|
+
if (isString(input)) input = JSON.parse(input) ?? {}
|
|
559
|
+
const result = JSON.stringify(input, (key, value) => {
|
|
560
|
+
if (value instanceof RegExp) return ['__REGEXP__', value.source, value.flags]
|
|
561
|
+
return value
|
|
562
|
+
})
|
|
563
|
+
if (returnString) return result
|
|
564
|
+
return JSON.parse(result)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
reviveRegexInJson = (input, returnObject = true) => {
|
|
568
|
+
const { isPlainObject } = this.app.lib._
|
|
569
|
+
if (isPlainObject(input)) input = JSON.stringify(input)
|
|
570
|
+
const result = JSON.parse(input, (key, value) => {
|
|
571
|
+
if (Array.isArray(value) && value[0] === '__REGEXP__') return { $regex: new RegExp(value[1], value[2]) }
|
|
572
|
+
return value
|
|
573
|
+
})
|
|
574
|
+
return returnObject ? result : JSON.stringify(result)
|
|
575
|
+
}
|
|
521
576
|
}
|
|
522
577
|
|
|
523
578
|
return Dobo
|
package/lib/collect-models.js
CHANGED
|
@@ -10,7 +10,7 @@ import actionFactory from './factory/action.js'
|
|
|
10
10
|
* @param {Array} [indexes] - Container array to fill up found index
|
|
11
11
|
*/
|
|
12
12
|
async function sanitizeProp (model, prop, indexes) {
|
|
13
|
-
const { isEmpty, isString, keys, pick, isArray, isPlainObject
|
|
13
|
+
const { isEmpty, isString, keys, pick, isArray, isPlainObject } = this.app.lib._
|
|
14
14
|
const allPropKeys = this.getAllPropertyKeys(model.connection.driver)
|
|
15
15
|
const propType = this.constructor.propertyType
|
|
16
16
|
if (isString(prop)) {
|
|
@@ -25,7 +25,7 @@ async function sanitizeProp (model, prop, indexes) {
|
|
|
25
25
|
if (isArray(prop.values)) {
|
|
26
26
|
prop.values = prop.values.map(item => {
|
|
27
27
|
if (isPlainObject(item)) return pick(item, ['value', 'text'])
|
|
28
|
-
return { value: item, text:
|
|
28
|
+
return { value: item, text: item }
|
|
29
29
|
})
|
|
30
30
|
} else if (!isString(prop.values)) delete prop.values
|
|
31
31
|
if (prop.index) {
|
|
@@ -39,7 +39,7 @@ async function sanitizeProp (model, prop, indexes) {
|
|
|
39
39
|
else {
|
|
40
40
|
const feature = this.getFeature(prop.type)
|
|
41
41
|
if (!feature) this.fatal('unknownPropType%s%s', prop.type, model.name)
|
|
42
|
-
await applyFeature.call(this, model, feature, {
|
|
42
|
+
await applyFeature.call(this, model, feature, { field: prop.name }, indexes)
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -74,12 +74,13 @@ async function findAllProps (model, inputs = [], indexes = [], isExtender) {
|
|
|
74
74
|
*/
|
|
75
75
|
async function applyFeature (model, feature, options, indexes, isExtender) {
|
|
76
76
|
const { isArray, findIndex } = this.app.lib._
|
|
77
|
-
if (feature.name === 'dobo:unique' && options.
|
|
77
|
+
if (feature.name === 'dobo:unique' && options.field === 'id') {
|
|
78
78
|
const idx = findIndex(model.properties, { name: 'id' })
|
|
79
79
|
if (idx > -1) model.properties.pullAt(idx)
|
|
80
80
|
}
|
|
81
81
|
const item = await feature.handler(options)
|
|
82
82
|
if (item.rules) model.rules.push(...item.rules)
|
|
83
|
+
if (item.scanables) model.scanables.push(...item.scanables)
|
|
83
84
|
if (!isArray(item.properties)) item.properties = [item.properties]
|
|
84
85
|
for (const prop of item.properties) {
|
|
85
86
|
prop.feature = `${feature.plugin.ns}:${feature.name}`
|
|
@@ -137,24 +138,29 @@ async function findAllIndexes (model, inputs = [], indexes = []) {
|
|
|
137
138
|
export async function sanitizeRef (model, models) {
|
|
138
139
|
const { find, isString, pullAt } = this.app.lib._
|
|
139
140
|
if (!models) models = this.models
|
|
141
|
+
const _refKeys = []
|
|
140
142
|
for (const prop of model.properties) {
|
|
141
143
|
const ignored = []
|
|
142
144
|
for (const key in prop.ref ?? {}) {
|
|
145
|
+
_refKeys.push(key)
|
|
143
146
|
let ref = prop.ref[key]
|
|
144
147
|
if (isString(ref)) {
|
|
145
|
-
ref = {
|
|
148
|
+
ref = { field: ref }
|
|
146
149
|
}
|
|
150
|
+
ref.field = ref.field ?? 'id'
|
|
147
151
|
ref.type = ref.type ?? '1:1'
|
|
152
|
+
ref.searchField = ref.searchField ?? model.scanables[0] ?? ref.field
|
|
153
|
+
ref.labelField = ref.labelField ?? ref.searchField
|
|
148
154
|
const rModel = find(models, { name: ref.model })
|
|
149
155
|
if (!rModel) {
|
|
150
156
|
ignored.push(key)
|
|
151
157
|
this.log.warn('notFound%s%s', this.t('model'), ref.model)
|
|
152
158
|
continue
|
|
153
159
|
}
|
|
154
|
-
const rProp = find(rModel.properties, { name: ref.
|
|
160
|
+
const rProp = find(rModel.properties, { name: ref.field })
|
|
155
161
|
if (!rProp) {
|
|
156
162
|
ignored.push(key)
|
|
157
|
-
this.log.warn('notFound%s%s', this.t('property'), `${ref.
|
|
163
|
+
this.log.warn('notFound%s%s', this.t('property'), `${ref.field}@${ref.model}`)
|
|
158
164
|
continue
|
|
159
165
|
}
|
|
160
166
|
ref.fields = ref.fields ?? '*'
|
|
@@ -172,6 +178,8 @@ export async function sanitizeRef (model, models) {
|
|
|
172
178
|
delete prop.ref[key]
|
|
173
179
|
}
|
|
174
180
|
}
|
|
181
|
+
const dupes = _refKeys.filter((item, index) => _refKeys.indexOf(item) !== index)
|
|
182
|
+
if (dupes.length > 0) throw this.error('duplicateRefKeys%s%s', model.name, dupes.join(', '))
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
/**
|
|
@@ -333,7 +341,7 @@ async function collectModels () {
|
|
|
333
341
|
me.models.push(model)
|
|
334
342
|
}
|
|
335
343
|
for (const model of me.models) {
|
|
336
|
-
await sanitizeRef.call(this, model, me.models
|
|
344
|
+
await sanitizeRef.call(this, model, me.models)
|
|
337
345
|
me.log.trace('- %s', model.name)
|
|
338
346
|
}
|
|
339
347
|
this.log.debug('collected%s%d', this.t('model'), this.models.length)
|
package/lib/factory/action.js
CHANGED
|
@@ -13,9 +13,9 @@ const methods = {
|
|
|
13
13
|
createHistogram: ['params'],
|
|
14
14
|
createAttachment: ['id'],
|
|
15
15
|
findAttachment: ['id'],
|
|
16
|
-
getAttachment: ['id', '
|
|
16
|
+
getAttachment: ['id', 'field', 'file'],
|
|
17
17
|
listAttachment: ['params'],
|
|
18
|
-
removeAttachment: ['id', '
|
|
18
|
+
removeAttachment: ['id', 'field', 'file'],
|
|
19
19
|
updateAttachment: ['id']
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -123,8 +123,8 @@ async function actionFactory () {
|
|
|
123
123
|
return this
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
this.
|
|
126
|
+
field = value => {
|
|
127
|
+
this._field = value
|
|
128
128
|
return this
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -147,8 +147,8 @@ async function actionFactory () {
|
|
|
147
147
|
return await this.model[this.name](...args)
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
-
dispose () {
|
|
151
|
-
super.dispose()
|
|
150
|
+
dispose = async () => {
|
|
151
|
+
await super.dispose()
|
|
152
152
|
this.model = null
|
|
153
153
|
this._options = null
|
|
154
154
|
}
|
package/lib/factory/feature.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import path from 'path'
|
|
2
|
-
import nql from '@tryghost/nql'
|
|
3
2
|
|
|
4
3
|
export const omittedOptionsKeys = ['req', 'reply', 'trx']
|
|
5
4
|
|
|
@@ -115,12 +114,12 @@ export async function mergeAttachmentInfo (rec, source, options = {}) {
|
|
|
115
114
|
}
|
|
116
115
|
}
|
|
117
116
|
|
|
118
|
-
export async function getAttachmentPath (id,
|
|
117
|
+
export async function getAttachmentPath (id, field, file, options = {}) {
|
|
119
118
|
const { getPluginDataDir } = this.app.bajo
|
|
120
119
|
const { fs } = this.app.lib
|
|
121
120
|
const dir = `${getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
|
|
122
121
|
if (options.dirOnly) return dir
|
|
123
|
-
const path =
|
|
122
|
+
const path = field ? `${dir}/${field}/${file}` : `${dir}/${file}`
|
|
124
123
|
if (!fs.existsSync(path)) throw this.app.dobo.error('notFound')
|
|
125
124
|
return path
|
|
126
125
|
}
|
|
@@ -134,11 +133,11 @@ export async function copyAttachment (id, options = {}) {
|
|
|
134
133
|
const result = []
|
|
135
134
|
if (files.length === 0) return result
|
|
136
135
|
for (const f of files) {
|
|
137
|
-
let [
|
|
136
|
+
let [field, ...parts] = path.basename(f).split('@')
|
|
138
137
|
if (parts.length === 0) continue
|
|
139
|
-
|
|
138
|
+
field = setField ?? field
|
|
140
139
|
const file = setFile ?? parts.join('@')
|
|
141
|
-
const opts = { source: f,
|
|
140
|
+
const opts = { source: f, field, file, mimeType, stats, req }
|
|
142
141
|
const rec = await this.createAttachment(id, opts)
|
|
143
142
|
if (!rec) continue
|
|
144
143
|
delete rec.dir
|
|
@@ -162,8 +161,20 @@ export async function handleAttachmentUpload (id, trigger, options = {}) {
|
|
|
162
161
|
return copyAttachment.call(this, id, { req, mimeType, stats, setFile, setField })
|
|
163
162
|
}
|
|
164
163
|
|
|
164
|
+
async function _getRef ({ ref, rModel, prop, key, options, filter } = {}) {
|
|
165
|
+
if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) return
|
|
166
|
+
if (ref.fields.length === 0) return
|
|
167
|
+
const { formatValue, retainOriginalValue } = options
|
|
168
|
+
const fields = [...ref.fields]
|
|
169
|
+
if (!fields.includes(prop.name)) fields.push(prop.name)
|
|
170
|
+
const rOptions = { dataOnly: true, refs: [], formatValue, retainOriginalValue, fields }
|
|
171
|
+
const results = await rModel.findRecord(filter, rOptions)
|
|
172
|
+
return { rOptions, results }
|
|
173
|
+
}
|
|
174
|
+
|
|
165
175
|
export async function getSingleRef (record = {}, options = {}) {
|
|
166
176
|
const { isSet } = this.app.lib.aneka
|
|
177
|
+
const { parseQuery } = this.app.dobo
|
|
167
178
|
const { get } = this.app.lib._
|
|
168
179
|
const props = this.properties.filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
|
|
169
180
|
const refs = {}
|
|
@@ -172,21 +183,21 @@ export async function getSingleRef (record = {}, options = {}) {
|
|
|
172
183
|
for (const prop of props) {
|
|
173
184
|
for (const key in prop.ref) {
|
|
174
185
|
try {
|
|
175
|
-
if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) continue
|
|
176
|
-
const ref = prop.ref[key]
|
|
177
|
-
if (ref.fields.length === 0) continue
|
|
178
186
|
if (get(record, `_ref.${key}`)) continue
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
+
const ref = prop.ref[key]
|
|
188
|
+
const rModel = this.app.dobo.getModel(ref.model, true)
|
|
189
|
+
if (!rModel) return
|
|
190
|
+
let query = {}
|
|
191
|
+
query[ref.field] = record[prop.name]
|
|
192
|
+
if (ref.field === 'id') query[ref.field] = this.sanitizeId(query[ref.field])
|
|
193
|
+
if (ref.query) query = { $and: [query, parseQuery(ref.query)] }
|
|
194
|
+
const filter = { query }
|
|
195
|
+
const resp = await _getRef.call(this, { ref, rModel, prop, key, options, filter })
|
|
196
|
+
if (!resp) continue
|
|
197
|
+
const { rOptions, results } = resp
|
|
187
198
|
const data = []
|
|
188
199
|
for (const res of results) {
|
|
189
|
-
data.push(await rModel.sanitizeRecord(res,
|
|
200
|
+
data.push(await rModel.sanitizeRecord(res, rOptions))
|
|
190
201
|
}
|
|
191
202
|
refs[key] = ['1:1'].includes(ref.type) ? data[0] : data
|
|
192
203
|
} catch (err) {}
|
|
@@ -199,35 +210,34 @@ export async function getSingleRef (record = {}, options = {}) {
|
|
|
199
210
|
export async function getMultiRefs (records = [], options = {}) {
|
|
200
211
|
const { isSet } = this.app.lib.aneka
|
|
201
212
|
const { uniq, without, get } = this.app.lib._
|
|
213
|
+
const { parseQuery } = this.app.dobo
|
|
202
214
|
const props = this.properties.filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
|
|
203
215
|
options.refs = options.refs ?? []
|
|
204
216
|
if (props.length > 0) {
|
|
205
217
|
for (const prop of props) {
|
|
206
218
|
for (const key in prop.ref) {
|
|
207
219
|
try {
|
|
208
|
-
if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) continue
|
|
209
|
-
const ref = prop.ref[key]
|
|
210
|
-
if (ref.fields.length === 0) continue
|
|
211
|
-
if (ref.type !== '1:1') continue
|
|
212
220
|
if (get(records, `0._ref.${key}`)) continue
|
|
213
|
-
const
|
|
221
|
+
const ref = prop.ref[key]
|
|
222
|
+
const rModel = this.app.dobo.getModel(ref.model, true)
|
|
223
|
+
if (!rModel) return
|
|
214
224
|
let matches = []
|
|
215
225
|
for (const r of records) {
|
|
216
226
|
matches.push(rModel.sanitizeId(r[prop.name]))
|
|
217
227
|
}
|
|
218
228
|
matches = uniq(without(matches, undefined, null, NaN))
|
|
219
|
-
|
|
220
|
-
query[ref.
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
let query = {}
|
|
230
|
+
query[ref.field] = { $in: matches }
|
|
231
|
+
if (ref.query) query = { $and: [query, parseQuery(ref.query)] }
|
|
232
|
+
const filter = { query, limit: matches.length }
|
|
233
|
+
const resp = await _getRef.call(this, { ref, rModel, prop, key, options, filter })
|
|
234
|
+
if (!resp) continue
|
|
235
|
+
const { rOptions, results } = resp
|
|
226
236
|
for (const i in records) {
|
|
227
237
|
records[i]._ref = records[i]._ref ?? {}
|
|
228
238
|
const rec = records[i]
|
|
229
|
-
const res = results.find(res => (res[ref.
|
|
230
|
-
if (res) records[i]._ref[key] = await rModel.sanitizeRecord(res,
|
|
239
|
+
const res = results.find(res => (res[ref.field] + '') === rec[prop.name] + '')
|
|
240
|
+
if (res) records[i]._ref[key] = await rModel.sanitizeRecord(res, rOptions)
|
|
231
241
|
else records[i]._ref[key] = {}
|
|
232
242
|
}
|
|
233
243
|
} catch (err) {}
|
|
@@ -236,31 +246,16 @@ export async function getMultiRefs (records = [], options = {}) {
|
|
|
236
246
|
}
|
|
237
247
|
}
|
|
238
248
|
|
|
239
|
-
export function parseNql (text) {
|
|
240
|
-
const sanitized = text.split('+').map(item => {
|
|
241
|
-
const [key, ...rest] = item.split(':').map(i => i.trim())
|
|
242
|
-
let value = rest.join(':')
|
|
243
|
-
const neg = value[1] === '-' ? '-' : ''
|
|
244
|
-
if ((value[0] === '{' || value[1] === '{') && value[value.length - 1] === '}') {
|
|
245
|
-
if (value[0] === '-') value = value.slice(1)
|
|
246
|
-
const items = value.slice(1, -1).split(',')
|
|
247
|
-
value = `${neg}>=${items[0]}+${key}:${neg}<=${items[1]}`
|
|
248
|
-
}
|
|
249
|
-
return `${key}:${value}`
|
|
250
|
-
}).join('+')
|
|
251
|
-
|
|
252
|
-
return nql(sanitized).parse()
|
|
253
|
-
}
|
|
254
|
-
|
|
255
249
|
export function buildFilterQuery (filter = {}) {
|
|
256
250
|
const { trim, find, isString, isPlainObject } = this.app.lib._
|
|
251
|
+
const { parseNql } = this.app.dobo
|
|
257
252
|
let query = filter.query ?? {}
|
|
258
253
|
let q = {}
|
|
259
254
|
if (isString(query)) {
|
|
260
255
|
try {
|
|
261
256
|
query = trim(query)
|
|
262
257
|
if (query.startsWith('{')) q = JSON.parse(query) // JSON formatted query
|
|
263
|
-
else if (query.includes(':')) q = parseNql
|
|
258
|
+
else if (query.includes(':')) q = parseNql(query) // NQL
|
|
264
259
|
else {
|
|
265
260
|
let scanables = [...this.scanables]
|
|
266
261
|
if (scanables.length === 0) scanables = [...this.sortables]
|
|
@@ -273,8 +268,8 @@ export function buildFilterQuery (filter = {}) {
|
|
|
273
268
|
if (query[query.length - 1] === '*') return `${f}:~^'${query.replaceAll('*', '')}'`
|
|
274
269
|
return `${f}:~'${query.replaceAll('*', '')}'`
|
|
275
270
|
})
|
|
276
|
-
if (parts.length === 1) q =
|
|
277
|
-
else if (parts.length > 1) q =
|
|
271
|
+
if (parts.length === 1) q = parseNql(parts[0])
|
|
272
|
+
else if (parts.length > 1) q = parseNql(parts.join(','))
|
|
278
273
|
}
|
|
279
274
|
} catch (err) {
|
|
280
275
|
this.plugin.error('invalidQuery', { orgMessage: err.message })
|
|
@@ -366,16 +361,10 @@ export function buildFilterSearch (filter = {}) {
|
|
|
366
361
|
}
|
|
367
362
|
|
|
368
363
|
export function replaceIdInQuerySearch (filter) {
|
|
369
|
-
|
|
370
|
-
const query =
|
|
371
|
-
if (value instanceof RegExp) return ['__REGEXP__', value.source, value.flags]
|
|
372
|
-
return value
|
|
373
|
-
}).replaceAll('"id"', `"${this.driver.idField.name}"`)
|
|
364
|
+
const { replaceRegexInJson, reviveRegexInJson } = this.app.dobo
|
|
365
|
+
const query = replaceRegexInJson(filter.query).replaceAll('"id"', `"${this.driver.idField.name}"`)
|
|
374
366
|
try {
|
|
375
|
-
filter.query =
|
|
376
|
-
if (Array.isArray(value) && value[0] === '__REGEXP__') return new RegExp(value[1], value[2])
|
|
377
|
-
return value
|
|
378
|
-
})
|
|
367
|
+
filter.query = reviveRegexInJson(query)
|
|
379
368
|
} catch (err) {}
|
|
380
369
|
// search
|
|
381
370
|
const search = JSON.stringify(filter.search ?? {}).replaceAll('"id"', `"${this.driver.idField.name}"`)
|
|
@@ -7,17 +7,17 @@ async function createAttachment (...args) {
|
|
|
7
7
|
const [id, opts = {}] = args
|
|
8
8
|
const { fs } = this.app.lib
|
|
9
9
|
const { isEmpty } = this.app.lib._
|
|
10
|
-
const { source,
|
|
10
|
+
const { source, field = 'file', file, fullPath, stats, mimeType, req } = opts
|
|
11
11
|
if (isEmpty(file)) return
|
|
12
12
|
if (!source) throw this.plugin.error('isMissing%s', this.plugin.t('field.source'))
|
|
13
|
-
const baseDir = await getAttachmentPath.call(this, id,
|
|
14
|
-
let dir = `${baseDir}/${
|
|
15
|
-
if ((
|
|
13
|
+
const baseDir = await getAttachmentPath.call(this, id, field, file, { dirOnly: true })
|
|
14
|
+
let dir = `${baseDir}/${field}`
|
|
15
|
+
if ((field || '').endsWith('[]')) dir = `${baseDir}/${field.replace('[]', '')}`
|
|
16
16
|
const dest = `${dir}/${file}`.replaceAll('//', '/')
|
|
17
17
|
await fs.ensureDir(dir)
|
|
18
18
|
await fs.copy(source, dest)
|
|
19
19
|
const rec = {
|
|
20
|
-
field:
|
|
20
|
+
field: field === '' ? undefined : field,
|
|
21
21
|
dir,
|
|
22
22
|
file
|
|
23
23
|
}
|
|
@@ -14,12 +14,12 @@ async function findAttachment (...args) {
|
|
|
14
14
|
const recs = []
|
|
15
15
|
for (const f of files) {
|
|
16
16
|
const item = f.replace(dir, '')
|
|
17
|
-
let [,
|
|
17
|
+
let [, field, file] = item.split('/')
|
|
18
18
|
if (!file) {
|
|
19
|
-
file =
|
|
20
|
-
|
|
19
|
+
file = field
|
|
20
|
+
field = null
|
|
21
21
|
}
|
|
22
|
-
const rec = {
|
|
22
|
+
const rec = { field, file }
|
|
23
23
|
await mergeAttachmentInfo.call(this, rec, f, { mimeType, fullPath, stats })
|
|
24
24
|
recs.push(rec)
|
|
25
25
|
}
|
|
@@ -3,11 +3,11 @@ const action = 'getAttachment'
|
|
|
3
3
|
async function getAttachment (...args) {
|
|
4
4
|
if (!this.attachment) return
|
|
5
5
|
if (args.length === 0) return this.action(action, ...args)
|
|
6
|
-
let [id,
|
|
6
|
+
let [id, field, file, opts = {}] = args
|
|
7
7
|
const { find } = this.app.lib._
|
|
8
8
|
const all = await this.findAttachment(id, opts)
|
|
9
|
-
if (
|
|
10
|
-
const data = find(all, {
|
|
9
|
+
if (field === 'null') field = null
|
|
10
|
+
const data = find(all, { field, file })
|
|
11
11
|
if (!data) throw this.error('notFound', { statusCode: 404 })
|
|
12
12
|
return data
|
|
13
13
|
}
|
|
@@ -10,10 +10,10 @@ async function listAttachment (...args) {
|
|
|
10
10
|
const mime = await importPkg('waibu:mime')
|
|
11
11
|
const { fastGlob } = this.app.lib
|
|
12
12
|
|
|
13
|
-
const { id = '*',
|
|
13
|
+
const { id = '*', field = '*', file = '*', type } = params
|
|
14
14
|
const { uriEncoded = true } = opts
|
|
15
15
|
const root = `${getPluginDataDir('dobo')}/attachment`
|
|
16
|
-
let pattern = `${root}/${this.name}/${id}/${
|
|
16
|
+
let pattern = `${root}/${this.name}/${id}/${field}/${file}`
|
|
17
17
|
if (type === 'image') pattern += '.{jpg,jpeg,gif,png,webp,avif,svg}'
|
|
18
18
|
else if (type === 'video') pattern += '.{mp4,m4v,webm,mov,qt,mkv,ogg,ogv}'
|
|
19
19
|
else if (type) pattern += `.${type}`
|
|
@@ -26,12 +26,12 @@ async function listAttachment (...args) {
|
|
|
26
26
|
fileName: path.basename(fullPath),
|
|
27
27
|
fullPath,
|
|
28
28
|
mimeType,
|
|
29
|
-
params: { model: this.name, id,
|
|
29
|
+
params: { model: this.name, id, field, file }
|
|
30
30
|
}
|
|
31
31
|
if (this.app.waibuMpa) {
|
|
32
32
|
const { routePath } = this.app.waibu
|
|
33
|
-
const [, _model, _id,
|
|
34
|
-
row.url = routePath(`dobo:/attachment/${kebabCase(_model)}/${_id}/${
|
|
33
|
+
const [, _model, _id, _field, _file] = fullPath.split('/')
|
|
34
|
+
row.url = routePath(`dobo:/attachment/${kebabCase(_model)}/${_id}/${_field}/${_file}`)
|
|
35
35
|
}
|
|
36
36
|
return row
|
|
37
37
|
})
|
|
@@ -4,9 +4,9 @@ const action = 'removeAttachment'
|
|
|
4
4
|
async function removeAttachment (...args) {
|
|
5
5
|
if (!this.attachment) return
|
|
6
6
|
if (args.length === 0) return this.action(action, ...args)
|
|
7
|
-
const [id,
|
|
7
|
+
const [id, field, file, opts = {}] = args
|
|
8
8
|
const { fs } = this.app.lib
|
|
9
|
-
const path = await getAttachmentPath.call(this, id,
|
|
9
|
+
const path = await getAttachmentPath.call(this, id, field, file)
|
|
10
10
|
const { req } = opts
|
|
11
11
|
await fs.remove(path)
|
|
12
12
|
if (!opts.noFlash && req && req.flash) req.flash('notify', req.t('attachmentRemoved'))
|
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
async function sanitizeRecord (record = {}, opts = {}) {
|
|
13
13
|
const { fields = [], hidden = [], forceNoHidden } = opts
|
|
14
|
-
const { isEmpty, map, without, isArray } = this.app.lib._
|
|
14
|
+
const { isEmpty, map, without, isArray, isFunction, isString, get, cloneDeep } = this.app.lib._
|
|
15
15
|
const { fillObject } = this.app.lib.aneka
|
|
16
|
+
const { callHandler, format } = this.app.bajo
|
|
16
17
|
let allHidden = without([...this.hidden, ...hidden], 'id')
|
|
17
18
|
if (forceNoHidden === true) allHidden = []
|
|
18
19
|
else if (isArray(forceNoHidden)) allHidden = without(allHidden, ...forceNoHidden)
|
|
@@ -22,6 +23,24 @@ async function sanitizeRecord (record = {}, opts = {}) {
|
|
|
22
23
|
newFields = without(newFields, ...allHidden)
|
|
23
24
|
const body = fillObject(record, newFields, null)
|
|
24
25
|
const newRecord = await this.sanitizeBody({ body, noDefault: true })
|
|
26
|
+
if (opts.formatValue) {
|
|
27
|
+
if (opts.retainOriginalValue) newRecord._orig = cloneDeep(newRecord)
|
|
28
|
+
for (const key in newRecord) {
|
|
29
|
+
const prop = this.getProperty(key)
|
|
30
|
+
if (!prop) continue
|
|
31
|
+
if (prop.format) {
|
|
32
|
+
if (isFunction(prop.format)) newRecord[key] = await prop.format.call(this, newRecord[key], newRecord, opts)
|
|
33
|
+
else if (isString(prop.format)) newRecord[key] = await callHandler(this.plugin, this, newRecord[key], newRecord, opts)
|
|
34
|
+
} else {
|
|
35
|
+
const options = {
|
|
36
|
+
lang: get(opts, 'req.lang'),
|
|
37
|
+
latitude: ['lat', 'latitude'].includes(key),
|
|
38
|
+
longitude: ['lon', 'lng', 'longitude'].includes(key)
|
|
39
|
+
}
|
|
40
|
+
newRecord[key] = format(newRecord[key], prop.type, options)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
25
44
|
if (record._ref) newRecord._ref = record._ref
|
|
26
45
|
return newRecord
|
|
27
46
|
}
|
package/lib/factory/model.js
CHANGED
|
@@ -160,8 +160,8 @@ async function modelFactory () {
|
|
|
160
160
|
getField = (name) => this.getProperty(name)
|
|
161
161
|
hasField = (name) => this.hasProperty(name)
|
|
162
162
|
|
|
163
|
-
dispose () {
|
|
164
|
-
super.dispose()
|
|
163
|
+
dispose = async () => {
|
|
164
|
+
await super.dispose()
|
|
165
165
|
this.connection = null
|
|
166
166
|
this.driver = null
|
|
167
167
|
}
|
package/package.json
CHANGED
package/wiki/CHANGES.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 2026-04-11
|
|
4
|
+
|
|
5
|
+
- [2.16.0] Add ```format``` as new model's property key
|
|
6
|
+
- [2.16.0] Rewrite ```getDefaultValues()``` to base on ```req.getSetting()```
|
|
7
|
+
- [2.16.0] All inter site admins are now exempts from ```immutable``` row
|
|
8
|
+
- [2.16.0] Bug fix in ```collect-models.js```
|
|
9
|
+
- [2.16.0] Bug fix in ```getSingleRef()``` and ```getMultiRefs()```
|
|
10
|
+
- [2.16.0] Add feature to return formatted row(s) with ```options.formatValue```
|
|
11
|
+
- [2.16.0] If row is formatted, add feature to save original row in ```_orig``` with ```options.retainOriginalValue```
|
|
12
|
+
|
|
13
|
+
## 2026-04-07
|
|
14
|
+
|
|
15
|
+
- [2.15.0] Add ```parseNql()```
|
|
16
|
+
- [2.15.0] Add ```parseQuery()```
|
|
17
|
+
- [2.15.0] Add ```replaceRegexInJson()```
|
|
18
|
+
- [2.15.0] Add ```reviveRegexInJson()```
|
|
19
|
+
- [2.15.0] Change all ```opts.fieldName``` to ```opts.field``` in features
|
|
20
|
+
- [2.15.0] Add ```ref.searchField``` in model reference
|
|
21
|
+
- [2.15.0] Add ```ref.labelField``` in model reference
|
|
22
|
+
- [2.15.0] Add ```ref.valueField``` in model reference
|
|
23
|
+
- [2.15.0] change ```ref.propName``` to ```ref.field``` in model reference
|
|
24
|
+
|
|
25
|
+
## 2026-04-02
|
|
26
|
+
|
|
27
|
+
- [2.14.1] Bug fix in ```hardCap```
|
|
28
|
+
- [2.14.1] ```warnings``` now can be turned off through ```config``` or site settings
|
|
29
|
+
|
|
3
30
|
## 2026-04-01
|
|
4
31
|
|
|
5
32
|
- [2.14.0] Add ```between``` as custom query, since it doesn't exists in NQL
|