dobo 2.21.0 → 2.22.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.
@@ -150,7 +150,7 @@
150
150
  "maxPageError%s%s": "Page number (%s) above the allowed threshold (%s)",
151
151
  "duplicateRefKeys%s%s": "Duplicate reference keys found in '%s' (%s)",
152
152
  "sanitizeBodyError": "Error sanitizing body",
153
- "virtualFieldIn%s%s": "Virtual field can't be used in '%s' on %s",
153
+ "virtualFieldIn%s%s%s": "Virtual field '%s' can't be used in '%s' on %s",
154
154
  "field": {
155
155
  "id": "ID",
156
156
  "code": "Kode",
@@ -215,7 +215,9 @@
215
215
  "type": "Type",
216
216
  "model": "Model",
217
217
  "age": "Age",
218
- "provider": "Provider"
218
+ "provider": "Provider",
219
+ "image": "Image",
220
+ "attachment": "Attachment"
219
221
  },
220
222
  "validationError": "Validation Error",
221
223
  "validation": {
@@ -148,7 +148,7 @@
148
148
  "maxPageError%s%s": "Nomor halaman (%s) melampaui batas yang diijinkan (%s)",
149
149
  "duplicateRefKeys%s%s": "Ditemukan kunci referensi duplikat di '%s' (%s)",
150
150
  "sanitizeBodyError": "Kesalahan saat sanitasi body",
151
- "virtualFieldIn%s%s": "Kolom virtual tidak bisa digunakan di '%s' pada %s",
151
+ "virtualFieldIn%s%s%s": "Kolom virtual '%s' tidak bisa digunakan di '%s' pada %s",
152
152
  "field": {
153
153
  "id": "ID",
154
154
  "code": "Kode",
@@ -213,7 +213,9 @@
213
213
  "type": "Tipe",
214
214
  "model": "Model",
215
215
  "age": "Usia",
216
- "provider": "Penyedia"
216
+ "provider": "Penyedia",
217
+ "image": "Gambar",
218
+ "attachment": "Lampiran"
217
219
  },
218
220
  "validationError": "Kesalahan Validasi",
219
221
  "validation": {
@@ -0,0 +1,25 @@
1
+ import path from 'path'
2
+
3
+ async function image (opts = {}) {
4
+ opts.field = opts.field ?? 'image'
5
+ opts.baseName = opts.baseName ?? true
6
+ opts.single = opts.single ?? true
7
+ return {
8
+ properties: {
9
+ name: opts.field,
10
+ type: 'string',
11
+ virtual: true,
12
+ getValue: async function (val, rec) {
13
+ const atts = await this.listAttachment({ id: rec.id })
14
+ if (atts.length === 0) return
15
+ let items = atts.filter(att => att.mimeType.startsWith('image/'))
16
+ if (opts.withLink && this.app.waibu) items = items.map(f => `<a href="${f.url}">${opts.baseName ? path.basename(f.file) : f.file}</a>`)
17
+ else if (opts.baseName) items = items.map(f => path.basename(f.file))
18
+ if (opts.single) return items[0]
19
+ return items
20
+ }
21
+ }
22
+ }
23
+ }
24
+
25
+ export default image
@@ -1,9 +1,18 @@
1
1
  import path from 'path'
2
2
 
3
+ async function handleNotFound (req, reply) {
4
+ const { isEmpty, isString } = this.app.lib._
5
+ const { routePath } = this.app.waibu
6
+ if (!req.query.notfound) throw this.error('_notFound', { noContent: true })
7
+ const ext = path.extname(req.params.file)
8
+ const replacer = isString(req.query.notfound) ? req.query.notfound : `waibuStatic.asset:/not-found${isEmpty(ext) ? '.png' : ext}`
9
+ return reply.redirectTo(routePath(replacer))
10
+ }
11
+
3
12
  async function attachment (req, reply) {
4
- const { isString, isEmpty, find, last } = this.app.lib._
13
+ const { isEmpty, find, last } = this.app.lib._
5
14
  const { pascalCase } = this.app.lib.aneka
6
- const { routePath } = this.app.waibu
15
+ const { createThumbnail } = this.app.bajoExtra ?? {}
7
16
  const { fs } = this.app.lib
8
17
  const mdl = this.app.dobo.getModel(req.params.model)
9
18
  const type = req.query.type
@@ -20,11 +29,24 @@ async function attachment (req, reply) {
20
29
  file === decodeURI(req.params.file)
21
30
  })
22
31
  }
23
- if (!item) {
24
- if (!req.query.notfound) throw this.error('_notFound', { noContent: true })
25
- const ext = path.extname(req.params.file)
26
- const replacer = isString(req.query.notfound) ? req.query.notfound : `waibuStatic.asset:/not-found${isEmpty(ext) ? '.png' : ext}`
27
- return reply.redirectTo(routePath(replacer))
32
+ if (!item) return await handleNotFound.call(this, req, reply)
33
+ if (req.query.thumbnail) {
34
+ const dir = path.dirname(item.file)
35
+ const ext = path.extname(item.file)
36
+ const base = path.basename(item.file, ext)
37
+ const dest = `${dir}/_tn/${base}-${req.query.thumbnail}.png`
38
+ if (createThumbnail && !fs.existsSync(dest)) {
39
+ const opts = {
40
+ dir: `${dir}/_tn`,
41
+ size: req.query.thumbnail,
42
+ silent: true,
43
+ format: '.png'
44
+ }
45
+ await createThumbnail(item.file, opts)
46
+ }
47
+ if (!fs.existsSync(dest)) await handleNotFound.call(this, req, reply)
48
+ item.mimeType = 'image/png'
49
+ item.file = dest
28
50
  }
29
51
  if (!isEmpty(item.mimeType)) reply.header('Content-Type', item.mimeType)
30
52
  const stream = fs.createReadStream(item.file)
package/index.js CHANGED
@@ -158,7 +158,10 @@ async function factory (pkgName) {
158
158
  ttlDur: '10s'
159
159
  },
160
160
  hardCap: 10000, // max returned records
161
- warnings: true
161
+ warnings: true,
162
+ attachment: {
163
+ thumbSizes: ['s', 'm', 'l']
164
+ }
162
165
  },
163
166
  memDb: {
164
167
  autoSaveDur: '1s'
@@ -29,10 +29,11 @@ async function sanitizeProp (model, prop, indexes) {
29
29
  })
30
30
  } else if (!isString(prop.values)) delete prop.values
31
31
  if (prop.hidden) model.hidden.push(prop.name)
32
+ if (prop.scanable) model.scanables.push(prop.scanable)
32
33
  if (prop.virtual) {
33
34
  const keys = Object.keys(propType)
34
35
  if (!keys.includes(prop.type)) this.fatal('unknownPropType%s%s', `${prop.name}:${prop.type}`, model.name)
35
- for (const key of ['required', 'rules', 'index', 'validator', 'ref', 'rulesMsg', 'immutable', 'feature']) {
36
+ for (const key of ['required', 'rules', 'index', 'validator', 'ref', 'rulesMsg', 'immutable']) {
36
37
  delete prop[key]
37
38
  }
38
39
  model.properties.push(prop)
@@ -160,7 +161,7 @@ export async function sanitizeRef (model, models) {
160
161
  }
161
162
  ref.field = ref.field ?? 'id'
162
163
  ref.type = ref.type ?? '1:1'
163
- ref.searchField = ref.searchField ?? model.scanables[0] ?? ref.field
164
+ ref.searchField = ref.searchField ?? ref.field
164
165
  ref.labelField = ref.labelField ?? ref.searchField
165
166
  const rModel = find(models, { name: ref.model })
166
167
  if (!rModel) {
@@ -318,7 +319,7 @@ async function collectModels () {
318
319
 
319
320
  const base = path.basename(file, path.extname(file))
320
321
  const defName = pascalCase(`${this.alias} ${base}`)
321
- const item = await readConfig(file, { ns: this.ns, baseNs: me.ns })
322
+ const item = await readConfig(file, { ns: this.ns, baseNs: me.ns, merge: true })
322
323
  if (isEmpty(item)) return undefined
323
324
  if (!isPlainObject(item)) me.fatal('invalidModel%s', defName)
324
325
  item.name = item.name ?? defName
@@ -345,12 +346,12 @@ async function collectModels () {
345
346
  for (const item of model.indexes) {
346
347
  for (const field of item.fields) {
347
348
  const prop = model.properties.find(p => p.name === field)
348
- if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s', 'index', model.name)
349
+ if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s%s', field, 'index', model.name)
349
350
  }
350
351
  }
351
- for (const field of model.scanable) {
352
+ for (const field of model.scanables) {
352
353
  const prop = model.properties.find(p => p.name === field)
353
- if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s', 'scanable', model.name)
354
+ if (!prop || (prop && prop.virtual)) throw this.error('virtualFieldIn%s%s%s', field, 'scanable', model.name)
354
355
  }
355
356
  }
356
357
  this.log.debug('collected%s%d', this.t('model'), this.models.length)
@@ -272,7 +272,7 @@ async function driverFactory () {
272
272
  }
273
273
 
274
274
  async _updateRecord (model, id, input = {}, options = {}) {
275
- let body = omit(input, this.getVirtualFields())
275
+ let body = omit(input, this.getVirtualFields(model))
276
276
  if (!options.noUniqueCheck) {
277
277
  if (!this.support.uniqueIndex) await this._checkUnique(model, body, options)
278
278
  }
@@ -292,7 +292,7 @@ async function driverFactory () {
292
292
  }
293
293
 
294
294
  async _upsertRecord (model, input = {}, options = {}) {
295
- let body = omit(input, this.getVirtualFields())
295
+ let body = omit(input, this.getVirtualFields(model))
296
296
  if (!options.noUniqueCheck) {
297
297
  if (!this.uniqueIndexSupport) await this._checkUnique(model, body, options)
298
298
  }
@@ -232,7 +232,7 @@ export async function getMultiRefs (records = [], options = {}) {
232
232
  if (!rModel) return
233
233
  let matches = []
234
234
  for (const r of records) {
235
- matches.push(rModel.sanitizeId(r[prop.name]))
235
+ matches.push(prop.name === 'id' ? rModel.sanitizeId(r[prop.name]) : r[prop.name])
236
236
  }
237
237
  matches = uniq(without(matches, undefined, null, NaN)).map(i => i + '')
238
238
  let query = {}
@@ -438,7 +438,7 @@ export function preparePagination (filter = {}, options = {}) {
438
438
  }
439
439
 
440
440
  export async function clearCache (id) {
441
- const { clear } = this.app.bajoCache
441
+ const { clear } = this.app.bajoCache ?? {}
442
442
  if (!clear) return
443
443
  await clear({ key: `dobo|${this.name}|getRecord|${id}` })
444
444
  await clear({ key: `dobo|${this.name}|findRecord` })
@@ -2,6 +2,8 @@ import { mergeAttachmentInfo, getAttachmentPath } from './_util.js'
2
2
  const action = 'createAttachment'
3
3
 
4
4
  async function createAttachment (...args) {
5
+ const { createThumbnail } = this.app.bajoExtra
6
+ const { thumbSizes: size } = this.app.dobo.config.default.attachment
5
7
  if (!this.attachment) return
6
8
  if (args.length === 0) return this.action(action, ...args)
7
9
  const [id, opts = {}] = args
@@ -18,6 +20,9 @@ async function createAttachment (...args) {
18
20
  const dest = `${dir}/${file}`.replaceAll('//', '/')
19
21
  await fs.ensureDir(dir)
20
22
  await fs.copy(source, dest)
23
+ try {
24
+ if (createThumbnail) await createThumbnail(dest, { dir: `${dir}/_tn`, size, format: ['.png'] })
25
+ } catch (err) {}
21
26
  const rec = {
22
27
  field: field === '' ? undefined : field,
23
28
  dir,
@@ -20,13 +20,13 @@ async function createRecord (...args) {
20
20
  await execDynHook.call(this, 'beforeCreateRecord', input, options)
21
21
  if (!noValidation) await execValidation.call(this, input, options)
22
22
  let result = options.record ?? (await this.driver._createRecord(this, input, options)) ?? {}
23
+ await handleReq.call(this, result.data.id, 'created', options)
23
24
  if (noResult) return
24
25
  result = result ?? {}
25
26
  const { warnings } = getDefaultValues(options)
26
27
  if (!warnings) delete result.warnings
27
- if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
28
28
  if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
29
- await handleReq.call(this, result.data.id, 'created', options)
29
+ if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
30
30
  await execDynHook.call(this, 'afterCreateRecord', input, result, options)
31
31
  await execModelHook.call(this, 'afterCreateRecord', input, result, options)
32
32
  await execHook.call(this, 'afterCreateRecord', input, result, options)
@@ -37,12 +37,12 @@ async function native (...args) {
37
37
  result.pages = options.count ? Math.ceil(result.count / filter.limit) : undefined
38
38
  if (!warnings) delete result.warnings
39
39
 
40
+ if (isSet(options.refs)) await getMultiRefs.call(this, result.data, options)
40
41
  if (!noResultSanitizer) {
41
42
  for (const idx in result.data) {
42
43
  result.data[idx] = await this.sanitizeRecord(result.data[idx], options)
43
44
  }
44
45
  }
45
- if (isSet(options.refs)) await getMultiRefs.call(this, result.data, options)
46
46
  await execDynHook.call(this, 'afterFindRecord', filter, result, options)
47
47
  await execModelHook.call(this, 'afterFindRecord', filter, result, options)
48
48
  await execHook.call(this, 'afterFindRecord', filter, result, options)
@@ -101,13 +101,12 @@ async function findRecord (...args) {
101
101
  }
102
102
  result.pages = options.count ? Math.ceil(result.count / filter.limit) : undefined
103
103
  if (!warnings) delete result.warnings
104
-
104
+ if (isSet(options.refs)) await getMultiRefs.call(this, result.data, options)
105
105
  if (!noResultSanitizer) {
106
106
  for (const idx in result.data) {
107
107
  result.data[idx] = await this.sanitizeRecord(result.data[idx], options)
108
108
  }
109
109
  }
110
- if (isSet(options.refs)) await getMultiRefs.call(this, result.data, options)
111
110
  await execDynHook.call(this, 'afterFindRecord', filter, result, options)
112
111
  await execModelHook.call(this, 'afterFindRecord', filter, result, options)
113
112
  await execHook.call(this, 'afterFindRecord', filter, result, options)
@@ -68,8 +68,8 @@ async function getRecord (...args) {
68
68
  const { warnings } = getDefaultValues(options)
69
69
  if (!warnings) delete result.warnings
70
70
  if (isEmpty(result.data) && !options.throwNotFound) return dataOnly ? undefined : { data: undefined }
71
- if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
72
71
  if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
72
+ if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
73
73
  await execDynHook.call(this, 'afterGetRecord', id, result, options)
74
74
  await execModelHook.call(this, 'afterGetRecord', id, result, options)
75
75
  await execHook.call(this, 'afterGetRecord', id, result, options)
@@ -1,14 +1,23 @@
1
1
  import { getAttachmentPath } from './_util.js'
2
+ import path from 'path'
2
3
  const action = 'removeAttachment'
3
4
 
4
5
  async function removeAttachment (...args) {
5
6
  if (!this.attachment) return
6
7
  if (args.length === 0) return this.action(action, ...args)
7
8
  const [id, field, file, opts = {}] = args
8
- const { fs } = this.app.lib
9
- const path = await getAttachmentPath.call(this, id, field, file)
9
+ const { fs, fastGlob } = this.app.lib
10
+ const fullPath = await getAttachmentPath.call(this, id, field, file)
10
11
  const { req } = opts
11
- await fs.remove(path)
12
+ await fs.remove(fullPath)
13
+ const dir = path.dirname(fullPath)
14
+ const ext = path.extname(fullPath)
15
+ const base = path.basename(fullPath, ext)
16
+ const pattern = `${dir}/_tn/${base}-*.*`
17
+ const files = await fastGlob(pattern)
18
+ for (const f of files) {
19
+ await fs.remove(f)
20
+ }
12
21
  if (!opts.noFlash && req && req.flash) req.flash('notify', req.t('attachmentRemoved'))
13
22
  }
14
23
 
@@ -45,13 +45,13 @@ async function removeRecord (...args) {
45
45
  await execModelHook.call(this, 'beforeRemoveRecord', id, options)
46
46
  await execDynHook.call(this, 'beforeRemoveRecord', id, options)
47
47
  const result = options.record ?? (await this.driver._removeRecord(this, id, options)) ?? {}
48
+ await handleReq.call(this, result.oldData.id, 'removed', options)
48
49
  await clearCache.call(this, id)
49
50
  if (noResult) return
50
51
  const { warnings } = getDefaultValues(options)
51
52
  if (!warnings) delete result.warnings
52
53
  if (!noResultSanitizer) result.oldData = await this.sanitizeRecord(result.oldData, options)
53
54
  if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
54
- await handleReq.call(this, result.oldData.id, 'removed', options)
55
55
  await execDynHook.call(this, 'afterRemoveRecord', id, result, options)
56
56
  await execModelHook.call(this, 'afterRemoveRecord', id, result, options)
57
57
  await execHook.call(this, 'afterRemoveRecord', id, result, options)
@@ -25,13 +25,14 @@ async function sanitizeRecord (record = {}, opts = {}) {
25
25
  newFields = without(newFields, ...allHidden)
26
26
  const body = fillObject(record, newFields, null)
27
27
  const newRecord = await this.sanitizeBody({ body, noDefault: true })
28
+ if (record._ref) newRecord._ref = cloneDeep(record._ref)
28
29
  for (const key in newRecord) {
29
30
  const prop = this.getProperty(key)
31
+ if (!prop) continue
30
32
  const val = ['object', 'array'].includes(prop.type) ? cloneDeep(newRecord[key]) : newRecord[key]
31
33
  if (isFunction(prop.getValue)) newRecord[key] = await prop.getValue.call(this, val, newRecord, opts)
32
34
  else if (isString(prop.getValue)) newRecord[key] = await callHandler(this.plugin, this, val, newRecord, opts)
33
35
  }
34
-
35
36
  if (opts.fmt) {
36
37
  newRecord._fmt = cloneDeep(newRecord)
37
38
  for (const key in newRecord) {
@@ -55,7 +56,6 @@ async function sanitizeRecord (record = {}, opts = {}) {
55
56
  }
56
57
  }
57
58
  }
58
- if (record._ref) newRecord._ref = record._ref
59
59
  return newRecord
60
60
  }
61
61
 
@@ -67,16 +67,16 @@ async function updateRecord (...args) {
67
67
  await execDynHook.call(this, 'beforeUpdateRecord', id, input, options)
68
68
  if (!noValidation) await execValidation.call(this, input, options)
69
69
  const result = options.record ?? (await this.driver._updateRecord(this, id, input, options)) ?? {}
70
+ await handleReq.call(this, result.data.id, 'updated', options)
70
71
  await clearCache.call(this, id)
71
72
  if (noResult) return
72
73
  const { warnings } = getDefaultValues(options)
73
74
  if (!warnings) delete result.warnings
75
+ if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
74
76
  if (!noResultSanitizer) {
75
77
  result.data = await this.sanitizeRecord(result.data, options)
76
78
  result.oldData = await this.sanitizeRecord(result.oldData, options)
77
79
  }
78
- if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
79
- await handleReq.call(this, result.data.id, 'updated', options)
80
80
  await execDynHook.call(this, 'afterUpdateRecord', id, input, result, options)
81
81
  await execModelHook.call(this, 'afterUpdateRecord', id, input, result, options)
82
82
  await execHook.call(this, 'afterUpdateRecord', id, input, result, options)
@@ -16,13 +16,13 @@ async function native (body = {}, opts = {}) {
16
16
  await execModelHook.call(this, 'beforeUpsertRecord', input, options)
17
17
  await execDynHook.call(this, 'beforeUpsertRecord', input, options)
18
18
  const result = options.record ?? (await this.driver._upsertRecord(this, input, options)) ?? {}
19
+ await handleReq.call(this, result.data.id, 'upserted', options)
19
20
  await clearCache.call(this, body.id)
20
21
  if (noResult) return
21
22
  const { warnings } = getDefaultValues(options)
22
23
  if (!warnings) delete result.warnings
23
- if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
24
24
  if (isSet(options.refs)) await getSingleRef.call(this, result.data, options)
25
- await handleReq.call(this, result.data.id, 'upserted', options)
25
+ if (!noResultSanitizer) result.data = await this.sanitizeRecord(result.data, options)
26
26
  await execDynHook.call(this, 'afterUpsertRecord', input, result, options)
27
27
  await execModelHook.call(this, 'afterUpsertRecord', input, result, options)
28
28
  await execHook.call(this, 'afterUpsertRecord', input, result, options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dobo",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "DBMS for Bajo Framework",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/wiki/CHANGES.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-05-02
4
+
5
+ - [2.22.0] Add auto thumbnail creation when image attachment is uploaded
6
+ - [2.22.0] Add feature to get the thumbnail instead of attachment file in attachment route
7
+ - [2.22.0] Add ```dobo:image``` feature
8
+ - [2.22.0] Bug fix in ```model.createRecord()```
9
+ - [2.22.0] Bug fix in ```model.updateRecord()```
10
+ - [2.22.0] Bug fix in ```model.upsertRecord()```
11
+ - [2.22.0] Bug fix in ```model.removeRecord()```
12
+ - [2.22.0] Remove attachment now also remove corresponding thumbnails
13
+
14
+ ## 2026-04-28
15
+
16
+ - [2.21.1] Bug fix in ```collect-models.js```
17
+ - [2.21.1] Bug fix in ```driver._updateRecord()```
18
+ - [2.21.1] Bug fix in ```util.getMultiRef()```
19
+ - [2.21.1] Bug fix in setting references
20
+ - [2.21.1] Bug fix in ```model.sanitizeRecord()```
21
+
3
22
  ## 2026-04-25
4
23
 
5
24
  - [2.21.0] Change ```options.formatValue``` to ```options.fmt```