0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-06 22:40:14 -05:00

Added posts restore UI (#21096)

ref https://app.incident.io/ghost/incidents/107
ref cc88757e2a
- added new path in admin `/restore`
- added basic ui for restoring posts from local storage
- added limits for # of revisions for posts with an `id` (5 revisions)

This commit adds a simple UI for restoring posts in case of data loss.
This is a backstop for very rare situations in which it seems Ember gets
into a conflicted state. See ref'd commit for more info. Clicking
'Restore' will create a new post with the saved off content.
This commit is contained in:
Steve Larson 2024-09-24 10:01:17 -05:00 committed by GitHub
parent 45211b2f4c
commit c121149ca3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 214 additions and 8 deletions

View file

@ -0,0 +1,22 @@
import Controller from '@ember/controller';
import {inject as service} from '@ember/service';
import {task} from 'ember-concurrency';
export default class RestorePostsController extends Controller {
@service localRevisions;
@service notifications;
@task
*restorePostTask(revision) {
try {
yield this.localRevisions.restore(revision.key);
this.notifications.showNotification('Post restored successfully', {type: 'success'});
return true;
} catch (error) {
this.notifications.showNotification('Failed to restore post', {type: 'error'});
// eslint-disable-next-line no-console
console.error('Failed to restore post:', error);
return false;
}
}
}

View file

@ -33,6 +33,8 @@ Router.map(function () {
this.route('posts.analytics', {path: '/posts/analytics/:post_id'});
this.route('posts.mentions', {path: '/posts/analytics/:post_id/mentions'});
this.route('posts.debug', {path: '/posts/analytics/:post_id/debug'});
this.route('restore-posts', {path: '/restore'});
this.route('pages');

View file

@ -0,0 +1,10 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import {inject as service} from '@ember/service';
export default class RevisionsRoute extends AuthenticatedRoute {
@service localRevisions;
model() {
return this.localRevisions.findAll();
}
}

View file

@ -72,6 +72,10 @@ export default class LocalRevisionsService extends Service {
allKeys.push(key);
localStorage.setItem(this._indexKey, JSON.stringify(allKeys));
localStorage.setItem(key, JSON.stringify(data));
// Apply the filter after saving
this.filterRevisions(data.id);
return key;
} catch (err) {
if (err.name === 'QuotaExceededError') {
@ -108,16 +112,23 @@ export default class LocalRevisionsService extends Service {
}
/**
* Returns all revisions from localStorage, optionally filtered by key prefix
* Returns all revisions from localStorage as an array, optionally filtered by key prefix and ordered by timestamp
* @param {string | undefined} prefix - optional prefix to filter revision keys
* @returns
* @returns {Array} - all revisions matching the prefix, ordered by timestamp (newest first)
*/
findAll(prefix = undefined) {
findAll(prefix = this._prefix) {
const keys = this.keys(prefix);
const revisions = {};
for (const key of keys) {
revisions[key] = JSON.parse(localStorage.getItem(key));
}
const revisions = keys.map((key) => {
const revision = JSON.parse(localStorage.getItem(key));
return {
key,
...revision
};
});
// Sort revisions by timestamp, newest first
revisions.sort((a, b) => b.revisionTimestamp - a.revisionTimestamp);
return revisions;
}
@ -243,4 +254,22 @@ export default class LocalRevisionsService extends Service {
console.warn(err);
}
}
/**
* Filters revisions to keep only the most recent 5 for a given post ID
* @param {string} postId - ID of the post to filter revisions for
*/
filterRevisions(postId) {
if (postId === 'draft') {
return; // Ignore filter for drafts
}
const allRevisions = this.findAll(`${this._prefix}-${postId}`);
if (allRevisions.length > 5) {
const revisionsToRemove = allRevisions.slice(5);
revisionsToRemove.forEach((revision) => {
this.remove(revision.key);
});
}
}
}

View file

@ -0,0 +1,50 @@
<section class="gh-canvas gh-post-restore" {{did-insert this.loadData}}>
<GhCanvasHeader class="gh-canvas-header gh-post-restore-header">
<div class="flex flex-column flex-grow-1">
<h2 class="gh-canvas-title">
Restore Posts
</h2>
</div>
</GhCanvasHeader>
<section class="view-container content-list">
<p>Posts are regularly saved locally on your device. If you've lost a post, you can restore it from here as long as too much time hasn't passed.</p>
<ol class="revisions-list gh-list {{unless this.model.length "no-revisions"}}">
{{#if this.model.length}}
<li class="gh-list-row header">
<div class="gh-list-header gh-list-cellwidth-70">Title</div>
<div class="gh-list-header gh-list-cellwidth-20">Created</div>
<div class="gh-list-header gh-list-cellwidth-10"></div>
</li>
<VerticalCollection @items={{this.model}} @key="key" @containerSelector=".gh-main" @estimateHeight={{60}} @bufferSize={{20}} as |revision|>
<li class="gh-list-row">
<div class="gh-list-data gh-list-cellwidth-70">
<h3 class="gh-revision-title" data-test-id="restore-post-title">{{if revision.title revision.title "(no title)"}}</h3>
<p class="gh-revision-excerpt">{{truncate revision.excerpt 100}}</p>
</div>
<div class="gh-list-data gh-list-cellwidth-20">
{{moment-format revision.revisionTimestamp "MMM D, YYYY HH:mm"}}
</div>
<div class="gh-list-data gh-list-cellwidth-10">
<GhTaskButton
@task={{this.restorePostTask}}
@taskArgs={{revision}}
@buttonText="Restore"
@showSuccess={{false}}
@showIcon={{false}}
@class="gh-btn gh-btn-icon gh-btn-sm"
data-test-id="restore-post-button"
/>
</div>
</li>
</VerticalCollection>
{{else}}
<li class="no-revisions-box">
<div class="no-revisions">
{{svg-jar "revision-placeholder" class="gh-revisions-placeholder"}}
<h4>No local revisions found.</h4>
</div>
</li>
{{/if}}
</ol>
</section>
</section>

View file

@ -0,0 +1,49 @@
import {authenticateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {click} from '@ember/test-helpers';
import {expect} from 'chai';
import {find, visit} from '@ember/test-helpers';
import {setupApplicationTest} from 'ember-mocha';
import {setupMirage} from 'ember-cli-mirage/test-support';
describe('Acceptance: Restore', function () {
let hooks = setupApplicationTest();
setupMirage(hooks);
beforeEach(async function () {
// Create a user and authenticate the session
let role = this.server.create('role', {name: 'Owner'});
this.server.create('user', {roles: [role], slug: 'owner'});
await authenticateSession();
});
it('restores a post from a revision', async function () {
// Create a post revision in localStorage
const revisionData = {
id: 'test-id',
title: 'Test Post',
lexical: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Test content","type":"extended-text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}',
revisionTimestamp: Date.now()
};
const revisionKey = `post-revision-${revisionData.id}-${revisionData.revisionTimestamp}`;
localStorage.setItem(revisionKey, JSON.stringify(revisionData));
localStorage.setItem('ghost-revisions', JSON.stringify([revisionKey]));
// Visit the restore route
await visit(`/restore/`);
// Verify that the post title is displayed
const postTitle = find('[data-test-id="restore-post-title"]').textContent.trim();
expect(postTitle).to.equal('Test Post');
// Verify that the restore button is present
const restoreButton = find('[data-test-id="restore-post-button"]');
expect(restoreButton).to.exist;
// Click the restore button
await click(restoreButton);
// Verify that the post is restored (notification will show)
expect(find('.gh-notification-title').textContent.trim()).to.equal('Post restored successfully');
});
});

View file

@ -108,6 +108,50 @@ describe('Unit: Service: local-revisions', function () {
// Ensure the latest revision saved
expect(this.service.find(keyToAdd)).to.not.be.null;
});
it('keeps only the latest 5 revisions for a given post ID', async function () {
const postId = 'test-id';
const revisionCount = 7;
// Save 7 revisions for the same post ID
for (let i = 0; i < revisionCount; i++) {
await sleep(1); // Ensure unique timestamps
this.service.performSave('post', {id: postId, lexical: `test-${i}`});
}
// Get all revisions for the post ID
const revisions = this.service.findAll(`post-revision-${postId}`);
// Check that only 5 revisions are kept
expect(revisions).to.have.lengthOf(5);
// Check that the kept revisions are the latest ones
for (let i = 0; i < 5; i++) {
expect(revisions[i].lexical).to.equal(`test-${revisionCount - 1 - i}`);
}
});
it('does not limit revisions for drafts', async function () {
const postId = 'draft';
const revisionCount = 7;
// Save 7 revisions for a draft
for (let i = 0; i < revisionCount; i++) {
await sleep(1); // Ensure unique timestamps
this.service.performSave('post', {id: postId, lexical: `test-${i}`});
}
// Get all revisions for the draft
const revisions = this.service.findAll(`post-revision-${postId}`);
// Check that all 7 revisions are kept for the draft
expect(revisions).to.have.lengthOf(revisionCount);
// Check that all revisions are present
for (let i = 0; i < revisionCount; i++) {
expect(revisions[i].lexical).to.equal(`test-${revisionCount - 1 - i}`);
}
});
});
describe('scheduleSave', function () {
@ -175,7 +219,7 @@ describe('Unit: Service: local-revisions', function () {
it('returns an empty object if there are no revisions', function () {
const result = this.service.findAll();
expect(result).to.deep.equal({});
expect(result).to.deep.equal([]);
});
});