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, ` + + + + + + + `, '')} + ${iff(result.problems && result.problems.length > 0, ` + + + + + + + `, '')} + ${iff(result.data && result.data.posts && result.data.posts.length > 0, ` + + + + `, ``)} +
+

${iff(result.errors, `Your content import was unsuccessful`, `Your content import has finished`)}

+
+

Failed to import content with the following error${iff(result.errors && result.errors.length > 1), `s`, ``}:

+
+
    + ${_.each(result.errors, error => ` +
  • ${error.message}
  • + `)} +
+
+

Imported content successfully, but with the following warning${iff(result.problems && result.problems.length > 1), `s`, ``}:

+
+
    + ${_.each(result.problems, problem => ` +
  • ${problem.message}
  • + `)} +
+
+ 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] */ /**