Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added local revisions to the editor as backstop against data loss #21044

Merged
merged 18 commits into from
Sep 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
27edbe4
Added initial API of local revisions service — currently using in-mem…
cmraible Sep 18, 2024
652ea5f
added one minute restriction on saves
9larsons Sep 18, 2024
96f21d1
Added unit tests for local revisions class
cmraible Sep 18, 2024
169b994
Converted service to persist revisions in localStorage
cmraible Sep 18, 2024
7bf724c
Revert "Converted service to persist revisions in localStorage"
cmraible Sep 18, 2024
4694837
Removed one minute save restriction for now
cmraible Sep 19, 2024
c870489
Reimplemented localStorage for persistence
cmraible Sep 19, 2024
cefa449
fixup! Reimplemented localStorage for persistence
cmraible Sep 19, 2024
83133cb
Removed extra lexical field to reduce data size and added error handl…
cmraible Sep 19, 2024
a1f1f4a
Limited the fields saved in each revision to reduce data size
cmraible Sep 19, 2024
d7f41bf
Used the keys() method to make implementation more DRY
cmraible Sep 19, 2024
72ae194
Renamed get and getAll to find and findAll to avoid conflict with emb…
cmraible Sep 19, 2024
dbf881d
Added timestamp field to value for filtering
cmraible Sep 19, 2024
011ddc6
Added restore functionality
cmraible Sep 19, 2024
f1d8f84
Added eviction logic to remove oldest revision if the quota is hit
cmraible Sep 19, 2024
04fd87c
Added mechanism to reduce the number of saves
cmraible Sep 20, 2024
488781a
Added comments to local-revisions service
cmraible Sep 20, 2024
79bbed3
Added try/catch blocks to ensure saving a revision does not interfere…
cmraible Sep 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion ghost/admin/app/controllers/lexical-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export default class LexicalEditorController extends Controller {
@service session;
@service settings;
@service ui;
@service localRevisions;

@inject config;

Expand Down Expand Up @@ -306,8 +307,18 @@ export default class LexicalEditorController extends Controller {

@action
updateScratch(lexical) {
this.set('post.lexicalScratch', JSON.stringify(lexical));
const lexicalString = JSON.stringify(lexical);
this.set('post.lexicalScratch', lexicalString);

try {
// schedule a local revision save
if (this.post.status === 'draft') {
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), lexical: lexicalString});
}
} catch (err) {
// ignore errors
}

// save 3 seconds after last edit
this._autosaveTask.perform();
// force save at 60 seconds
Expand All @@ -322,6 +333,14 @@ export default class LexicalEditorController extends Controller {
@action
updateTitleScratch(title) {
this.set('post.titleScratch', title);
try {
// schedule a local revision save
if (this.post.status === 'draft') {
this.localRevisions.scheduleSave(this.post.displayName, {...this.post.serialize({includeId: true}), title: title});
}
} catch (err) {
// ignore errors
}
}

@action
Expand Down
246 changes: 246 additions & 0 deletions ghost/admin/app/services/local-revisions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import Service, {inject as service} from '@ember/service';
import config from 'ghost-admin/config/environment';
import {task, timeout} from 'ember-concurrency';

/**
* Service to manage local post revisions in localStorage
*/
export default class LocalRevisionsService extends Service {
constructor() {
super(...arguments);
if (this.isTesting === undefined) {
this.isTesting = config.environment === 'test';
}
this.MIN_REVISION_TIME = this.isTesting ? 50 : 60000; // 1 minute in ms
this.performSave = this.performSave.bind(this);
}

@service store;

// base key prefix to avoid collisions in localStorage
_prefix = 'post-revision';
latestRevisionTime = null;

// key to store a simple index of all revisions
_indexKey = 'ghost-revisions';

/**
*
* @param {object} data - serialized post data, must include id and revisionTimestamp
* @returns {string} - key to store the revision in localStorage
*/
generateKey(data) {
return `${this._prefix}-${data.id}-${data.revisionTimestamp}`;
}

/**
* Performs the save operations, either immediately or after a delay
*
* leepLatest ensures the latest changes will be saved
* @param {string} type - post or page
* @param {object} data - serialized post data
*/
@task({keepLatest: true})
*saveTask(type, data) {
const currentTime = Date.now();
if (!this.lastRevisionTime || currentTime - this.lastRevisionTime > this.MIN_REVISION_TIME) {
yield this.performSave(type, data);
this.lastRevisionTime = currentTime;
} else {
const waitTime = this.MIN_REVISION_TIME - (currentTime - this.lastRevisionTime);
yield timeout(waitTime);
yield this.performSave(type, data);
this.lastRevisionTime = Date.now();
}
}

/**
* Saves the revision to localStorage
*
* If localStorage is full, the oldest revision will be removed
* @param {string} type - post or page
* @param {object} data - serialized post data
* @returns {string | undefined} - key of the saved revision or undefined if it couldn't be saved
*/
performSave(type, data) {
data.id = data.id || 'draft';
data.type = type;
data.revisionTimestamp = Date.now();
const key = this.generateKey(data);
try {
const allKeys = this.keys();
allKeys.push(key);
localStorage.setItem(this._indexKey, JSON.stringify(allKeys));
localStorage.setItem(key, JSON.stringify(data));
return key;
} catch (err) {
if (err.name === 'QuotaExceededError') {
// Remove the current key in case it's already in the index
this.remove(key);

// If there are any revisions, remove the oldest one and try to save again
if (this.keys().length) {
this.removeOldest();
return this.performSave(type, data);
}
// LocalStorage is full and there are no revisions to remove
// We can't save the revision
}
}
}

/**
* Method to trigger the save task
* @param {string} type - post or page
* @param {object} data - serialized post data
*/
scheduleSave(type, data) {
this.saveTask.perform(type, data);
}

/**
* Returns the specified revision from localStorage, or null if it doesn't exist
* @param {string} key - key of the revision to find
* @returns {string | null}
*/
find(key) {
return JSON.parse(localStorage.getItem(key));
}

/**
* Returns all revisions from localStorage, optionally filtered by key prefix
* @param {string | undefined} prefix - optional prefix to filter revision keys
* @returns
*/
findAll(prefix = undefined) {
const keys = this.keys(prefix);
const revisions = {};
for (const key of keys) {
revisions[key] = JSON.parse(localStorage.getItem(key));
}
return revisions;
}

/**
* Removes the specified key from localStorage
* @param {string} key
*/
remove(key) {
localStorage.removeItem(key);
const keys = this.keys();
let index = keys.indexOf(key);
if (index !== -1) {
keys.splice(index, 1);
}
localStorage.setItem(this._indexKey, JSON.stringify(keys));
}

/**
* Finds the oldest revision and removes it from localStorage to clear up space
*/
removeOldest() {
const keys = this.keys();
const keysByTimestamp = keys.map(key => ({key, timestamp: this.find(key).revisionTimestamp}));
keysByTimestamp.sort((a, b) => a.timestamp - b.timestamp);
this.remove(keysByTimestamp[0].key);
}

/**
* Removes all revisions from localStorage
*/
clear() {
const keys = this.keys();
for (const key of keys) {
this.remove(key);
}
}

/**
* Returns all revision keys from localStorage, optionally filtered by key prefix
* @param {string | undefined} prefix
* @returns {string[]}
*/
keys(prefix = undefined) {
let keys = JSON.parse(localStorage.getItem(this._indexKey) || '[]');
if (prefix) {
keys = keys.filter(key => key.startsWith(prefix));
}
return keys;
}

/**
* Logs all revisions to the console
*
* Currently this is the only UI for local revisions
*/
list() {
const revisions = this.findAll();
const data = {};
for (const [key, revision] of Object.entries(revisions)) {
if (!data[revision.title]) {
data[revision.title] = [];
}
data[revision.title].push({
key,
timestamp: revision.revisionTimestamp,
time: new Date(revision.revisionTimestamp).toLocaleString(),
title: revision.title,
type: revision.type,
id: revision.id
});
}
/* eslint-disable no-console */
console.groupCollapsed('Local revisions');
for (const [title, row] of Object.entries(data)) {
// eslint-disable-next-line no-console
console.groupCollapsed(`${title}`);
for (const item of row.sort((a, b) => b.timestamp - a.timestamp)) {
// eslint-disable-next-line no-console
console.groupCollapsed(`${item.time}`);
console.log('Revision ID: ', item.key);
console.groupEnd();
}
console.groupEnd();
}
console.groupEnd();
/* eslint-enable no-console */
}

/**
* Creates a new post from the specified revision
*
* @param {string} key
* @returns {Promise} - the new post model
*/
async restore(key) {
try {
const revision = this.find(key);
let authors = [];
if (revision.authors) {
for (const author of revision.authors) {
const authorModel = await this.store.queryRecord('user', {id: author.id});
authors.push(authorModel);
}
}
let post = this.store.createRecord('post', {
title: `(Restored) ${revision.title}`,
lexical: revision.lexical,
authors,
type: revision.type,
slug: revision.slug || 'untitled',
status: 'draft',
tags: revision.tags || [],
post_revisions: []
});
await post.save();
const location = window.location;
const url = `${location.origin}${location.pathname}#/editor/${post.get('type')}/${post.id}`;
// eslint-disable-next-line no-console
console.log('Post restored: ', url);
return post;
} catch (err) {
// eslint-disable-next-line no-console
console.warn(err);
}
}
}
59 changes: 59 additions & 0 deletions ghost/admin/tests/integration/services/local-revisions-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Pretender from 'pretender';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';

function stubCreatePostEndpoint(server) {
server.post(`${ghostPaths().apiRoot}/posts/`, function () {
return [
201,
{'Content-Type': 'application/json'},
JSON.stringify({posts: [{
id: 'test id',
lexical: 'test lexical string',
title: 'test title',
post_revisions: []
}]})
];
});

server.get(`${ghostPaths().apiRoot}/users/`, function () {
return [
200,
{'Content-Type': 'application/json'},
JSON.stringify({users: [{
id: '1',
name: 'test name',
roles: ['owner']
}]})
];
});
}

describe('Integration: Service: local-revisions', function () {
setupTest();

let server;

beforeEach(function () {
server = new Pretender();
this.service = this.owner.lookup('service:local-revisions');
this.service.clear();
});

afterEach(function () {
server.shutdown();
});

it('restores a post from a revision', async function () {
stubCreatePostEndpoint(server);
// create a post to restore
const key = this.service.performSave('post', {id: 'test-id', authors: [{id: '1'}], lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"\\"{\\\\\\"root\\\\\\":{\\\\\\"children\\\\\\":[{\\\\\\"children\\\\\\":[{\\\\\\"detail\\\\\\":0,\\\\\\"format\\\\\\":0,\\\\\\"mode\\\\\\":\\\\\\"normal\\\\\\",\\\\\\"style\\\\\\":\\\\\\"\\\\\\",\\\\\\"text\\\\\\":\\\\\\"T\\\\\\",\\\\\\"type\\\\\\":\\\\\\"extended-text\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"paragraph\\\\\\",\\\\\\"version\\\\\\":1}],\\\\\\"direction\\\\\\":\\\\\\"ltr\\\\\\",\\\\\\"format\\\\\\":\\\\\\"\\\\\\",\\\\\\"indent\\\\\\":0,\\\\\\"type\\\\\\":\\\\\\"root\\\\\\",\\\\\\"version\\\\\\":1}}\\"","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}'});

// restore the post
const post = await this.service.restore(key);

expect(post.get('lexical')).to.equal('test lexical string');
});
});
Loading
Loading