diff --git a/ghost/core/core/server/api/endpoints/db.js b/ghost/core/core/server/api/endpoints/db.js
index 8203166936..2d3f60111d 100644
--- a/ghost/core/core/server/api/endpoints/db.js
+++ b/ghost/core/core/server/api/endpoints/db.js
@@ -1,9 +1,11 @@
const Promise = require('bluebird');
+const moment = require('moment-timezone');
const dbBackup = require('../../data/db/backup');
const exporter = require('../../data/exporter');
const importer = require('../../data/importer');
const errors = require('@tryghost/errors');
const models = require('../../models');
+const settingsCache = require('../../../shared/settings-cache');
module.exports = {
docName: 'db',
@@ -83,7 +85,14 @@ module.exports = {
},
permissions: true,
query(frame) {
- return importer.importFromFile(frame.file, {include: frame.options.withRelated});
+ const siteTimezone = settingsCache.get('timezone');
+ const importTag = `Import ${moment().tz(siteTimezone).format('YYYY-MM-DD HH:mm')}`;
+ return importer.importFromFile(frame.file, {
+ user: {
+ email: frame.user.get('email')
+ },
+ importTag
+ });
}
},
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/db.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/db.js
index 46bae9326a..5e3f2ff818 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/db.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/db.js
@@ -20,15 +20,11 @@ module.exports = {
importContent(response, apiConfig, frame) {
debug('importContent');
- // NOTE: response can contain 2 objects if images are imported
- const problems = (response.length === 2)
- ? response[1].problems
- : response[0].problems;
-
frame.response = {
- db: [],
- problems: problems
+ db: []
};
+
+ frame.response.problems = response?.data?.problems ?? [];
},
deleteAllContent(response, apiConfig, frame) {
diff --git a/ghost/core/core/server/data/importer/email-template.js b/ghost/core/core/server/data/importer/email-template.js
new file mode 100644
index 0000000000..b65fc16e9e
--- /dev/null
+++ b/ghost/core/core/server/data/importer/email-template.js
@@ -0,0 +1,194 @@
+const _ = require('lodash');
+
+const iff = (cond, yes, no) => (cond ? yes : no);
+module.exports = ({result, siteUrl, postsUrl, emailRecipient}) => `
+
+
+
+
+
+ Your member import is complete
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+  |
+
+
+
+ ${iff(result.errors, `Your content import was unsuccessful`, `Your content import has finished`)}
+ |
+
+ ${iff(result.errors, `
+
+
+ Failed to import content with the following error${iff(result.errors && result.errors.length > 1), `s`, ``}:
+ |
+
+
+
+
+ ${_.each(result.errors, error => `
+ - ${error.message}
+ `)}
+
+ |
+
+ `, '')}
+ ${iff(result.problems && result.problems.length > 0, `
+
+
+ Imported content successfully, but with the following warning${iff(result.problems && result.problems.length > 1), `s`, ``}:
+ |
+
+
+
+
+ ${_.each(result.problems, problem => `
+ - ${problem.message}
+ `)}
+
+ |
+
+ `, '')}
+ ${iff(result.data && result.data.posts && result.data.posts.length > 0, `
+
+
+ View imported posts
+ |
+
+ `, ``)}
+
+ |
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+`;
+
diff --git a/ghost/core/core/server/data/importer/import-manager.js b/ghost/core/core/server/data/importer/import-manager.js
index 084ae4fdda..0b2e3871f5 100644
--- a/ghost/core/core/server/data/importer/import-manager.js
+++ b/ghost/core/core/server/data/importer/import-manager.js
@@ -13,6 +13,12 @@ const JSONHandler = require('./handlers/json');
const MarkdownHandler = require('./handlers/markdown');
const ImageImporter = require('./importers/image');
const DataImporter = require('./importers/data');
+const urlUtils = require('../../../shared/url-utils');
+const {GhostMailer} = require('../../services/mail');
+const jobManager = require('../../services/jobs');
+
+const emailTemplate = require('./email-template');
+const ghostMailer = new GhostMailer();
const messages = {
couldNotCleanUpFile: {
@@ -115,28 +121,6 @@ class ImportManager {
return prefix + this.getGlobPattern(directories);
}
- /**
- * Remove files after we're done (abstracted into a function for easier testing)
- * @returns {Promise}
- */
- async cleanUp() {
- if (this.fileToDelete === null) {
- return;
- }
-
- try {
- await fs.remove(this.fileToDelete);
- } catch (err) {
- logging.error(new errors.InternalServerError({
- err: err,
- context: tpl(messages.couldNotCleanUpFile.error),
- help: tpl(messages.couldNotCleanUpFile.context)
- }));
- }
-
- this.fileToDelete = null;
- }
-
/**
* Return true if the given file is a Zip
* @returns Boolean
@@ -333,15 +317,15 @@ class ImportManager {
* data that it should import. Each importer then handles actually importing that data into Ghost
* @param {ImportData} importData
* @param {ImportOptions} [importOptions] to allow override of certain import features such as locking a user
- * @returns {Promise} importResults
+ * @returns {Promise>} importResults
*/
async doImport(importData, importOptions) {
importOptions = importOptions || {};
- const importResults = [];
+ const importResults = {};
for (const importer of this.importers) {
if (Object.prototype.hasOwnProperty.call(importData, importer.type)) {
- importResults.push(await importer.doImport(importData[importer.type], importOptions));
+ importResults[importer.type] = await importer.doImport(importData[importer.type], importOptions);
}
}
@@ -351,45 +335,126 @@ class ImportManager {
/**
* Import Step 4:
* Report on what was imported, currently a no-op
- * @param {ImportResult[]} importResults
- * @returns {Promise} importResults
+ * @param {Object.} importResults
+ * @returns {Promise>} importResults
*/
async generateReport(importResults) {
return Promise.resolve(importResults);
}
+ /**
+ * Step 5:
+ * Remove files after we're done (abstracted into a function for easier testing)
+ * @returns {Promise}
+ */
+ async cleanUp() {
+ if (this.fileToDelete === null) {
+ return;
+ }
+
+ try {
+ await fs.remove(this.fileToDelete);
+ } catch (err) {
+ logging.error(new errors.InternalServerError({
+ err: err,
+ context: tpl(messages.couldNotCleanUpFile.error),
+ help: tpl(messages.couldNotCleanUpFile.context)
+ }));
+ }
+
+ this.fileToDelete = null;
+ }
+
+ /**
+ * Import Step 6:
+ * Create an email to notify the user that the import has completed
+ * @param {ImportResult} result
+ * @param {Object} options
+ * @param {string} options.emailRecipient
+ * @param {string} options.importTag
+ * @returns {string}
+ */
+ generateCompletionEmail(result, {
+ emailRecipient,
+ importTag
+ }) {
+ const siteUrl = new URL(urlUtils.urlFor('home', null, true));
+ const postsUrl = new URL('posts', urlUtils.urlFor('admin', null, true));
+ if (importTag && result?.data?.tags) {
+ const tag = result.data.tags.find(t => t.name === importTag);
+ postsUrl.searchParams.set('tag', tag.slug);
+ }
+
+ return emailTemplate({
+ result,
+ siteUrl,
+ postsUrl,
+ emailRecipient
+ });
+ }
+
/**
* Import From File
* The main method of the ImportManager, call this to kick everything off!
* @param {File} file
* @param {ImportOptions} importOptions to allow override of certain import features such as locking a user
- * @returns {Promise}
+ * @returns {Promise>}
*/
async importFromFile(file, importOptions = {}) {
+ if (!importOptions.forceInline && !importOptions.runningInJob) {
+ return jobManager.addJob({
+ job: () => this.importFromFile(file, Object.assign({}, importOptions, {
+ runningInJob: true
+ })),
+ offloaded: false
+ });
+ }
+
+ let importResult;
try {
// Step 1: Handle converting the file to usable data
let importData = await this.loadFile(file);
// Step 2: Let the importers pre-process the data
importData = await this.preProcess(importData);
-
+
// Step 3: Actually do the import
// @TODO: It would be cool to have some sort of dry run flag here
- let importResult = await this.doImport(importData, importOptions);
-
+ importResult = await this.doImport(importData, importOptions);
+
// Step 4: Report on the import
- return await this.generateReport(importResult);
+ importResult = await this.generateReport(importResult);
+
+ return importResult;
} finally {
// Step 5: Cleanup any files
- this.cleanUp();
+ await this.cleanUp();
+
+ if (!importOptions.forceInline) {
+ // Step 6: Send email
+ const email = this.generateCompletionEmail(importResult.data, {
+ emailRecipient: importOptions.user.email,
+ importTag: importOptions.importTag
+ });
+ await ghostMailer.send({
+ to: importOptions.user.email,
+ subject: 'Imported content successfully',
+ html: email
+ });
+ }
}
}
}
/**
* @typedef {object} ImportOptions
+ * @property {boolean} [forceInline]
+ * @property {boolean} [runningInJob]
* @property {boolean} [returnImportedData]
* @property {boolean} [importPersistUser]
+ * @property {Object} [user]
+ * @property {string} [user.email]
+ * @property {string} [importTag]
*/
/**