TryGhost/Ghost

View on GitHub
ghost/post-revisions/src/PostRevisions.ts

Summary

Maintainability
B
4 hrs
Test Coverage
type PostLike = {
    id: string;
    lexical: string;
    html: string;
    author_id: string;
    feature_image: string | null;
    feature_image_alt: string | null;
    feature_image_caption: string | null;
    title: string;
    reason: string;
    post_status: string;
}

type Revision = {
    post_id: string;
    lexical: string;
    author_id: string;
    feature_image: string | null;
    feature_image_alt: string | null;
    feature_image_caption: string | null;
    title: string;
    post_status: string;
    reason: string;
    created_at_ts: number;
}

type PostRevisionsDeps = {
    config: {
        max_revisions: number;
        revision_interval_ms: number;
    },
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    model: any
}

type RevisionResult = {
    value: true;
    reason: string;
} | {
    value: false
}

export class PostRevisions {
    config: PostRevisionsDeps['config'];
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    model: any;

    constructor(deps: PostRevisionsDeps) {
        this.config = deps.config;
        this.model = deps.model;
    }

    shouldGenerateRevision(current: PostLike, revisions: Revision[], options?: { isPublished?: boolean; forceRevision?: boolean; newStatus?: string; olderStatus?: string; }): RevisionResult {
        const latestRevision = revisions[revisions.length - 1];
        // If there's no revisions for this post, we should always save a revision
        if (revisions.length === 0) {
            return {value: true, reason: 'initial_revision'};
        }
        // check if the post has been unpublished
        const isUnpublished = options && options.newStatus === 'draft' && options.olderStatus === 'published';
        if (isUnpublished) {
            return {value: true, reason: 'unpublished'};
        }
        // check if the post has been published
        const isPublished = options && options.isPublished;
        if (isPublished) {
            return {value: true, reason: 'published'};
        }

        const forceRevision = options && options.forceRevision;
        const featuredImagedHasChanged = latestRevision.feature_image !== current.feature_image;
        const lexicalHasChanged = latestRevision.lexical !== current.lexical;
        const titleHasChanged = latestRevision.title !== current.title;
        // CASE: we only want to save a revision if something has changed since the previous revision
        if (lexicalHasChanged || titleHasChanged || featuredImagedHasChanged) {
            // CASE: user has explicitly requested a revision by hitting cmd+s or leaving the editor
            if (forceRevision) {
                return {value: true, reason: 'explicit_save'};
            }
            // CASE: it's been X mins since the last revision, so we should save a new one
            if ((Date.now() - latestRevision.created_at_ts) > this.config.revision_interval_ms) {
                return {value: true, reason: 'background_save'};
            }
        }
        return {value: false};
    }

    async getRevisions(current: PostLike, revisions: Revision[], options?: { isPublished?: boolean; forceRevision?: boolean; newStatus?: string; olderStatus?: string; }): Promise<Revision[]> {
        const shouldGenerateRevision = this.shouldGenerateRevision(current, revisions, options);
        if (!shouldGenerateRevision.value) {
            return revisions;
        }

        const currentRevision = this.convertPostLikeToRevision(current);
        currentRevision.reason = shouldGenerateRevision.reason;

        if (revisions.length === 0) {
            return [
                currentRevision
            ];
        }

        // Grab the most recent revisions, limited by max_revisions
        const updatedRevisions = [...revisions, currentRevision];
        if (updatedRevisions.length > this.config.max_revisions) {
            return updatedRevisions.slice(updatedRevisions.length - this.config.max_revisions, updatedRevisions.length);
        } else {
            return updatedRevisions;
        }
    }

    convertPostLikeToRevision(input: PostLike, offset = 0): Revision {
        return {
            post_id: input.id,
            lexical: input.lexical,
            created_at_ts: Date.now() - offset,
            author_id: input.author_id,
            feature_image: input.feature_image,
            feature_image_alt: input.feature_image_alt,
            feature_image_caption: input.feature_image_caption,
            title: input.title,
            reason: input.reason,
            post_status: input.post_status
        };
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async removeAuthorFromRevisions(authorId: string, options: any): Promise<void> {
        const revisions = await this.model.findAll({
            filter: `author_id:'${authorId}'`,
            columns: ['id'],
            transacting: options.transacting
        });

        const revisionIds = revisions.toJSON()
            .map(({id}: {id: string}) => id);

        if (revisionIds.length === 0) {
            return;
        }

        await this.model.bulkEdit(revisionIds, 'post_revisions', {
            data: {
                author_id: null
            },
            column: 'id',
            transacting: options.transacting,
            throwErrors: true
        });
    }
}