mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-20 22:42:53 -05: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
9 changed files with 267 additions and 38 deletions
|
@ -647,8 +647,14 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
|
||||||
this.processOptions(options);
|
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) {
|
return itemCollection.fetchAll(options).then(function then(result) {
|
||||||
if (options.withRelated) {
|
if (options.withRelated) {
|
||||||
_.each(result.models, function each(item) {
|
_.each(result.models, function each(item) {
|
||||||
|
|
|
@ -33,7 +33,8 @@ models = [
|
||||||
'invite',
|
'invite',
|
||||||
'webhook',
|
'webhook',
|
||||||
'integration',
|
'integration',
|
||||||
'api-key'
|
'api-key',
|
||||||
|
'mobiledoc-revision'
|
||||||
];
|
];
|
||||||
|
|
||||||
function init() {
|
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
|
// # Post Model
|
||||||
var _ = require('lodash'),
|
const _ = require('lodash');
|
||||||
uuid = require('uuid'),
|
const uuid = require('uuid');
|
||||||
moment = require('moment'),
|
const moment = require('moment');
|
||||||
Promise = require('bluebird'),
|
const Promise = require('bluebird');
|
||||||
sequence = require('../lib/promise/sequence'),
|
const sequence = require('../lib/promise/sequence');
|
||||||
common = require('../lib/common'),
|
const common = require('../lib/common');
|
||||||
htmlToText = require('html-to-text'),
|
const htmlToText = require('html-to-text');
|
||||||
ghostBookshelf = require('./base'),
|
const ghostBookshelf = require('./base');
|
||||||
config = require('../config'),
|
const config = require('../config');
|
||||||
converters = require('../lib/mobiledoc/converters'),
|
const converters = require('../lib/mobiledoc/converters');
|
||||||
relations = require('./relations'),
|
const relations = require('./relations');
|
||||||
Post,
|
const MOBILEDOC_REVISIONS_COUNT = 10;
|
||||||
Posts;
|
|
||||||
|
let Post;
|
||||||
|
let Posts;
|
||||||
|
|
||||||
Post = ghostBookshelf.Model.extend({
|
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
|
// NOTE: look up object, not super nice, but was easy to implement
|
||||||
relationshipBelongsTo: {
|
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);
|
return sequence(ops);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -385,6 +432,11 @@ Post = ghostBookshelf.Model.extend({
|
||||||
fields: function fields() {
|
fields: function fields() {
|
||||||
return this.morphMany('AppField', 'relatable');
|
return this.morphMany('AppField', 'relatable');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mobiledoc_revisions() {
|
||||||
|
return this.hasMany('MobiledocRevision', 'post_id');
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @NOTE:
|
* @NOTE:
|
||||||
* If you are requesting models with `columns`, you try to only receive some fields of the model/s.
|
* 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);
|
attrs = this.formatsToJSON(attrs, options);
|
||||||
|
|
||||||
|
// CASE: never expose the revisions
|
||||||
|
delete attrs.mobiledoc_revisions;
|
||||||
|
|
||||||
// If the current column settings allow it...
|
// If the current column settings allow it...
|
||||||
if (!options.columns || (options.columns && options.columns.indexOf('primary_tag') > -1)) {
|
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
|
// ... 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
|
// Post
|
||||||
should.exist(result.posts);
|
should.exist(result.posts);
|
||||||
result.posts.length.should.eql(7);
|
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
|
// Tag
|
||||||
should.exist(result.tags);
|
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 () {
|
describe('Multiauthor Posts', function () {
|
||||||
before(testUtils.teardown);
|
before(testUtils.teardown);
|
||||||
|
|
||||||
|
|
|
@ -253,27 +253,23 @@ describe('Unit: models/post', function () {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Unit: models/post: uses database (@TODO: fix me)', function () {
|
describe('toJSON', function () {
|
||||||
before(function () {
|
const toJSON = function toJSON(model, options) {
|
||||||
models.init();
|
return new models.Post(model).toJSON(options);
|
||||||
});
|
};
|
||||||
|
|
||||||
before(testUtils.teardown);
|
it('ensure mobiledoc revisions are never exposed', function () {
|
||||||
before(testUtils.setup('users:roles', 'posts'));
|
const post = {
|
||||||
|
mobiledoc: 'test',
|
||||||
|
mobiledoc_revisions: [],
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(function () {
|
const json = toJSON(post, {formats: ['mobiledoc']});
|
||||||
sandbox.stub(security.password, 'hash').resolves('$2a$10$we16f8rpbrFZ34xWj0/ZC.LTPUux8ler7bcdTs5qIleN6srRHhilG');
|
|
||||||
sandbox.stub(urlService, 'getUrlByResourceId');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function () {
|
should.not.exist(json.mobiledoc_revisions);
|
||||||
sandbox.restore();
|
should.exist(json.mobiledoc);
|
||||||
});
|
});
|
||||||
|
|
||||||
after(function () {
|
|
||||||
sandbox.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('processOptions', function () {
|
describe('processOptions', function () {
|
||||||
|
@ -364,6 +360,28 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () {
|
||||||
filter.should.equal('page:false+status:published');
|
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('add', function () {
|
||||||
describe('ensure full set of data for model events', function () {
|
describe('ensure full set of data for model events', function () {
|
||||||
|
|
|
@ -55,7 +55,7 @@
|
||||||
"express-session": "1.15.6",
|
"express-session": "1.15.6",
|
||||||
"extract-zip": "1.6.7",
|
"extract-zip": "1.6.7",
|
||||||
"fs-extra": "3.0.1",
|
"fs-extra": "3.0.1",
|
||||||
"ghost-gql": "0.0.10",
|
"ghost-gql": "0.0.11",
|
||||||
"ghost-ignition": "2.9.6",
|
"ghost-ignition": "2.9.6",
|
||||||
"ghost-storage-base": "0.0.3",
|
"ghost-storage-base": "0.0.3",
|
||||||
"glob": "5.0.15",
|
"glob": "5.0.15",
|
||||||
|
|
|
@ -2200,9 +2200,9 @@ getsetdeep@~2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
typechecker "~2.0.1"
|
typechecker "~2.0.1"
|
||||||
|
|
||||||
ghost-gql@0.0.10:
|
ghost-gql@0.0.11:
|
||||||
version "0.0.10"
|
version "0.0.11"
|
||||||
resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.10.tgz#cd546ed77ee8f135a520d5d0463bbe4f01c24a10"
|
resolved "https://registry.yarnpkg.com/ghost-gql/-/ghost-gql-0.0.11.tgz#f0bc85305d0be80e5131011bf48b0ffd7c058b6b"
|
||||||
dependencies:
|
dependencies:
|
||||||
lodash "^4.17.4"
|
lodash "^4.17.4"
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue