|
|
@@ -3,53 +3,59 @@ const fs = require('fs');
|
|
|
const path = require('path');
|
|
|
const streamToPromise = require('stream-to-promise');
|
|
|
const archiver = require('archiver');
|
|
|
+const toArrayIfNot = require('../../lib/util/toArrayIfNot');
|
|
|
|
|
|
class ExportService {
|
|
|
|
|
|
constructor(crowi) {
|
|
|
+ this.crowi = crowi;
|
|
|
+ this.appService = crowi.appService;
|
|
|
+ this.growiBridgeService = crowi.growiBridgeService;
|
|
|
+ this.getFile = this.growiBridgeService.getFile.bind(this);
|
|
|
this.baseDir = path.join(crowi.tmpDir, 'downloads');
|
|
|
- this.extension = 'json';
|
|
|
- this.encoding = 'utf-8';
|
|
|
this.per = 100;
|
|
|
this.zlibLevel = 9; // 0(min) - 9(max)
|
|
|
+ }
|
|
|
|
|
|
- this.files = {};
|
|
|
- // populate this.files
|
|
|
- // this.files = {
|
|
|
- // configs: path.join(this.baseDir, 'configs.json'),
|
|
|
- // pages: path.join(this.baseDir, 'pages.json'),
|
|
|
- // pagetagrelations: path.join(this.baseDir, 'pagetagrelations.json'),
|
|
|
- // ...
|
|
|
- // };
|
|
|
- // TODO: handle 3 globalnotificationsettings collection properly
|
|
|
- // see Object.values(crowi.models).forEach((m) => { return console.log(m.collection.collectionName) });
|
|
|
- Object.values(crowi.models).forEach((m) => {
|
|
|
- const name = m.collection.collectionName;
|
|
|
- this.files[name] = path.join(this.baseDir, `${name}.${this.extension}`);
|
|
|
- });
|
|
|
+ /**
|
|
|
+ * parse all zip files in downloads dir
|
|
|
+ *
|
|
|
+ * @memberOf ExportService
|
|
|
+ * @return {Array.<object>} info for zip files
|
|
|
+ */
|
|
|
+ async getStatus() {
|
|
|
+ const zipFiles = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' });
|
|
|
+ const zipFileStats = await Promise.all(zipFiles.map((file) => {
|
|
|
+ const zipFile = this.getFile(file);
|
|
|
+ return this.growiBridgeService.parseZipFile(zipFile);
|
|
|
+ }));
|
|
|
+
|
|
|
+ return zipFileStats;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * dump a collection into json
|
|
|
+ * create meta.json
|
|
|
*
|
|
|
* @memberOf ExportService
|
|
|
- * @return {object} cache info for exported zip files
|
|
|
+ * @return {string} path to meta.json
|
|
|
*/
|
|
|
- getStatus() {
|
|
|
- const status = {};
|
|
|
- const collections = Object.keys(this.files);
|
|
|
- collections.forEach((file) => {
|
|
|
- status[path.basename(file, '.zip')] = null;
|
|
|
- });
|
|
|
+ async createMetaJson() {
|
|
|
+ const metaJson = path.join(this.baseDir, this.growiBridgeService.getMetaFileName());
|
|
|
+ const writeStream = fs.createWriteStream(metaJson, { encoding: this.growiBridgeService.getEncoding() });
|
|
|
+
|
|
|
+ const metaData = {
|
|
|
+ version: this.crowi.version,
|
|
|
+ url: this.appService.getSiteUrl(),
|
|
|
+ passwordSeed: this.crowi.env.PASSWORD_SEED,
|
|
|
+ exportedAt: new Date(),
|
|
|
+ };
|
|
|
|
|
|
- // extract ${collectionName}.zip
|
|
|
- const files = fs.readdirSync(this.baseDir).filter((file) => { return path.extname(file) === '.zip' && collections.includes(path.basename(file, '.zip')) });
|
|
|
+ writeStream.write(JSON.stringify(metaData));
|
|
|
+ writeStream.close();
|
|
|
|
|
|
- files.forEach((file) => {
|
|
|
- status[path.basename(file, '.zip')] = file;
|
|
|
- });
|
|
|
+ await streamToPromise(writeStream);
|
|
|
|
|
|
- return status;
|
|
|
+ return metaJson;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -58,29 +64,32 @@ class ExportService {
|
|
|
* @memberOf ExportService
|
|
|
* @param {string} file path to json file to be written
|
|
|
* @param {readStream} readStream read stream
|
|
|
- * @param {number} [total] number of target items (optional)
|
|
|
+ * @param {number} total number of target items (optional)
|
|
|
+ * @param {function} [getLogText] (n, total) => { ... }
|
|
|
+ * @return {string} path to the exported json file
|
|
|
*/
|
|
|
- async export(file, readStream, total) {
|
|
|
+ async export(writeStream, readStream, total, getLogText) {
|
|
|
let n = 0;
|
|
|
- const ws = fs.createWriteStream(file, { encoding: this.encoding });
|
|
|
|
|
|
// open an array
|
|
|
- ws.write('[');
|
|
|
+ writeStream.write('[');
|
|
|
|
|
|
readStream.on('data', (chunk) => {
|
|
|
- if (n !== 0) ws.write(',');
|
|
|
- ws.write(JSON.stringify(chunk));
|
|
|
+ if (n !== 0) writeStream.write(',');
|
|
|
+ writeStream.write(JSON.stringify(chunk));
|
|
|
n++;
|
|
|
- this.logProgress(n, total);
|
|
|
+ this.logProgress(n, total, getLogText);
|
|
|
});
|
|
|
|
|
|
readStream.on('end', () => {
|
|
|
// close the array
|
|
|
- ws.write(']');
|
|
|
- ws.close();
|
|
|
+ writeStream.write(']');
|
|
|
+ writeStream.close();
|
|
|
});
|
|
|
|
|
|
await streamToPromise(readStream);
|
|
|
+
|
|
|
+ return writeStream.path;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -90,19 +99,30 @@ class ExportService {
|
|
|
* @param {object} Model instance of mongoose model
|
|
|
* @return {string} path to zip file
|
|
|
*/
|
|
|
- async exportCollection(Model) {
|
|
|
- const modelName = Model.collection.collectionName;
|
|
|
- const file = this.files[modelName];
|
|
|
- const total = await Model.countDocuments();
|
|
|
+ async exportCollectionToJson(Model) {
|
|
|
+ const { collectionName } = Model.collection;
|
|
|
+ const jsonFileToWrite = path.join(this.baseDir, `${collectionName}.json`);
|
|
|
+ const writeStream = fs.createWriteStream(jsonFileToWrite, { encoding: this.growiBridgeService.getEncoding() });
|
|
|
const readStream = Model.find().cursor();
|
|
|
+ const total = await Model.countDocuments();
|
|
|
+ const getLogText = (n, total) => `${collectionName}: ${n}/${total} written`;
|
|
|
|
|
|
- await this.export(file, readStream, total);
|
|
|
+ const jsonFileWritten = await this.export(writeStream, readStream, total, getLogText);
|
|
|
|
|
|
- const { file: zipFile, size } = await this.zipSingleFile(file);
|
|
|
+ return jsonFileWritten;
|
|
|
+ }
|
|
|
|
|
|
- logger.info(`exported ${modelName} collection into ${zipFile} (${size} bytes)`);
|
|
|
+ /**
|
|
|
+ * export multiple collections
|
|
|
+ *
|
|
|
+ * @memberOf ExportService
|
|
|
+ * @param {Array.<object>} models array of instances of mongoose model
|
|
|
+ * @return {Array.<string>} paths to json files created
|
|
|
+ */
|
|
|
+ async exportMultipleCollectionsToJsons(models) {
|
|
|
+ const jsonFiles = await Promise.all(models.map(Model => this.exportCollectionToJson(Model)));
|
|
|
|
|
|
- return zipFile;
|
|
|
+ return jsonFiles;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
@@ -110,16 +130,11 @@ class ExportService {
|
|
|
*
|
|
|
* @memberOf ExportService
|
|
|
* @param {number} n number of items exported
|
|
|
- * @param {number} [total] number of target items (optional)
|
|
|
+ * @param {number} total number of target items (optional)
|
|
|
+ * @param {function} [getLogText] (n, total) => { ... }
|
|
|
*/
|
|
|
- logProgress(n, total) {
|
|
|
- let output;
|
|
|
- if (total) {
|
|
|
- output = `${n}/${total} written`;
|
|
|
- }
|
|
|
- else {
|
|
|
- output = `${n} items written`;
|
|
|
- }
|
|
|
+ logProgress(n, total, getLogText) {
|
|
|
+ const output = getLogText ? getLogText(n, total) : `${n}/${total} items written`;
|
|
|
|
|
|
// output every this.per items
|
|
|
if (n % this.per === 0) logger.debug(output);
|
|
|
@@ -128,21 +143,21 @@ class ExportService {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * zip a file
|
|
|
+ * zip files into one zip file
|
|
|
*
|
|
|
* @memberOf ExportService
|
|
|
- * @param {string} from path to input file
|
|
|
- * @param {string} [to=`${path.join(path.dirname(from), `${path.basename(from, path.extname(from))}.zip`)}`] path to output file
|
|
|
- * @param {string} [as=path.basename(from)] file name after unzipped
|
|
|
- * @return {object} file path and file size
|
|
|
+ * @param {object|array<object>} configs object or array of object { from: "path to source file", as: "file name after unzipped" }
|
|
|
+ * @return {string} absolute path to the zip file
|
|
|
* @see https://www.archiverjs.com/#quick-start
|
|
|
*/
|
|
|
- async zipSingleFile(from, to = this.replaceExtension(from, 'zip'), as = path.basename(from)) {
|
|
|
+ async zipFiles(_configs) {
|
|
|
+ const configs = toArrayIfNot(_configs);
|
|
|
+ const appTitle = this.appService.getAppTitle();
|
|
|
+ const timeStamp = (new Date()).getTime();
|
|
|
+ const zipFile = path.join(this.baseDir, `${appTitle}-${timeStamp}.zip`);
|
|
|
const archive = archiver('zip', {
|
|
|
zlib: { level: this.zlibLevel },
|
|
|
});
|
|
|
- const input = fs.createReadStream(from);
|
|
|
- const output = fs.createWriteStream(to);
|
|
|
|
|
|
// good practice to catch warnings (ie stat failures and other non-blocking errors)
|
|
|
archive.on('warning', (err) => {
|
|
|
@@ -153,8 +168,14 @@ class ExportService {
|
|
|
// good practice to catch this error explicitly
|
|
|
archive.on('error', (err) => { throw err });
|
|
|
|
|
|
- // append a file from stream
|
|
|
- archive.append(input, { name: as });
|
|
|
+ for (const { from, as } of configs) {
|
|
|
+ const input = fs.createReadStream(from);
|
|
|
+
|
|
|
+ // append a file from stream
|
|
|
+ archive.append(input, { name: as });
|
|
|
+ }
|
|
|
+
|
|
|
+ const output = fs.createWriteStream(zipFile);
|
|
|
|
|
|
// pipe archive data to the file
|
|
|
archive.pipe(output);
|
|
|
@@ -165,39 +186,14 @@ class ExportService {
|
|
|
|
|
|
await streamToPromise(archive);
|
|
|
|
|
|
- return {
|
|
|
- file: to,
|
|
|
- size: archive.pointer(),
|
|
|
- };
|
|
|
- }
|
|
|
+ logger.info(`zipped growi data into ${zipFile} (${archive.pointer()} bytes)`);
|
|
|
|
|
|
- /**
|
|
|
- * replace a file extension
|
|
|
- *
|
|
|
- * @memberOf ExportService
|
|
|
- * @param {string} file file path
|
|
|
- * @param {string} extension new extension
|
|
|
- * @return {string} path to file with new extension
|
|
|
- */
|
|
|
- replaceExtension(file, extension) {
|
|
|
- return `${path.join(path.dirname(file), `${path.basename(file, path.extname(file))}.${extension}`)}`;
|
|
|
- }
|
|
|
-
|
|
|
- /**
|
|
|
- * get the path to the zipped file for a collection
|
|
|
- *
|
|
|
- * @memberOf ExportService
|
|
|
- * @param {object} Model instance of mongoose model
|
|
|
- * @return {string} path to zip file
|
|
|
- */
|
|
|
- getZipFile(Model) {
|
|
|
- const json = this.files[Model.collection.collectionName];
|
|
|
- const zip = this.replaceExtension(json, 'zip');
|
|
|
- if (!fs.existsSync(zip)) {
|
|
|
- return null;
|
|
|
+ // delete json files
|
|
|
+ for (const { from } of configs) {
|
|
|
+ fs.unlinkSync(from);
|
|
|
}
|
|
|
|
|
|
- return zip;
|
|
|
+ return zipFile;
|
|
|
}
|
|
|
|
|
|
}
|