mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-03-18 02:21:47 -05:00
Added a job for content imports
refs: https://github.com/TryGhost/Toolbox/issues/431
This commit is contained in:
parent
9c12a2a043
commit
8ed5f9784d
4 changed files with 305 additions and 41 deletions
|
@ -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
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
194
ghost/core/core/server/data/importer/email-template.js
Normal file
194
ghost/core/core/server/data/importer/email-template.js
Normal file
|
@ -0,0 +1,194 @@
|
|||
const _ = require('lodash');
|
||||
|
||||
const iff = (cond, yes, no) => (cond ? yes : no);
|
||||
module.exports = ({result, siteUrl, postsUrl, emailRecipient}) => `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<title>Your member import is complete</title>
|
||||
<style>
|
||||
/* -------------------------------------
|
||||
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
||||
------------------------------------- */
|
||||
@media only screen and (max-width: 620px) {
|
||||
table[class=body] h1 {
|
||||
font-size: 28px !important;
|
||||
margin-bottom: 10px !important;
|
||||
}
|
||||
table[class=body] p,
|
||||
table[class=body] ul,
|
||||
table[class=body] ol,
|
||||
table[class=body] td,
|
||||
table[class=body] span,
|
||||
table[class=body] a {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
table[class=body] .title {
|
||||
font-size: 22px !important;
|
||||
}
|
||||
table[class=body] .wrapper,
|
||||
table[class=body] .article {
|
||||
padding: 10px !important;
|
||||
}
|
||||
table[class=body] .content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
table[class=body] .container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
table[class=body] .btn table {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .btn a {
|
||||
width: 100% !important;
|
||||
}
|
||||
table[class=body] .img-responsive {
|
||||
height: auto !important;
|
||||
max-width: 100% !important;
|
||||
width: auto !important;
|
||||
}
|
||||
table[class=body] p[class=small],
|
||||
table[class=body] a[class=small] {
|
||||
font-size: 12x !important;
|
||||
}
|
||||
}
|
||||
/* -------------------------------------
|
||||
PRESERVE THESE STYLES IN THE HEAD
|
||||
------------------------------------- */
|
||||
@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
.recipient-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
hr {
|
||||
border-width: 0;
|
||||
height: 0;
|
||||
margin-top: 34px;
|
||||
margin-bottom: 34px;
|
||||
border-bottom-width: 1px;
|
||||
border-bottom-color: #EEF5F8;
|
||||
}
|
||||
a {
|
||||
color: #3A464C;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="" style="background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;"> </td>
|
||||
<td class="container" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;">
|
||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
|
||||
|
||||
<!-- START CENTERED CONTAINER -->
|
||||
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">Your member import is complete</span>
|
||||
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
|
||||
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
<tr>
|
||||
<td class="wrapper" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; box-sizing: border-box;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 20px; padding-bottom: 12px;"><img src="https://static.ghost.org/v4.0.0/images/ghost-orb-4.png" width="60" height="60" style="width: 60px; height: 60px;" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top;">
|
||||
<p class="title" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 21px; color: #3A464C; font-weight: normal; line-height: 25px; margin-bottom: 30px; margin-top: 50px; font-weight: 600; color: #15212A;">${iff(result.errors, `Your content import was unsuccessful`, `Your content import has finished`)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
${iff(result.errors, `
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 16px;">
|
||||
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">Failed to import content with the following error${iff(result.errors && result.errors.length > 1), `s`, ``}:</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 16px;">
|
||||
<ul>
|
||||
${_.each(result.errors, error => `
|
||||
<li style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">${error.message}</li>
|
||||
`)}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
`, '')}
|
||||
${iff(result.problems && result.problems.length > 0, `
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 16px;">
|
||||
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">Imported content successfully, but with the following warning${iff(result.problems && result.problems.length > 1), `s`, ``}:</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 16px;">
|
||||
<ul>
|
||||
${_.each(result.problems, problem => `
|
||||
<li style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 0px;">${problem.message}</li>
|
||||
`)}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
`, '')}
|
||||
${iff(result.data && result.data.posts && result.data.posts.length > 0, `
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top; padding-bottom: 12px; padding-top: 16px;">
|
||||
<a href="${postsUrl.href}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #15212A; border: solid 1px #15212A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #15212A;">View imported posts</a>
|
||||
</td>
|
||||
</tr>
|
||||
`, ``)}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; padding-top: 80px; padding-bottom: 10px;">
|
||||
<div class="footer">
|
||||
<p class="small" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; color: #738A94; font-weight: normal; margin: 0; line-height: 18px; margin-bottom: 0px; font-size: 11px;">This email was sent from <a href="${siteUrl.href}" style="color: #738A94;">${siteUrl.host}</a> to <a href="mailto:${emailRecipient}" style="color: #738A94;">${emailRecipient}</a></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
|
||||
<!-- END CENTERED CONTAINER -->
|
||||
</div>
|
||||
</td>
|
||||
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
|
@ -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<void>}
|
||||
*/
|
||||
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<ImportResult[]>} importResults
|
||||
* @returns {Promise<Object.<string, ImportResult>>} 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<ImportResult[]>} importResults
|
||||
* @param {Object.<string, ImportResult>} importResults
|
||||
* @returns {Promise<Object.<string, ImportResult>>} 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<void>}
|
||||
*/
|
||||
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<ImportResult[]>}
|
||||
* @returns {Promise<Object.<string, ImportResult>>}
|
||||
*/
|
||||
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]
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Reference in a new issue