mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-09-11 12:55:37 +03:00
Added mobiledoc revisions functionality
closes #9927 - Added post model implementation to be able to store up to 10 versions of mobiledoc - Bumped GQL to support filtering on the mobiledoc revision table - Added tests ensuring new functionality works
This commit is contained in:
parent
1b9aa2546f
commit
a7b0029471
@ -647,8 +647,14 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
|
||||
this.processOptions(options);
|
||||
}
|
||||
|
||||
itemCollection.applyDefaultAndCustomFilters(options);
|
||||
// @TODO: we can't use order raw when running migrations (see https://github.com/tgriesser/knex/issues/2763)
|
||||
if (this.orderDefaultRaw && !options.migrating) {
|
||||
itemCollection.query((qb) => {
|
||||
qb.orderByRaw(this.orderDefaultRaw());
|
||||
});
|
||||
}
|
||||
|
||||
itemCollection.applyDefaultAndCustomFilters(options);
|
||||
return itemCollection.fetchAll(options).then(function then(result) {
|
||||
if (options.withRelated) {
|
||||
_.each(result.models, function each(item) {
|
||||
|
@ -33,7 +33,8 @@ models = [
|
||||
'invite',
|
||||
'webhook',
|
||||
'integration',
|
||||
'api-key'
|
||||
'api-key',
|
||||
'mobiledoc-revision'
|
||||
];
|
||||
|
||||
function init() {
|
||||
|
35
core/server/models/mobiledoc-revision.js
Normal file
35
core/server/models/mobiledoc-revision.js
Normal file
@ -0,0 +1,35 @@
|
||||
const ghostBookshelf = require('./base');
|
||||
|
||||
const MobiledocRevision = ghostBookshelf.Model.extend({
|
||||
tableName: 'mobiledoc_revisions'
|
||||
}, {
|
||||
permittedOptions(methodName) {
|
||||
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
|
||||
const validOptions = {
|
||||
findAll: ['filter', 'columns']
|
||||
};
|
||||
|
||||
if (validOptions[methodName]) {
|
||||
options = options.concat(validOptions[methodName]);
|
||||
}
|
||||
|
||||
return options;
|
||||
},
|
||||
|
||||
orderDefaultRaw() {
|
||||
return 'created_at_ts DESC';
|
||||
},
|
||||
|
||||
toJSON(unfilteredOptions) {
|
||||
const options = MobiledocRevision.filterOptions(unfilteredOptions, 'toJSON');
|
||||
const attrs = ghostBookshelf.Model.prototype.toJSON.call(this, options);
|
||||
|
||||
// CASE: only for internal accuracy
|
||||
delete attrs.created_at_ts;
|
||||
return attrs;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
MobiledocRevision: ghostBookshelf.model('MobiledocRevision', MobiledocRevision)
|
||||
};
|
@ -1,17 +1,19 @@
|
||||
// # Post Model
|
||||
var _ = require('lodash'),
|
||||
uuid = require('uuid'),
|
||||
moment = require('moment'),
|
||||
Promise = require('bluebird'),
|
||||
sequence = require('../lib/promise/sequence'),
|
||||
common = require('../lib/common'),
|
||||
htmlToText = require('html-to-text'),
|
||||
ghostBookshelf = require('./base'),
|
||||
config = require('../config'),
|
||||
converters = require('../lib/mobiledoc/converters'),
|
||||
relations = require('./relations'),
|
||||
Post,
|
||||
Posts;
|
||||
const _ = require('lodash');
|
||||
const uuid = require('uuid');
|
||||
const moment = require('moment');
|
||||
const Promise = require('bluebird');
|
||||
const sequence = require('../lib/promise/sequence');
|
||||
const common = require('../lib/common');
|
||||
const htmlToText = require('html-to-text');
|
||||
const ghostBookshelf = require('./base');
|
||||
const config = require('../config');
|
||||
const converters = require('../lib/mobiledoc/converters');
|
||||
const relations = require('./relations');
|
||||
const MOBILEDOC_REVISIONS_COUNT = 10;
|
||||
|
||||
let Post;
|
||||
let Posts;
|
||||
|
||||
Post = ghostBookshelf.Model.extend({
|
||||
|
||||
@ -46,7 +48,7 @@ Post = ghostBookshelf.Model.extend({
|
||||
};
|
||||
},
|
||||
|
||||
relationships: ['tags', 'authors'],
|
||||
relationships: ['tags', 'authors', 'mobiledoc_revisions'],
|
||||
|
||||
// NOTE: look up object, not super nice, but was easy to implement
|
||||
relationshipBelongsTo: {
|
||||
@ -348,6 +350,51 @@ Post = ghostBookshelf.Model.extend({
|
||||
});
|
||||
}
|
||||
|
||||
// CASE: Handle mobiledoc backups/revisions. This is a pure database feature.
|
||||
if (model.hasChanged('mobiledoc') && !options.importing && !options.migrating) {
|
||||
ops.push(function updateRevisions() {
|
||||
return ghostBookshelf.model('MobiledocRevision')
|
||||
.findAll(Object.assign({
|
||||
filter: `post_id:${model.id}`,
|
||||
columns: ['id']
|
||||
}, _.pick(options, 'transacting')))
|
||||
.then((revisions) => {
|
||||
/**
|
||||
* Store prev + latest mobiledoc content, because we have decided against a migration, which
|
||||
* iterates over all posts and creates a copy of the current mobiledoc content.
|
||||
*
|
||||
* Reasons:
|
||||
* - usually migrations for the post table are slow and error-prone
|
||||
* - there is no need to create a copy for all posts now, because we only want to ensure
|
||||
* that posts, which you are currently working on, are getting a content backup
|
||||
* - no need to create revisions for existing published posts
|
||||
*
|
||||
* The feature is very minimal in the beginning. As soon as you update to this Ghost version,
|
||||
* you
|
||||
*/
|
||||
if (!revisions.length && options.method !== 'insert') {
|
||||
model.set('mobiledoc_revisions', [{
|
||||
post_id: model.id,
|
||||
mobiledoc: model.previous('mobiledoc'),
|
||||
created_at_ts: Date.now() - 1
|
||||
}, {
|
||||
post_id: model.id,
|
||||
mobiledoc: model.get('mobiledoc'),
|
||||
created_at_ts: Date.now()
|
||||
}]);
|
||||
} else {
|
||||
const revisionsJSON = revisions.toJSON().slice(0, MOBILEDOC_REVISIONS_COUNT - 1);
|
||||
|
||||
model.set('mobiledoc_revisions', revisionsJSON.concat([{
|
||||
post_id: model.id,
|
||||
mobiledoc: model.get('mobiledoc'),
|
||||
created_at_ts: Date.now()
|
||||
}]));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return sequence(ops);
|
||||
},
|
||||
|
||||
@ -385,6 +432,11 @@ Post = ghostBookshelf.Model.extend({
|
||||
fields: function fields() {
|
||||
return this.morphMany('AppField', 'relatable');
|
||||
},
|
||||
|
||||
mobiledoc_revisions() {
|
||||
return this.hasMany('MobiledocRevision', 'post_id');
|
||||
},
|
||||
|
||||
/**
|
||||
* @NOTE:
|
||||
* If you are requesting models with `columns`, you try to only receive some fields of the model/s.
|
||||
@ -441,6 +493,9 @@ Post = ghostBookshelf.Model.extend({
|
||||
|
||||
attrs = this.formatsToJSON(attrs, options);
|
||||
|
||||
// CASE: never expose the revisions
|
||||
delete attrs.mobiledoc_revisions;
|
||||
|
||||
// If the current column settings allow it...
|
||||
if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
|
||||
// ... attach a computed property of primary_tag which is the first tag if it is public, else null
|
||||
|
@ -218,7 +218,8 @@ describe('Database Migration (special functions)', function () {
|
||||
// Post
|
||||
should.exist(result.posts);
|
||||
result.posts.length.should.eql(7);
|
||||
result.posts.at(0).get('title').should.eql('Creating a custom theme');
|
||||
result.posts.at(0).get('title').should.eql('Welcome to Ghost');
|
||||
result.posts.at(6).get('title').should.eql('Creating a custom theme');
|
||||
|
||||
// Tag
|
||||
should.exist(result.tags);
|
||||
|
@ -1683,6 +1683,119 @@ describe('Post Model', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('mobiledoc versioning', function () {
|
||||
it('can create revisions', function () {
|
||||
const newPost = {
|
||||
mobiledoc: markdownToMobiledoc('a')
|
||||
};
|
||||
|
||||
return models.Post.add(newPost, context)
|
||||
.then((createdPost) => {
|
||||
return models.Post.findOne({id: createdPost.id, status: 'all'});
|
||||
})
|
||||
.then((createdPost) => {
|
||||
should.exist(createdPost);
|
||||
|
||||
return createdPost.save({mobiledoc: markdownToMobiledoc('b')}, context);
|
||||
})
|
||||
.then((updatedPost) => {
|
||||
updatedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b'));
|
||||
|
||||
return models.MobiledocRevision
|
||||
.findAll({
|
||||
filter: `post_id:${updatedPost.id}`,
|
||||
});
|
||||
})
|
||||
.then((mobiledocRevisions) => {
|
||||
should.equal(mobiledocRevisions.length, 2);
|
||||
|
||||
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b'));
|
||||
mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a'));
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps only 10 last revisions in FIFO style', function () {
|
||||
let revisionedPost;
|
||||
const newPost = {
|
||||
mobiledoc: markdownToMobiledoc('revision: 0')
|
||||
};
|
||||
|
||||
return models.Post.add(newPost, context)
|
||||
.then((createdPost) => {
|
||||
return models.Post.findOne({id: createdPost.id, status: 'all'});
|
||||
})
|
||||
.then((createdPost) => {
|
||||
should.exist(createdPost);
|
||||
revisionedPost = createdPost;
|
||||
|
||||
return sequence(_.times(11, (i) => {
|
||||
return () => {
|
||||
return models.Post.edit({
|
||||
mobiledoc: markdownToMobiledoc('revision: ' + (i + 1))
|
||||
}, _.extend({}, context, {id: createdPost.id}));
|
||||
};
|
||||
}));
|
||||
})
|
||||
.then(() => models.MobiledocRevision
|
||||
.findAll({
|
||||
filter: `post_id:${revisionedPost.id}`,
|
||||
})
|
||||
)
|
||||
.then((mobiledocRevisions) => {
|
||||
should.equal(mobiledocRevisions.length, 10);
|
||||
|
||||
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('revision: 11'));
|
||||
mobiledocRevisions.toJSON()[9].mobiledoc.should.equal(markdownToMobiledoc('revision: 2'));
|
||||
});
|
||||
});
|
||||
|
||||
it('creates 2 revisions after first edit for previously unversioned post', function () {
|
||||
let unversionedPost;
|
||||
|
||||
const newPost = {
|
||||
title: 'post title',
|
||||
mobiledoc: markdownToMobiledoc('a')
|
||||
};
|
||||
|
||||
// passing 'migrating' flag to simulate unversioned post
|
||||
const options = Object.assign(_.clone(context), {migrating: true});
|
||||
|
||||
return models.Post.add(newPost, options)
|
||||
.then((createdPost) => {
|
||||
should.exist(createdPost);
|
||||
unversionedPost = createdPost;
|
||||
createdPost.get('mobiledoc').should.equal(markdownToMobiledoc('a'));
|
||||
|
||||
return models.MobiledocRevision
|
||||
.findAll({
|
||||
filter: `post_id:${createdPost.id}`,
|
||||
});
|
||||
})
|
||||
.then((mobiledocRevisions) => {
|
||||
should.equal(mobiledocRevisions.length, 0);
|
||||
|
||||
return models.Post.edit({
|
||||
mobiledoc: markdownToMobiledoc('b')
|
||||
}, _.extend({}, context, {id: unversionedPost.id}));
|
||||
})
|
||||
.then((editedPost) => {
|
||||
should.exist(editedPost);
|
||||
editedPost.get('mobiledoc').should.equal(markdownToMobiledoc('b'));
|
||||
|
||||
return models.MobiledocRevision
|
||||
.findAll({
|
||||
filter: `post_id:${editedPost.id}`,
|
||||
});
|
||||
})
|
||||
.then((mobiledocRevisions) => {
|
||||
should.equal(mobiledocRevisions.length, 2);
|
||||
|
||||
mobiledocRevisions.toJSON()[0].mobiledoc.should.equal(markdownToMobiledoc('b'));
|
||||
mobiledocRevisions.toJSON()[1].mobiledoc.should.equal(markdownToMobiledoc('a'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Multiauthor Posts', function () {
|
||||
before(testUtils.teardown);
|
||||
|
||||
|
@ -253,27 +253,23 @@ describe('Unit: models/post', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit: models/post: uses database (@TODO: fix me)', function () {
|
||||
before(function () {
|
||||
models.init();
|
||||
});
|
||||
describe('toJSON', function () {
|
||||
const toJSON = function toJSON(model, options) {
|
||||
return new models.Post(model).toJSON(options);
|
||||
};
|
||||
|
||||
before(testUtils.teardown);
|
||||
before(testUtils.setup('users:roles', 'posts'));
|
||||
it('ensure mobiledoc revisions are never exposed', function () {
|
||||
const post = {
|
||||
mobiledoc: 'test',
|
||||
mobiledoc_revisions: [],
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG');
|
||||
sandbox.stub(urlService, 'getUrlByResourceId');
|
||||
});
|
||||
const json = toJSON(post, {formats: ['mobiledoc']});
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
sandbox.restore();
|
||||
should.not.exist(json.mobiledoc_revisions);
|
||||
should.exist(json.mobiledoc);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processOptions', function () {
|
||||
@ -364,6 +360,28 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () {
|
||||
filter.should.equal('page:false+status:published');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unit: models/post: uses database (@TODO: fix me)', function () {
|
||||
before(function () {
|
||||
models.init();
|
||||
});
|
||||
|
||||
before(testUtils.teardown);
|
||||
before(testUtils.setup('users:roles', 'posts'));
|
||||
|
||||
beforeEach(function () {
|
||||
sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG');
|
||||
sandbox.stub(urlService, 'getUrlByResourceId');
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('add', function () {
|
||||
describe('ensure full set of data for model events', function () {
|
||||
|
@ -55,7 +55,7 @@
|
||||
"express-session": "1.15.6",
|
||||
"extract-zip": "1.6.7",
|
||||
"fs-extra": "3.0.1",
|
||||
"ghost-gql": "0.0.10",
|
||||
"ghost-gql": "0.0.11",
|
||||
"ghost-ignition": "2.9.6",
|
||||
"ghost-storage-base": "0.0.3",
|
||||
"glob": "5.0.15",
|
||||
|
@ -2200,9 +2200,9 @@ getsetdeep@~2.0.0:
|
||||
dependencies:
|
||||
typechecker "~2.0.1"
|
||||
|
||||
ghost-gql@0.0.10:
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.10.tgz#cd546ed77ee8f135a520d5d0463bbe4f01c24a10"
|
||||
ghost-gql@0.0.11:
|
||||
version "0.0.11"
|
||||
resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.11.tgz#f0bc85305d0be80e5131011bf48b0ffd7c058b6b"
|
||||
dependencies:
|
||||
lodash "^4.17.4"
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user