dobo-extra 1.0.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/LICENSE +21 -0
- package/README.md +23 -0
- package/bajo/config.json +20 -0
- package/bajo/init.js +25 -0
- package/bajo/method/export-to.js +105 -0
- package/bajo/method/import-from.js +67 -0
- package/bajo/start.js +14 -0
- package/bajoCli/applet/archive.js +95 -0
- package/bajoCli/applet/export-to.js +56 -0
- package/bajoCli/applet/import-from.js +51 -0
- package/bajoCli/applet.js +1 -0
- package/bajoI18N/resource/id.json +37 -0
- package/lib/io-exts.js +1 -0
- package/lib/ndjson-csv-xlsx.js +35 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Ardhi Lukianto
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# dobo-extra
|
|
2
|
+
|
|
3
|
+
Plugin name: **doboExtra**, alias: **dbxtra**
|
|
4
|
+
|
|
5
|
+
 
|
|
6
|
+
|
|
7
|
+
> <br />**Attention**: I do NOT accept any pull request at the moment, thanks!<br /><br />
|
|
8
|
+
|
|
9
|
+
Tools & utility for [Dobo](https://github.com/ardhi/dobo)
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Goto your ```<bajo-base-dir>``` and type:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
$ npm install dobo-extra
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Now open your ```<bajo-data-dir>/config/.plugins``` and put ```dobo-extra``` in it
|
|
20
|
+
|
|
21
|
+
## License
|
|
22
|
+
|
|
23
|
+
[MIT](LICENSE)
|
package/bajo/config.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"alias": "dbx",
|
|
3
|
+
"export": {
|
|
4
|
+
"maxBatch": 1000,
|
|
5
|
+
"stringify": {
|
|
6
|
+
"open": "[\n",
|
|
7
|
+
"sep": ",\n",
|
|
8
|
+
"close": "\n]\n"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"import": {
|
|
12
|
+
"maxBatch": 1000
|
|
13
|
+
},
|
|
14
|
+
"archive": {
|
|
15
|
+
"tasks": [],
|
|
16
|
+
"checkInterval": false,
|
|
17
|
+
"runEarly": true
|
|
18
|
+
},
|
|
19
|
+
"dependencies": ["dobo", "bajo-extra"]
|
|
20
|
+
}
|
package/bajo/init.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const types = ['datetime', 'date', 'timestamp']
|
|
2
|
+
|
|
3
|
+
async function handler ({ item }) {
|
|
4
|
+
const { join } = this.app.bajo
|
|
5
|
+
const { getSchema } = this.app.dobo
|
|
6
|
+
const { has } = this.app.bajo.lib._
|
|
7
|
+
for (const f of ['source', 'destination']) {
|
|
8
|
+
if (!has(item, f)) throw this.error('Task must have %s model', f)
|
|
9
|
+
const key = `${f}Field`
|
|
10
|
+
item[key] = item[key] ?? 'createdAt'
|
|
11
|
+
const schema = getSchema(item[f])
|
|
12
|
+
const prop = schema.properties.find(p => p.name === item[key])
|
|
13
|
+
if (!prop) throw this.error('Unknown field \'%s@%s\'', item[key], item[f])
|
|
14
|
+
if (!types.includes(prop.type)) throw this.error('\'%s@%s (%s)\' is not supported (must be one of %s)', item[key], item[f], prop.type, join(types))
|
|
15
|
+
}
|
|
16
|
+
if (item.source === item.destination) throw this.error('Source & destination must be different')
|
|
17
|
+
item.maxAge = item.maxAge ?? 1 // in days, less then 1 is ignored
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function init () {
|
|
21
|
+
const { buildCollections } = this.app.bajo
|
|
22
|
+
this.archivers = await buildCollections({ ns: this.name, handler, container: 'archive.tasks', dupChecks: ['source'], useDefaultName: false })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default init
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import format from '../../lib/ndjson-csv-xlsx.js'
|
|
3
|
+
import { createGzip } from 'node:zlib'
|
|
4
|
+
import scramjet from 'scramjet'
|
|
5
|
+
import supportedExt from '../../lib/io-exts.js'
|
|
6
|
+
|
|
7
|
+
const { DataStream } = scramjet
|
|
8
|
+
const { json, ndjson, csv, xlsx } = format
|
|
9
|
+
|
|
10
|
+
async function getFile (dest, ensureDir) {
|
|
11
|
+
const { importPkg, getPluginDataDir } = this.app.bajo
|
|
12
|
+
const { fs } = this.app.bajo.lib
|
|
13
|
+
const increment = await importPkg('add-filename-increment')
|
|
14
|
+
let file
|
|
15
|
+
if (path.isAbsolute(dest)) file = dest
|
|
16
|
+
else {
|
|
17
|
+
file = `${getPluginDataDir(this.name)}/export/${dest}`
|
|
18
|
+
fs.ensureDirSync(path.dirname(file))
|
|
19
|
+
}
|
|
20
|
+
file = increment(file, { fs: true })
|
|
21
|
+
const dir = path.dirname(file)
|
|
22
|
+
if (!fs.existsSync(dir)) {
|
|
23
|
+
if (ensureDir) fs.ensureDirSync(dir)
|
|
24
|
+
else throw this.error('Directory \'%s\' doesn\'t exist', dir)
|
|
25
|
+
}
|
|
26
|
+
let compress = false
|
|
27
|
+
let ext = path.extname(file)
|
|
28
|
+
if (ext === '.gz') {
|
|
29
|
+
compress = true
|
|
30
|
+
ext = path.extname(path.basename(file).replace('.gz', ''))
|
|
31
|
+
// file = file.slice(0, file.length - 3)
|
|
32
|
+
}
|
|
33
|
+
if (!supportedExt.includes(ext)) throw this.error('Unsupported format \'%s\'', ext.slice(1))
|
|
34
|
+
return { file, ext, compress }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getData ({ source, filter, count, stream, progressFn }) {
|
|
38
|
+
let cnt = count ?? 0
|
|
39
|
+
const { recordFind } = this.app.dobo
|
|
40
|
+
for (;;) {
|
|
41
|
+
const batchStart = new Date()
|
|
42
|
+
const { data, page } = await recordFind(source, filter, { dataOnly: false })
|
|
43
|
+
if (data.length === 0) break
|
|
44
|
+
cnt += data.length
|
|
45
|
+
await stream.pull(data)
|
|
46
|
+
if (progressFn) await progressFn.call(this, { batchNo: page, data, batchStart, batchEnd: new Date() })
|
|
47
|
+
filter.page++
|
|
48
|
+
}
|
|
49
|
+
await stream.end()
|
|
50
|
+
return cnt
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function exportTo (source, dest, { filter = {}, ensureDir, useHeader = true, batch = 500, progressFn } = {}, opts = {}) {
|
|
54
|
+
const { fs } = this.app.bajo.lib
|
|
55
|
+
const { merge } = this.app.bajo.lib._
|
|
56
|
+
|
|
57
|
+
filter.page = 1
|
|
58
|
+
batch = parseInt(batch) ?? 500
|
|
59
|
+
if (batch > this.config.export.maxBatch) batch = this.config.export.maxBatch
|
|
60
|
+
if (batch < 0) batch = 1
|
|
61
|
+
filter.limit = batch
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const { getInfo } = this.app.dobo
|
|
65
|
+
let count = 0
|
|
66
|
+
let file
|
|
67
|
+
let ext
|
|
68
|
+
let stream
|
|
69
|
+
let compress
|
|
70
|
+
let writer
|
|
71
|
+
getInfo(source)
|
|
72
|
+
.then(res => {
|
|
73
|
+
return getFile.call(this, dest, ensureDir)
|
|
74
|
+
})
|
|
75
|
+
.then(res => {
|
|
76
|
+
file = res.file
|
|
77
|
+
ext = res.ext
|
|
78
|
+
compress = res.compress
|
|
79
|
+
writer = fs.createWriteStream(file)
|
|
80
|
+
writer.on('error', err => {
|
|
81
|
+
reject(err)
|
|
82
|
+
})
|
|
83
|
+
writer.on('finish', () => {
|
|
84
|
+
resolve({ file, count })
|
|
85
|
+
})
|
|
86
|
+
stream = new DataStream()
|
|
87
|
+
stream = stream.flatMap(items => (items))
|
|
88
|
+
const pipes = []
|
|
89
|
+
if (ext === '.json') pipes.push(json.stringify(opts))
|
|
90
|
+
else if (['.ndjson', '.jsonl'].includes(ext)) pipes.push(ndjson.stringify(opts))
|
|
91
|
+
else if (ext === '.csv') pipes.push(csv.stringify(merge({}, { headers: useHeader }, opts)))
|
|
92
|
+
else if (ext === '.tsv') pipes.push(csv.stringify(merge({}, { headers: useHeader }, merge({}, opts, { delimiter: '\t' }))))
|
|
93
|
+
else if (ext === '.xlsx') pipes.push(xlsx.stringify(merge({}, { header: useHeader }, opts)))
|
|
94
|
+
if (compress) pipes.push(createGzip())
|
|
95
|
+
DataStream.pipeline(stream, ...pipes).pipe(writer)
|
|
96
|
+
return getData.call(this, { source, filter, count, stream, progressFn })
|
|
97
|
+
})
|
|
98
|
+
.then(cnt => {
|
|
99
|
+
count = cnt
|
|
100
|
+
})
|
|
101
|
+
.catch(reject)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default exportTo
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import format from '../../lib/ndjson-csv-xlsx.js'
|
|
3
|
+
import { createGunzip } from 'zlib'
|
|
4
|
+
import supportedExt from '../../lib/io-exts.js'
|
|
5
|
+
import scramjet from 'scramjet'
|
|
6
|
+
|
|
7
|
+
const { DataStream } = scramjet
|
|
8
|
+
const { json, ndjson, csv, xlsx } = format
|
|
9
|
+
|
|
10
|
+
async function importFrom (source, dest, { trashOld = true, batch = 1, progressFn, converterFn, useHeader = true, fileType, createOpts = {} } = {}, opts = {}) {
|
|
11
|
+
const { getPluginDataDir } = this.app.bajo
|
|
12
|
+
const { recordCreate } = this.app.dobo
|
|
13
|
+
const { merge } = this.app.bajo.lib._
|
|
14
|
+
const { fs } = this.app.bajo.lib
|
|
15
|
+
|
|
16
|
+
if (dest !== false) this.app.dobo.getInfo(dest) // make sure dest model is valid
|
|
17
|
+
let file
|
|
18
|
+
if (path.isAbsolute(source)) file = source
|
|
19
|
+
else {
|
|
20
|
+
file = `${getPluginDataDir(this.name)}/import/${source}`
|
|
21
|
+
fs.ensureDirSync(path.dirname(file))
|
|
22
|
+
}
|
|
23
|
+
if (!fs.existsSync(file)) throw this.error('Source file \'%s\' doesn\'t exist', file)
|
|
24
|
+
let ext = fileType ? `.${fileType}` : path.extname(file)
|
|
25
|
+
let decompress = false
|
|
26
|
+
if (ext === '.gz') {
|
|
27
|
+
ext = path.extname(path.basename(file, '.gz'))
|
|
28
|
+
decompress = true
|
|
29
|
+
}
|
|
30
|
+
if (!supportedExt.includes(ext)) throw this.error('Unsupported format \'%s\'', ext.slice(1))
|
|
31
|
+
if (trashOld && dest !== false) await this.app.dobo.modelClear(dest)
|
|
32
|
+
const reader = fs.createReadStream(file)
|
|
33
|
+
batch = parseInt(batch) || 100
|
|
34
|
+
if (batch > this.config.import.maxBatch) batch = this.config.import.maxBatch
|
|
35
|
+
if (batch < 0) batch = 1
|
|
36
|
+
let count = 0
|
|
37
|
+
const pipes = [reader]
|
|
38
|
+
if (decompress) pipes.push(createGunzip())
|
|
39
|
+
if (ext === '.json') pipes.push(json.parse(opts))
|
|
40
|
+
else if (['.ndjson', '.jsonl'].includes(ext)) pipes.push(ndjson.parse(opts))
|
|
41
|
+
else if (ext === '.csv') pipes.push(csv.parse(merge({}, { headers: useHeader }, opts)))
|
|
42
|
+
else if (ext === '.tsv') pipes.push(csv.parse(merge({}, { headers: useHeader }, merge({}, opts, { delimiter: '\t' }))))
|
|
43
|
+
else if (ext === '.xlsx') pipes.push(xlsx.parse(merge({}, { header: useHeader }, opts)))
|
|
44
|
+
|
|
45
|
+
const stream = DataStream.pipeline(...pipes)
|
|
46
|
+
let batchNo = 1
|
|
47
|
+
const data = []
|
|
48
|
+
await stream
|
|
49
|
+
.batch(batch)
|
|
50
|
+
.map(async items => {
|
|
51
|
+
if (items.length === 0) return null
|
|
52
|
+
const batchStart = new Date()
|
|
53
|
+
for (let item of items) {
|
|
54
|
+
count++
|
|
55
|
+
item = converterFn ? await converterFn.call(this, item) : item
|
|
56
|
+
if (dest !== false) await recordCreate(dest, item, createOpts)
|
|
57
|
+
else data.push(item)
|
|
58
|
+
}
|
|
59
|
+
if (progressFn) await progressFn.call(this, { batchNo, data: items, batchStart, batchEnd: new Date() })
|
|
60
|
+
batchNo++
|
|
61
|
+
})
|
|
62
|
+
.run()
|
|
63
|
+
|
|
64
|
+
return dest === false ? data : { file, count }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default importFrom
|
package/bajo/start.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
async function start () {
|
|
2
|
+
if (this.config.archive.checkInterval === false || this.config.archive.checkInterval <= 0) {
|
|
3
|
+
this.log.warn('Automatic archiving is disabled')
|
|
4
|
+
return
|
|
5
|
+
}
|
|
6
|
+
if (this.config.archive.runEarly) await this.archive()
|
|
7
|
+
this.archiveIntv = setInterval(() => {
|
|
8
|
+
this.archive().then().catch(err => {
|
|
9
|
+
this.log.error('Archive error: %s', err.message)
|
|
10
|
+
})
|
|
11
|
+
}, this.config.archive.checkInterval * 60 * 1000)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default start
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
async function move (task) {
|
|
2
|
+
const { importPkg } = this.app.bajo
|
|
3
|
+
const { dayjs } = this.app.bajo.lib
|
|
4
|
+
const { set } = this.app.bajo.lib._
|
|
5
|
+
const { formatInteger } = this.app.bajoExtra
|
|
6
|
+
const { recordFind, recordCreate, recordRemove, statAggregate } = this.app.dobo
|
|
7
|
+
const prompts = await importPkg('bajoCli:@inquirer/prompts')
|
|
8
|
+
const { confirm } = prompts
|
|
9
|
+
// get relevant record
|
|
10
|
+
this.print.info('Copying %s -> %s...', task.source, task.destination)
|
|
11
|
+
const mark = dayjs().subtract(task.maxAge, 'day').toDate()
|
|
12
|
+
const query = set({}, task.sourceField, { $lt: mark })
|
|
13
|
+
let count = await statAggregate(task.source, { query }, { aggregate: 'count' })
|
|
14
|
+
count = count[0].count
|
|
15
|
+
if (count === 0) {
|
|
16
|
+
this.print.warn('No record found, skipped')
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (this.config.prompt !== false) {
|
|
20
|
+
const answer = await confirm({
|
|
21
|
+
message: this.print.write('%d records will be archived. Continue?', count),
|
|
22
|
+
default: true
|
|
23
|
+
})
|
|
24
|
+
if (!answer) {
|
|
25
|
+
this.print.warn('Task %s -> %s cancelled', task.source, task.destination)
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
} else this.print.info('%d records will be archived', count)
|
|
29
|
+
let page = 1
|
|
30
|
+
let total = 0
|
|
31
|
+
let affected = 0
|
|
32
|
+
let error = 0
|
|
33
|
+
let isMax = false
|
|
34
|
+
const ids = []
|
|
35
|
+
const spin = this.print.spinner({ showCounter: true }).start()
|
|
36
|
+
for (;;) {
|
|
37
|
+
const results = await recordFind(task.source, { query, page, limit: 100 }, { noCache: true, noHook: true })
|
|
38
|
+
if (results.length === 0) break
|
|
39
|
+
if (this.bajo.config.tool && total % 1000 === 0 && total !== 0) this.print.succeed('Milestone #%s records copied', formatInteger(total))
|
|
40
|
+
for (const r of results) {
|
|
41
|
+
if (task.maxRecords && task.maxRecords < total) {
|
|
42
|
+
isMax = true
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
total++
|
|
46
|
+
try {
|
|
47
|
+
await recordCreate(task.destination, r, { noSanitize: true, noHook: true, noResult: true, noCheckUnique: true })
|
|
48
|
+
ids.push(r.id)
|
|
49
|
+
spin.setText('ID #%s', r.id)
|
|
50
|
+
} catch (err) {
|
|
51
|
+
error++
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (isMax) break
|
|
55
|
+
affected = total - error
|
|
56
|
+
page++
|
|
57
|
+
}
|
|
58
|
+
if (isMax) this.print.warn('Max of %d records reached', task.maxRecords)
|
|
59
|
+
this.print.info('Removing %s...', task.source)
|
|
60
|
+
for (const idx in ids) {
|
|
61
|
+
const id = ids[idx]
|
|
62
|
+
try {
|
|
63
|
+
await recordRemove(task.source, id, { noHook: true, noResult: true })
|
|
64
|
+
spin.setText('ID #%s', id)
|
|
65
|
+
if (this.bajo.config.tool && idx % 1000 === 0 && idx !== '0') this.print.succeed('Milestone #%s records removed', formatInteger(idx))
|
|
66
|
+
} catch (err) {}
|
|
67
|
+
}
|
|
68
|
+
spin.stop()
|
|
69
|
+
this.print.info('Archiving %s -> %s ended, moved: %s, error: %s', task.source, task.destination, formatInteger(affected), formatInteger(error))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function archive (...args) {
|
|
73
|
+
const { importPkg } = this.app.bajo
|
|
74
|
+
const prompts = await importPkg('bajoCli:@inquirer/prompts')
|
|
75
|
+
const { confirm } = prompts
|
|
76
|
+
if (this.config.prompt !== false) {
|
|
77
|
+
const answer = await confirm({
|
|
78
|
+
message: this.print.write('You\'re about to manually run task archiver. Continue?'),
|
|
79
|
+
default: false
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
if (!answer) this.print.fatal('Aborted!')
|
|
83
|
+
}
|
|
84
|
+
await this.app.dobo.start()
|
|
85
|
+
if (this.archivers.length === 0) this.print.fatal('Nothing to archive')
|
|
86
|
+
for (const t of this.archivers.tasks) {
|
|
87
|
+
if (t.maxAge < 1) {
|
|
88
|
+
this.print.warn('Archive %s -> %s is disabled', t.source, t.destination)
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
await move.call(this, t)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export default archive
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import _path from 'path'
|
|
2
|
+
|
|
3
|
+
const batch = 100
|
|
4
|
+
|
|
5
|
+
function makeProgress (spin) {
|
|
6
|
+
const { secToHms } = this.app.bajo
|
|
7
|
+
return async function ({ batchNo, data, batchStart, batchEnd } = {}) {
|
|
8
|
+
if (data.length === 0) return
|
|
9
|
+
spin.setText('Batch #%d (%s)', batchNo, secToHms(batchEnd.toTime() - batchStart.toTime(), true))
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function exportTo (...args) {
|
|
14
|
+
const { importPkg } = this.app.bajo
|
|
15
|
+
const { dayjs } = this.app.bajo.lib
|
|
16
|
+
const { isEmpty, map } = this.app.bajo.lib._
|
|
17
|
+
const { getInfo, start } = this.app.dobo
|
|
18
|
+
|
|
19
|
+
const [input, select] = await importPkg('bajoCli:@inquirer/input',
|
|
20
|
+
'bajoCli:@inquirer/select')
|
|
21
|
+
const schemas = map(this.app.dobo.schemas, 'name')
|
|
22
|
+
if (isEmpty(schemas)) return this.print.fatal('No schema found!')
|
|
23
|
+
let [model, dest, query] = args
|
|
24
|
+
if (isEmpty(model)) {
|
|
25
|
+
model = await select({
|
|
26
|
+
message: this.print.write('Please choose model:'),
|
|
27
|
+
choices: map(schemas, s => ({ value: s }))
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
if (isEmpty(dest)) {
|
|
31
|
+
dest = await input({
|
|
32
|
+
message: this.print.write('Please enter destination file:'),
|
|
33
|
+
default: `${model}-${dayjs().format('YYYYMMDD')}.ndjson`,
|
|
34
|
+
validate: (item) => !isEmpty(item)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
if (isEmpty(query)) {
|
|
38
|
+
query = await input({
|
|
39
|
+
message: this.print.write('Please enter a query (if any):')
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
const spin = this.print.spinner().start('Exporting...')
|
|
43
|
+
const progressFn = makeProgress.call(this, spin)
|
|
44
|
+
const { connection } = getInfo(model)
|
|
45
|
+
await start(connection.name)
|
|
46
|
+
try {
|
|
47
|
+
const filter = { query }
|
|
48
|
+
const result = await this.exportTo(model, dest, { filter, batch, progressFn })
|
|
49
|
+
spin.succeed('%d records successfully exported to \'%s\'', result.count, _path.resolve(result.file))
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.log(err)
|
|
52
|
+
spin.fatal('Error: %s', err.message)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default exportTo
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import _path from 'path'
|
|
2
|
+
|
|
3
|
+
const batch = 100
|
|
4
|
+
|
|
5
|
+
function makeProgress (spin) {
|
|
6
|
+
const { secToHms } = this.app.bajo
|
|
7
|
+
return async function ({ batchNo, data, batchStart, batchEnd } = {}) {
|
|
8
|
+
spin.setText('Batch #%d (%s)', batchNo, secToHms(batchEnd.toTime() - batchStart.toTime(), true))
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function importFrom (...args) {
|
|
13
|
+
const { importPkg } = this.app.bajo
|
|
14
|
+
const { isEmpty, map } = this.app.bajo.lib._
|
|
15
|
+
const { getInfo, start } = this.app.dobo
|
|
16
|
+
|
|
17
|
+
const [input, select, confirm] = await importPkg('bajoCli:@inquirer/input',
|
|
18
|
+
'bajoCli:@inquirer/select', 'bajoCli:@inquirer/confirm')
|
|
19
|
+
const schemas = map(this.app.dobo.schemas, 'name')
|
|
20
|
+
if (isEmpty(schemas)) return this.print.fatal('No schema found!')
|
|
21
|
+
let [dest, model] = args
|
|
22
|
+
if (isEmpty(dest)) {
|
|
23
|
+
dest = await input({
|
|
24
|
+
message: this.print.write('Please enter source file:'),
|
|
25
|
+
validate: (item) => !isEmpty(item)
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
if (isEmpty(model)) {
|
|
29
|
+
model = await select({
|
|
30
|
+
message: this.print.write('Please choose model:'),
|
|
31
|
+
choices: map(schemas, s => ({ value: s }))
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
const answer = await confirm({
|
|
35
|
+
message: this.print.write('You\'re about to replace ALL records with the new ones. Are you really sure?'),
|
|
36
|
+
default: false
|
|
37
|
+
})
|
|
38
|
+
if (!answer) return this.print.fatal('Aborted!')
|
|
39
|
+
const spin = this.print.spinner({ showCounter: true }).start('Importing...')
|
|
40
|
+
const progressFn = makeProgress.call(this, spin)
|
|
41
|
+
const { connection } = getInfo(model)
|
|
42
|
+
await start(connection.name)
|
|
43
|
+
try {
|
|
44
|
+
const result = await importFrom(dest, model, { batch, progressFn })
|
|
45
|
+
spin.succeed('%d records successfully imported from \'%s\'', result.count, _path.resolve(result.file))
|
|
46
|
+
} catch (err) {
|
|
47
|
+
spin.fatal('Error: %s', err.message)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default importFrom
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default 'default'
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Directory '%s' doesn't exist": "",
|
|
3
|
+
"Unsupported format '%s'": "",
|
|
4
|
+
"Source file '%s' doesn't exist": "",
|
|
5
|
+
"Task must have %s model": "",
|
|
6
|
+
"Unknown field '%s@%s'": "",
|
|
7
|
+
"'%s@%s (%s)' is not supported (must be one of %s)": "",
|
|
8
|
+
"Source & destination must be different": "",
|
|
9
|
+
"Automatic archiving is disabled": "",
|
|
10
|
+
"Archive error: %s": "",
|
|
11
|
+
"Copying %s -> %s...": "",
|
|
12
|
+
"No record found, skipped": "",
|
|
13
|
+
"%d records will be archived. Continue?": "",
|
|
14
|
+
"Task %s -> %s cancelled": "",
|
|
15
|
+
"%d records will be archived": "",
|
|
16
|
+
"Milestone #%s records copied": "",
|
|
17
|
+
"ID #%s": "",
|
|
18
|
+
"Max of %d records reached": "",
|
|
19
|
+
"Removing %s...": "",
|
|
20
|
+
"Milestone #%s records removed": "",
|
|
21
|
+
"Archiving %s -> %s ended, moved: %s, error: %s": "",
|
|
22
|
+
"You're about to manually run task archiver. Continue?": "",
|
|
23
|
+
"Nothing to archive": "",
|
|
24
|
+
"Archive %s -> %s is disabled": "",
|
|
25
|
+
"Batch #%d (%s)": "",
|
|
26
|
+
"No schema found!": "",
|
|
27
|
+
"Please choose model:": "",
|
|
28
|
+
"Please enter destination file:": "",
|
|
29
|
+
"Please enter a query (if any):": "",
|
|
30
|
+
"Exporting...": "",
|
|
31
|
+
"%d records successfully exported to '%s'": "",
|
|
32
|
+
"Error: %s": "",
|
|
33
|
+
"Please enter source file:": "",
|
|
34
|
+
"You're about to replace ALL records with the new ones. Are you really sure?": "",
|
|
35
|
+
"Importing...": "",
|
|
36
|
+
"%d records successfully imported from '%s'": ""
|
|
37
|
+
}
|
package/lib/io-exts.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default ['.json', '.jsonl', '.ndjson', '.csv', '.xlsx', '.tsv']
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Borrowed from: https://github.com/fanlia/ndjson-csv-xlsx/blob/main/index.js
|
|
2
|
+
|
|
3
|
+
import ndjson from 'ndjson'
|
|
4
|
+
import csv from 'fast-csv'
|
|
5
|
+
import xlsxparse from 'xlsx-parse-stream'
|
|
6
|
+
import XLSXWriteStream from '@atomictech/xlsx-write-stream'
|
|
7
|
+
import StreamArray from 'stream-json/streamers/StreamArray.js'
|
|
8
|
+
import stringer from 'stream-json/Stringer.js'
|
|
9
|
+
import disassembler from 'stream-json/Disassembler.js'
|
|
10
|
+
import chain from 'stream-chain'
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
ndjson: {
|
|
14
|
+
parse: (...args) => ndjson.parse(...args),
|
|
15
|
+
stringify: (...args) => ndjson.stringify(...args)
|
|
16
|
+
},
|
|
17
|
+
csv: {
|
|
18
|
+
parse: (...args) => csv.parse(...args),
|
|
19
|
+
stringify: (...args) => csv.format(...args)
|
|
20
|
+
},
|
|
21
|
+
xlsx: {
|
|
22
|
+
parse: (...args) => xlsxparse(...args),
|
|
23
|
+
stringify: (...args) => new XLSXWriteStream(...args)
|
|
24
|
+
},
|
|
25
|
+
json: {
|
|
26
|
+
parse: (...args) => chain([
|
|
27
|
+
StreamArray.withParser(...args),
|
|
28
|
+
data => data.value
|
|
29
|
+
]),
|
|
30
|
+
stringify: (options, ...args) => chain([
|
|
31
|
+
disassembler(),
|
|
32
|
+
stringer({ ...options, makeArray: true })
|
|
33
|
+
])
|
|
34
|
+
}
|
|
35
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dobo-extra",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Bajo DB Extra Tools/Utility",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/ardhi/dobo-extra.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"extra",
|
|
16
|
+
"db",
|
|
17
|
+
"driver",
|
|
18
|
+
"bajo",
|
|
19
|
+
"framework",
|
|
20
|
+
"modular"
|
|
21
|
+
],
|
|
22
|
+
"author": "Ardhi Lukianto <ardhi@lukianto.com>",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/ardhi/dobo-extra/issues"
|
|
26
|
+
},
|
|
27
|
+
"homepage": "https://github.com/ardhi/dobo-extra#readme",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@atomictech/xlsx-write-stream": "^2.0.2",
|
|
30
|
+
"fast-csv": "^5.0.1",
|
|
31
|
+
"fast-jwt": "^4.0.2",
|
|
32
|
+
"ndjson": "^2.0.0",
|
|
33
|
+
"scramjet": "^4.37.0",
|
|
34
|
+
"stream-chain": "^2.2.5",
|
|
35
|
+
"stream-json": "^1.8.0",
|
|
36
|
+
"xlsx-parse-stream": "^1.1.0"
|
|
37
|
+
}
|
|
38
|
+
}
|