From c121149ca37ae28b160e2475467c24dcb6f081bf Mon Sep 17 00:00:00 2001 From: Steve Larson <9larsons@gmail.com> Date: Tue, 24 Sep 2024 10:01:17 -0500 Subject: [PATCH] Added posts restore UI (#21096) ref https://app.incident.io/ghost/incidents/107 ref cc88757e2adec39b0a62bb68b2d5b067245cd971 - 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. --- ghost/admin/app/controllers/restore-posts.js | 22 ++++++++ ghost/admin/app/router.js | 2 + ghost/admin/app/routes/restore-posts.js | 10 ++++ ghost/admin/app/services/local-revisions.js | 43 +++++++++++++--- ghost/admin/app/templates/restore-posts.hbs | 50 +++++++++++++++++++ .../tests/acceptance/restore-post-test.js | 49 ++++++++++++++++++ .../unit/services/local-revisions-test.js | 46 ++++++++++++++++- 7 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 ghost/admin/app/controllers/restore-posts.js create mode 100644 ghost/admin/app/routes/restore-posts.js create mode 100644 ghost/admin/app/templates/restore-posts.hbs create mode 100644 ghost/admin/tests/acceptance/restore-post-test.js diff --git a/ghost/admin/app/controllers/restore-posts.js b/ghost/admin/app/controllers/restore-posts.js new file mode 100644 index 0000000000..48e539d4cb --- /dev/null +++ b/ghost/admin/app/controllers/restore-posts.js @@ -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; + } + } +} \ No newline at end of file diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 843a86b2e5..0a82fe0c87 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -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'); diff --git a/ghost/admin/app/routes/restore-posts.js b/ghost/admin/app/routes/restore-posts.js new file mode 100644 index 0000000000..649163ed5e --- /dev/null +++ b/ghost/admin/app/routes/restore-posts.js @@ -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(); + } +} \ No newline at end of file diff --git a/ghost/admin/app/services/local-revisions.js b/ghost/admin/app/services/local-revisions.js index 65088e8e94..5a06067916 100644 --- a/ghost/admin/app/services/local-revisions.js +++ b/ghost/admin/app/services/local-revisions.js @@ -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); + }); + } + } } \ No newline at end of file diff --git a/ghost/admin/app/templates/restore-posts.hbs b/ghost/admin/app/templates/restore-posts.hbs new file mode 100644 index 0000000000..f04cd2e620 --- /dev/null +++ b/ghost/admin/app/templates/restore-posts.hbs @@ -0,0 +1,50 @@ +
+ +
+

+ Restore Posts +

+
+
+
+

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.

+
    + {{#if this.model.length}} +
  1. +
    Title
    +
    Created
    +
    +
  2. + +
  3. +
    +

    {{if revision.title revision.title "(no title)"}}

    +

    {{truncate revision.excerpt 100}}

    +
    +
    + {{moment-format revision.revisionTimestamp "MMM D, YYYY HH:mm"}} +
    +
    + +
    +
  4. +
    + {{else}} +
  5. +
    + {{svg-jar "revision-placeholder" class="gh-revisions-placeholder"}} +

    No local revisions found.

    +
    +
  6. + {{/if}} +
+
+
\ No newline at end of file diff --git a/ghost/admin/tests/acceptance/restore-post-test.js b/ghost/admin/tests/acceptance/restore-post-test.js new file mode 100644 index 0000000000..26eb1ebeac --- /dev/null +++ b/ghost/admin/tests/acceptance/restore-post-test.js @@ -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'); + }); +}); \ No newline at end of file diff --git a/ghost/admin/tests/unit/services/local-revisions-test.js b/ghost/admin/tests/unit/services/local-revisions-test.js index 782d8c13e7..daa82ad889 100644 --- a/ghost/admin/tests/unit/services/local-revisions-test.js +++ b/ghost/admin/tests/unit/services/local-revisions-test.js @@ -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([]); }); });