import * as Sentry from '@sentry/ember'; import ConfirmEditorLeaveModal from '../components/modals/editor/confirm-leave'; import Controller, {inject as controller} from '@ember/controller'; import DeletePostModal from '../components/modals/delete-post'; import DeleteSnippetModal from '../components/editor/modals/delete-snippet'; import PostModel from 'ghost-admin/models/post'; import PublishLimitModal from '../components/modals/limits/publish-limit'; import ReAuthenticateModal from '../components/editor/modals/re-authenticate'; import UpdateSnippetModal from '../components/editor/modals/update-snippet'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; import classic from 'ember-classic-decorator'; import config from 'ghost-admin/config/environment'; import isNumber from 'ghost-admin/utils/isNumber'; import moment from 'moment-timezone'; import {GENERIC_ERROR_MESSAGE} from '../services/notifications'; import {action, computed} from '@ember/object'; import {alias, mapBy} from '@ember/object/computed'; import {capitalizeFirstLetter} from '../helpers/capitalize-first-letter'; import {dropTask, enqueueTask, restartableTask, task, taskGroup, timeout} from 'ember-concurrency'; import {htmlSafe} from '@ember/template'; import {inject} from 'ghost-admin/decorators/inject'; import {isBlank} from '@ember/utils'; import {isArray as isEmberArray} from '@ember/array'; import {isHostLimitError, isServerUnreachableError, isVersionMismatchError} from 'ghost-admin/services/ajax'; import {isInvalidError} from 'ember-ajax/errors'; import {mobiledocToLexical} from '@tryghost/kg-converters'; import {inject as service} from '@ember/service'; import {slugify} from '@tryghost/string'; import {tracked} from '@glimmer/tracking'; const DEFAULT_TITLE = '(Untitled)'; // suffix that is applied to the title of a post when it has been duplicated const DUPLICATED_POST_TITLE_SUFFIX = '(Copy)'; // time in ms to save after last content edit const AUTOSAVE_TIMEOUT = 3000; // time in ms to force a save if the user is continuously typing const TIMEDSAVE_TIMEOUT = 60000; const TK_REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u); const WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); // this array will hold properties we need to watch for this.hasDirtyAttributes let watchedProps = [ 'post.lexicalScratch', 'post.titleScratch', 'post.hasDirtyAttributes', 'post.tags.[]', 'post.isError' ]; // add all post model attrs to the watchedProps array, easier to do it this way // than remember to update every time we add a new attr PostModel.eachAttribute(function (name) { watchedProps.push(`post.${name}`); }); const messageMap = { errors: { post: { published: { published: 'Update failed', draft: 'Saving failed', scheduled: 'Scheduling failed' }, draft: { published: 'Publish failed', draft: 'Saving failed', scheduled: 'Scheduling failed' }, scheduled: { scheduled: 'Update failed', draft: 'Unscheduling failed', published: 'Publish failed' } } }, success: { post: { published: { published: 'Updated', draft: 'Saved', scheduled: 'Scheduled', sent: 'Sent' }, draft: { published: 'Published', draft: 'Saved', scheduled: 'Scheduled', sent: 'Sent' }, scheduled: { scheduled: 'Updated', draft: 'Unscheduled', published: 'Published', sent: 'Sent' }, sent: { sent: 'Updated' } } } }; function textHasTk(text) { let matchArr = TK_REGEX.exec(text); if (matchArr === null) { return false; } function isValidMatch(match) { // negative lookbehind isn't supported before Safari 16.4 // so we capture the preceding char and test it here if (match[1] && match[1].trim() && WORD_CHAR_REGEX.test(match[1])) { return false; } // we also check any following char in code to avoid an overly // complex regex when looking for word-chars following the optional // trailing symbol char if (match[4] && match[4].trim() && WORD_CHAR_REGEX.test(match[4])) { return false; } return true; } // our regex will match invalid TKs because we can't use negative lookbehind // so we need to loop through the matches discarding any that are invalid // and moving on to any subsequent matches while (matchArr !== null && !isValidMatch(matchArr)) { text = text.slice(matchArr.index + matchArr[0].length - 1); matchArr = TK_REGEX.exec(text); } if (matchArr === null) { return false; } return true; } @classic export default class LexicalEditorController extends Controller { @controller application; @service feature; @service membersCountCache; @service modals; @service notifications; @service router; @service slugGenerator; @service search; @service session; @service settings; @service ui; @inject config; @tracked excerptErrorMessage = ''; /* public properties -----------------------------------------------------*/ shouldFocusTitle = false; showSettingsMenu = false; /** * Flag used to determine if we should return to the analytics page or to the posts/pages overview */ fromAnalytics = false; // koenig related properties wordCount = 0; postTkCount = 0; featureImageTkCount = 0; /* private properties ----------------------------------------------------*/ _leaveConfirmed = false; _saveOnLeavePerformed = false; _previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes /* computed properties ---------------------------------------------------*/ @alias('model') post; // store the desired post status locally without updating the model, // the model will only be updated when a save occurs @boundOneWay('post.isPublished') willPublish; @boundOneWay('post.isScheduled') willSchedule; // updateSlugTask and saveTask should always be enqueued so that we don't run into // problems with concurrency, for example when Cmd-S is pressed whilst the // cursor is in the slug field - that would previously trigger a simultaneous // slug update and save resulting in ember data errors and inconsistent save // results @(taskGroup().enqueue()) saveTasks; @mapBy('post.tags', 'name') _tagNames; @computed(...watchedProps) get hasDirtyAttributes() { return this._hasDirtyAttributes(); } set hasDirtyAttributes(value) { // eslint-disable-next-line no-setter-return return value; } @computed get _snippets() { return this.store.peekAll('snippet'); } @computed('_snippets.@each.{name,isNew,mobiledoc,lexical}') get snippets() { const snippets = this._snippets .reject(snippet => snippet.get('isNew')) .sort((a, b) => a.name.localeCompare(b.name)) .filter(item => item.lexical !== null); return snippets.map((item) => { item.value = JSON.stringify(item.lexical); return item; }); } @computed get collections() { return this.store.peekAll('collection'); } @computed('session.user.{isAdmin,isEditor}') get canManageSnippets() { let {user} = this.session; if (user.get('isAdmin') || user.get('isEditor')) { return true; } return false; } @computed('_autosaveTask.isRunning', '_timedSaveTask.isRunning') get _autosaveRunning() { let autosave = this.get('_autosaveTask.isRunning'); let timedsave = this.get('_timedSaveTask.isRunning'); return autosave || timedsave; } @computed('post.isDraft') get _canAutosave() { return config.environment !== 'test' && this.get('post.isDraft'); } TK_REGEX = new RegExp(/(^|.)([^\p{L}\p{N}\s]*(TK)+[^\p{L}\p{N}\s]*)(.)?/u); WORD_CHAR_REGEX = new RegExp(/\p{L}|\p{N}/u); @computed('post.titleScratch') get titleHasTk() { return textHasTk(this.post.titleScratch); } @computed('post.customExcerpt') get excerptHasTk() { if (!this.feature.editorExcerpt) { return false; } return textHasTk(this.post.customExcerpt || ''); } @computed('titleHasTk', 'excerptHasTk', 'postTkCount', 'featureImageTkCount') get tkCount() { const titleTk = this.titleHasTk ? 1 : 0; const excerptTk = (this.feature.editorExcerpt && this.excerptHasTk) ? 1 : 0; return titleTk + excerptTk + this.postTkCount + this.featureImageTkCount; } @action updateScratch(lexical) { this.set('post.lexicalScratch', JSON.stringify(lexical)); // save 3 seconds after last edit this._autosaveTask.perform(); // force save at 60 seconds this._timedSaveTask.perform(); } @action updateTitleScratch(title) { this.set('post.titleScratch', title); } @action async updateExcerpt(excerpt) { this.post.customExcerpt = excerpt; try { await this.post.validate({property: 'customExcerpt'}); this.excerptErrorMessage = ''; } catch (e) { // validator throws undefined on validation error if (e === undefined) { this.excerptErrorMessage = this.post.errors.errorsFor('customExcerpt')?.[0]?.message; return; } throw e; } } // updates local willPublish/Schedule values, does not get applied to // the post's `status` value until a save is triggered @action setSaveType(newType) { if (newType === 'publish') { this.set('willPublish', true); this.set('willSchedule', false); } else if (newType === 'draft') { this.set('willPublish', false); this.set('willSchedule', false); } else if (newType === 'schedule') { this.set('willSchedule', true); this.set('willPublish', false); } } @action save(options) { return this.saveTask.perform(options); } // used to prevent unexpected background saves. Triggered when opening // publish menu, starting a manual save, and when leaving the editor @action cancelAutosave() { this._autosaveTask.cancelAll(); this._timedSaveTask.cancelAll(); } // called by the "are you sure?" modal @action leaveEditor() { let transition = this.leaveEditorTransition; if (!transition) { this.notifications.showAlert(GENERIC_ERROR_MESSAGE, {type: 'error'}); return; } // perform cleanup and reset manually, ensures the transition will succeed this.reset(); return transition.retry(); } @action openDeletePostModal() { if (!this.get('post.isNew')) { this.modals.open(DeletePostModal, { post: this.post }); } } @action openUpgradeModal(hostLimitError = {}) { this.modals.open(PublishLimitModal, { message: hostLimitError.message, details: hostLimitError.details }); } @action setKoenigEditor(koenig) { this._koenig = koenig; // remove any empty cards when displaying a draft post // - empty cards may be left in draft posts due to autosave occuring // whilst an empty card is present then the user closing the browser // or refreshing the page // TODO: not yet implemented in react editor // if (this.post.isDraft) { // this._koenig.cleanup(); // } } @action updateWordCount(count) { this.set('wordCount', count); } @action updatePostTkCount(count) { this.set('postTkCount', count); } @action updateFeatureImageTkCount(count) { this.set('featureImageTkCount', count); } @action setFeatureImage(url) { this.post.set('featureImage', url); if (this.post.isDraft) { this.autosaveTask.perform(); } } @action registerEditorAPI(API) { this.editorAPI = API; } @action clearFeatureImage() { this.post.set('featureImage', null); this.post.set('featureImageAlt', null); this.post.set('featureImageCaption', null); if (this.post.isDraft) { this.autosaveTask.perform(); } } @action setFeatureImageAlt(text) { this.post.set('featureImageAlt', text); if (this.post.isDraft) { this.autosaveTask.perform(); } } @action setFeatureImageCaption(html) { this.post.set('featureImageCaption', html); } @action handleFeatureImageCaptionBlur() { if (!this.post || this.post.isDestroyed || this.post.isDestroying) { return; } if (this.post.isDraft) { this.autosaveTask.perform(); } } @action toggleSettingsMenu() { this.set('showSettingsMenu', !this.showSettingsMenu); } @action closeSettingsMenu() { this.set('showSettingsMenu', false); } @action saveNewSnippet(snippet) { const snippetData = {name: snippet.name, lexical: JSON.parse(snippet.value), mobiledoc: {}}; const snippetRecord = this.store.createRecord('snippet', snippetData); return snippetRecord.save().then(() => { this.notifications.closeAlerts('snippet.save'); this.notifications.showNotification( `Snippet saved as "${snippet.name}"`, {type: 'success'} ); return snippetRecord; }).catch((error) => { if (!snippetRecord.errors.isEmpty) { this.notifications.showAlert( `Snippet save failed: ${snippetRecord.errors.messages.join('. ')}`, {type: 'error', key: 'snippet.save'} ); } snippetRecord.rollbackAttributes(); throw error; }); } @action async createSnippet(data) { const snippetNameLC = data.name.trim().toLowerCase(); const existingSnippet = this.snippets.find(snippet => snippet.name.toLowerCase() === snippetNameLC); if (existingSnippet) { await this.confirmUpdateSnippet(existingSnippet, {lexical: JSON.parse(data.value)}); } else { await this.saveNewSnippet(data); } } @action async confirmUpdateSnippet(snippet, updatedProperties = {}) { await this.modals.open(UpdateSnippetModal, { snippet, updatedProperties }); } @action async confirmDeleteSnippet(snippet) { await this.modals.open(DeleteSnippetModal, { snippet }); } /* Public tasks ----------------------------------------------------------*/ // separate task for autosave so that it doesn't override a manual save @dropTask *autosaveTask(options) { if (!this.get('saveTask.isRunning')) { return yield this.saveTask.perform({ silent: true, backgroundSave: true, ...options }); } } // save tasks cancels autosave before running, although this cancels the // _xSave tasks that will also cancel the autosave task @task({group: 'saveTasks'}) *saveTask(options = {}) { if (this.post.isDestroyed || this.post.isDestroying) { return; } let prevStatus = this.get('post.status'); let isNew = this.get('post.isNew'); const adapterOptions = {}; this.cancelAutosave(); if (options.backgroundSave && !this.hasDirtyAttributes && !options.leavingEditor) { return; } // leaving the editor should never result in a status change, we only save on editor // leave to trigger a revision save if (options.leavingEditor) { // ensure we always have a status present otherwise we'll error when saving if (!this.post.status) { this.post.status = 'draft'; } } else { let status; if (options.backgroundSave) { // do not allow a post's status to be set to published by a background save status = 'draft'; } else { if (this.get('post.pastScheduledTime')) { status = (!this.willSchedule && !this.willPublish) ? 'draft' : 'published'; } else { if (this.willPublish && !this.get('post.isScheduled')) { status = 'published'; } else if (this.willSchedule && !this.get('post.isPublished')) { status = 'scheduled'; } else if (this.get('post.isSent')) { status = 'sent'; } else { status = 'draft'; } } } // set manually here instead of in beforeSaveTask because the // new publishing flow sets the post status manually on publish this.set('post.status', status); } const explicitSave = !options.backgroundSave; const leavingEditor = options.leavingEditor; if (explicitSave || leavingEditor) { adapterOptions.saveRevision = 1; } yield this.beforeSaveTask.perform(options); try { let post = yield this._savePostTask.perform({...options, adapterOptions}); // Clear any error notification (if any) this.notifications.clearAll(); if (!options.silent) { this._showSaveNotification(prevStatus, post.get('status'), isNew ? true : false); } // redirect to edit route if saving a new record if (isNew && post.get('id')) { if (!this.leaveEditorTransition) { this.replaceRoute('lexical-editor.edit', post); } return true; } return post; } catch (error) { if (!this.session.isAuthenticated) { yield this.modals.open(ReAuthenticateModal); if (this.session.isAuthenticated) { return this.saveTask.perform(options); } } this.set('post.status', prevStatus); if (error === undefined && this.post.errors.length === 0) { // "handled" error from _saveTask return; } // trigger upgrade modal if forbidden(403) error if (isHostLimitError(error)) { this.post.rollbackAttributes(); this.openUpgradeModal(error.payload.errors[0]); return; } // re-throw if we have a general server error if (error && !isInvalidError(error)) { this.send('error', error); return; } if (!options.silent) { let errorOrMessages = error || this.get('post.errors.messages'); this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); return; } return this.post; } } @task *beforeSaveTask(options = {}) { if (this.post?.isDestroyed || this.post?.isDestroying) { return; } // ensure we remove any blank cards when performing a full save if (!options.backgroundSave) { // TODO: not yet implemented in react editor // if (this._koenig) { // this._koenig.cleanup(); // this.set('hasDirtyAttributes', true); // } } // TODO: There's no need for (at least) most of these scratch values. // Refactor so we're setting model attributes directly // Set the properties that are indirected // Set lexical equal to what's in the editor but create a copy so that // nested objects/arrays don't keep references which can mean that both // scratch and lexical get updated simultaneously this.set('post.lexical', this.post.lexicalScratch || null); // Set a default title if (!this.get('post.titleScratch').trim()) { this.set('post.titleScratch', DEFAULT_TITLE); } this.set('post.title', this.get('post.titleScratch')); this.set('post.customExcerpt', this.get('post.customExcerptScratch')); this.set('post.footerInjection', this.get('post.footerExcerptScratch')); this.set('post.headerInjection', this.get('post.headerExcerptScratch')); this.set('post.metaTitle', this.get('post.metaTitleScratch')); this.set('post.metaDescription', this.get('post.metaDescriptionScratch')); this.set('post.ogTitle', this.get('post.ogTitleScratch')); this.set('post.ogDescription', this.get('post.ogDescriptionScratch')); this.set('post.twitterTitle', this.get('post.twitterTitleScratch')); this.set('post.twitterDescription', this.get('post.twitterDescriptionScratch')); this.set('post.emailSubject', this.get('post.emailSubjectScratch')); if (!this.get('post.slug')) { this.saveTitleTask.cancelAll(); yield this.generateSlugTask.perform(); } } /* * triggered by a user manually changing slug */ @task({group: 'saveTasks'}) *updateSlugTask(_newSlug) { let slug = this.get('post.slug'); let newSlug, serverSlug; newSlug = _newSlug || slug; newSlug = newSlug && newSlug.trim(); // Ignore unchanged slugs or candidate slugs that are empty if (!newSlug || slug === newSlug) { // reset the input to its previous state this.set('slugValue', slug); return; } serverSlug = yield this.slugGenerator.generateSlug('post', newSlug); // If after getting the sanitized and unique slug back from the API // we end up with a slug that matches the existing slug, abort the change if (serverSlug === slug) { return; } // Because the server transforms the candidate slug by stripping // certain characters and appending a number onto the end of slugs // to enforce uniqueness, there are cases where we can get back a // candidate slug that is a duplicate of the original except for // the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2) // get the last token out of the slug candidate and see if it's a number let slugTokens = serverSlug.split('-'); let check = Number(slugTokens.pop()); // if the candidate slug is the same as the existing slug except // for the incrementor then the existing slug should be used if (isNumber(check) && check > 0) { if (slug === slugTokens.join('-') && serverSlug !== newSlug) { this.set('slugValue', slug); return; } } this.set('post.slug', serverSlug); // If this is a new post. Don't save the post. Defer the save // to the user pressing the save button if (this.get('post.isNew')) { return; } return yield this._savePostTask.perform(); } // used in the PSM so that saves are sequential and don't trigger collision // detection errors @task({group: 'saveTasks'}) *savePostTask() { try { return yield this._savePostTask.perform(); } catch (error) { if (error === undefined) { // validation error return; } if (error) { let status = this.get('post.status'); this._showErrorAlert(status, status, error); } throw error; } } // convenience method for saving the post and performing post-save cleanup @task *_savePostTask(options = {}) { let {post} = this; const previousEmailOnlyValue = this.post.emailOnly; if (Object.prototype.hasOwnProperty.call(options, 'emailOnly')) { this.post.set('emailOnly', options.emailOnly); } const startTime = Date.now(); try { yield post.save(options); } catch (error) { this.post.set('emailOnly', previousEmailOnlyValue); if (this.config.sentry_dsn && (Date.now() - startTime > 2000)) { Sentry.captureException('Failed Lexical save took > 2s', (scope) => { scope.setTag('save_time', Math.ceil((Date.now() - startTime) / 1000)); scope.setTag('post_type', post.isPage ? 'page' : 'post'); scope.setTag('save_revision', options.adapterOptions?.saveRevision); scope.setTag('email_segment', options.adapterOptions?.emailSegment); scope.setTag('convert_to_lexical', options.adapterOptions?.convertToLexical); }); } if (isServerUnreachableError(error)) { const [prevStatus, newStatus] = this.post.changedAttributes().status || [this.post.status, this.post.status]; this._showErrorAlert(prevStatus, newStatus, error); // simulate a validation error so we don't end up on a 500 screen throw undefined; } throw error; } this.afterSave(post); return post; } @action afterSave(post) { this.notifications.closeAlerts('post.save'); // remove any unsaved tags // NOTE: `updateTags` changes `hasDirtyAttributes => true`. // For a saved post it would otherwise be false. post.updateTags(); this._previousTagNames = this._tagNames; // update the scratch property if it's `null` and we get a blank lexical // back from the API - prevents "unsaved changes" modal on new+blank posts if (!post.lexicalScratch) { post.set('lexicalScratch', post.get('lexical')); } // if the two "scratch" properties (title and content) match the post, // then it's ok to set hasDirtyAttributes to false // TODO: why is this necessary? let titlesMatch = post.get('titleScratch') === post.get('title'); let bodiesMatch = post.get('lexicalScratch') === post.get('lexical'); if (titlesMatch && bodiesMatch) { this.set('hasDirtyAttributes', false); } } @task *saveTitleTask() { let post = this.post; let currentTitle = post.get('title'); let newTitle = post.get('titleScratch').trim(); if ((currentTitle && newTitle && newTitle === currentTitle) || (!currentTitle && !newTitle)) { return; } // this is necessary to force a save when the title is blank this.set('hasDirtyAttributes', true); // always save updates automatically for drafts if (this.get('post.isDraft')) { yield this.generateSlugTask.perform(); yield this.autosaveTask.perform(); } this.ui.updateDocumentTitle(); } /* // sync the post slug with the post title, except when: // - the user has already typed a custom slug, which should not be overwritten // - the post has been published, so that published URLs are not broken */ @enqueueTask *generateSlugTask() { const currentTitle = this.get('post.title'); const newTitle = this.get('post.titleScratch'); const currentSlug = this.get('post.slug'); // Only set an "untitled" slug once per post if (newTitle === DEFAULT_TITLE && currentSlug) { return; } // Update the slug unless the slug looks to be a custom slug or the title is a default/has been cleared out if ( (currentSlug && slugify(currentTitle) !== currentSlug) && !(currentTitle === DEFAULT_TITLE || currentTitle?.endsWith(DUPLICATED_POST_TITLE_SUFFIX)) ) { return; } try { const newSlug = yield this.slugGenerator.generateSlug('post', newTitle); if (!isBlank(newSlug)) { this.set('post.slug', newSlug); } } catch (error) { // Nothing to do (would be nice to log this somewhere though), // but a rejected promise needs to be handled here so that a resolved // promise is returned. if (isVersionMismatchError(error)) { this.notifications.showAPIError(error); } } } // load supplemental data such as snippets and collections in the background @restartableTask *backgroundLoaderTask() { yield this.store.query('snippet', {limit: 'all'}); if (this.post.displayName === 'page' && this.feature.get('collections') && this.feature.get('collectionsCard')) { yield this.store.query('collection', {limit: 'all'}); } this.search.refreshContentTask.perform(); this.syncMobiledocSnippets(); } sleep(ms) { return new Promise((r) => { setTimeout(r, ms); }); } @action async syncMobiledocSnippets() { const snippets = this.store.peekAll('snippet'); // very early in the beta we had a bug where lexical snippets were saved with double-encoded JSON // we fix that here by re-saving any snippets that are still in that state for (let i = 0; i < snippets.length; i++) { const snippet = snippets.objectAt(i); if (typeof snippet.lexical === 'string') { try { snippet.lexical = JSON.parse(snippet.lexical); snippet.mobiledoc = {}; await snippet.save(); // Temp fix: Timeout for 100 ms between requests to avoid hitting rate limit (50req/s) // refs https://github.com/TryGhost/Product/issues/4022 await this.sleep(100); } catch (e) { snippet.lexical = null; await snippet.save(); // Temp fix: Timeout for 100 ms between requests to avoid hitting rate limit (50req/s) // refs https://github.com/TryGhost/Product/issues/4022 await this.sleep(100); console.error(e); // eslint-disable-line no-console if (this.config.sentry_dsn) { Sentry.captureException(e, { tags: { lexical: true } }); } } } } for (let i = 0; i < snippets.length; i++) { const snippet = snippets.objectAt(i); if (!snippet.lexical || snippet.lexical.syncedAt && moment.utc(snippet.lexical.syncedAt).isBefore(snippet.updatedAtUTC)) { const serializedLexical = mobiledocToLexical(JSON.stringify(snippet.mobiledoc)); // we get a full Lexical doc from the converter but Lexical only // stores an array of nodes in it's copy/paste dataset that we use for snippets const lexical = JSON.parse(serializedLexical); let nodes = lexical.root.children; // for a single-paragraph text selection Lexical only stores the // text children in the nodes array if (nodes.length === 1 && nodes[0].type === 'paragraph') { nodes = nodes[0].children; } const lexicalData = { namespace: 'KoenigEditor', nodes }; // set syncedAt so we can check if mobiledoc has been updated in next sync lexicalData.syncedAt = moment.utc().toISOString(); snippet.lexical = lexicalData; // kick off a background save, we already have .lexical updated which is what we need snippet.save(); // Temp fix: Timeout for 100 ms between requests to avoid hitting rate limit (50req/s) // refs https://github.com/TryGhost/Product/issues/4022 await this.sleep(100); } } } /* Public methods --------------------------------------------------------*/ // called by the new/edit routes to change the post model setPost(post) { // don't do anything else if we're setting the same post if (post === this.post) { this.set('shouldFocusTitle', post.get('isNew')); return; } // reset everything ready for a new post this.reset(); this.set('post', post); this.backgroundLoaderTask.perform(); // autofocus the title if we have a new post this.set('shouldFocusTitle', post.get('isNew')); // need to set scratch values because they won't be present on first // edit of the post // TODO: can these be `boundOneWay` on the model as per the other attrs? post.set('titleScratch', post.get('title')); post.set('lexicalScratch', post.get('lexical')); this._previousTagNames = this._tagNames; // triggered any time the admin tab is closed, we need to use a native // dialog here instead of our custom modal window.onbeforeunload = () => { if (this.hasDirtyAttributes) { return '==============================\n\n' + 'Hey there! It looks like you\'re in the middle of writing' + ' something and you haven\'t saved all of your content.' + '\n\nSave before you go!\n\n' + '=============================='; } }; } // called by editor route's willTransition hook, fires for editor.new->edit, // editor.edit->edit, or editor->any. Will either finish autosave then retry // transition or abort and show the "are you sure want to leave?" modal async willTransition(transition) { let post = this.post; // exit early and allow transition if we have no post, occurs if reset // has already been called if (!post) { return; } // user can enter the slug name and then leave the post page, // in such case we should wait until the slug would be saved on backend if (this.updateSlugTask.isRunning) { transition.abort(); await this.updateSlugTask.last; return transition.retry(); } const fromNewToEdit = this.router.currentRouteName === 'lexical-editor.new' && transition.targetName === 'lexical-editor.edit' && transition.intent.contexts?.[0]?.id === post.id; // clean up blank cards when leaving the editor if we have a draft post // - blank cards could be left around due to autosave triggering whilst // a blank card is present then the user attempting to leave // - will mark the post as dirty so it gets saved when transitioning // TODO: not yet implemented in lexical editor // if (this._koenig && post.isDraft) { // this._koenig.cleanup(); // } // if we need to save when leaving the editor, abort the transition, save, // then retry. If a previous transition already performed a save, skip to // avoid potential infinite transition+save loops let hasDirtyAttributes = this.hasDirtyAttributes; let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew'); // Check if anything has changed since the last revision let postRevisions = post.get('postRevisions').toArray(); let latestRevision = postRevisions[postRevisions.length - 1]; let hasChangedSinceLastRevision = !latestRevision || (!post.isNew && post.lexical.replaceAll(this.config.blogUrl, '') !== latestRevision.lexical.replaceAll(this.config.blogUrl, '')); let deletedWithoutChanges = state.isDeleted && (state.isSaving || !state.hasDirtyAttributes); // If leaving the editor and the post has changed since we last saved a revision (and it's not deleted), always save a new revision // but we should never autosave when leaving published or soon-to-be published content (scheduled); this should require the user to intervene if (!this._saveOnLeavePerformed && hasChangedSinceLastRevision && hasDirtyAttributes && !state.isDeleted && post.get('status') === 'draft') { transition.abort(); if (this._autosaveRunning) { this.cancelAutosave(); this.autosaveTask.cancelAll(); } await this.autosaveTask.perform({leavingEditor: true, backgroundSave: false}); this._saveOnLeavePerformed = true; return transition.retry(); } // controller is dirty and we aren't in a new->edit or delete->index // transition so show our "are you sure you want to leave?" modal if (!this._leaveConfirmed && !fromNewToEdit && !deletedWithoutChanges && hasDirtyAttributes) { transition.abort(); // if a save is running, wait for it to finish then transition if (this.saveTasks.isRunning) { await this.saveTasks.last; return transition.retry(); } // if an autosave is scheduled, cancel it, save then transition if (this._autosaveRunning) { this.cancelAutosave(); this.autosaveTask.cancelAll(); // If leaving the editor, always save a revision if (!this._saveOnLeavePerformed) { await this.autosaveTask.perform({leavingEditor: true}); this._saveOnLeavePerformed = true; } return transition.retry(); } // we genuinely have unsaved data, show the modal if (this.post) { Object.assign(this._leaveModalReason, {status: this.post.status}); } Sentry.captureMessage('showing leave editor modal', {extra: this._leaveModalReason}); console.log('showing leave editor modal', this._leaveModalReason); // eslint-disable-line const reallyLeave = await this.modals.open(ConfirmEditorLeaveModal); if (reallyLeave !== true) { return; } else { this._leaveConfirmed = true; return transition.retry(); } } // the transition is now certain to complete so cleanup and reset if // we're exiting the editor. new->edit keeps everything around and // edit->edit will call reset in the setPost method if necessary if (!fromNewToEdit && transition.targetName !== 'lexical-editor.edit') { this.reset(); } } // called when the editor route is left or the post model is swapped reset() { let post = this.post; // make sure the save tasks aren't still running in the background // after leaving the edit route this.cancelAutosave(); if (post) { // clear post of any unsaved, client-generated tags post.updateTags(); // remove new+unsaved records from the store and rollback any unsaved changes if (post.get('isNew')) { post.deleteRecord(); } else { post.rollbackAttributes(); } } this._previousTagNames = []; this._leaveConfirmed = false; this._saveOnLeavePerformed = false; this.set('post', null); this.set('hasDirtyAttributes', false); this.set('shouldFocusTitle', false); this.set('showSettingsMenu', false); this.set('wordCount', 0); this.set('postTkCount', 0); this.set('featureImageTkCount', 0); // remove the onbeforeunload handler as it's only relevant whilst on // the editor route window.onbeforeunload = null; } /* Private tasks ---------------------------------------------------------*/ // save 3 seconds after the last edit @(task(function* () { if (!this._canAutosave) { return; } // force an instant save on first body edit for new posts if (this.get('post.isNew')) { return this.autosaveTask.perform(); } yield timeout(AUTOSAVE_TIMEOUT); this.autosaveTask.perform(); }).restartable()) _autosaveTask; // save at 60 seconds even if the user doesn't stop typing @(task(function* () { if (!this._canAutosave) { return; } while (config.environment !== 'test' && true) { yield timeout(TIMEDSAVE_TIMEOUT); this.autosaveTask.perform(); } }).drop()) _timedSaveTask; /* Private methods -------------------------------------------------------*/ _hasDirtyAttributes() { let post = this.post; if (!post) { return false; } // if the Adapter failed to save the post isError will be true // and we should consider the post still dirty. if (post.get('isError')) { this._leaveModalReason = {reason: 'isError', context: post.errors.messages}; return true; } // post.tags is an array so hasDirtyAttributes doesn't pick up // changes unless the array ref is changed let currentTags = (this._tagNames || []).join(', '); let previousTags = (this._previousTagNames || []).join(', '); if (currentTags !== previousTags) { this._leaveModalReason = {reason: 'tags are different', context: {currentTags, previousTags}}; return true; } // titleScratch isn't an attr so needs a manual dirty check if (post.titleScratch !== post.title) { this._leaveModalReason = {reason: 'title is different', context: {current: post.title, scratch: post.titleScratch}}; return true; } // scratch isn't an attr so needs a manual dirty check let lexical = post.get('lexical'); let scratch = post.get('lexicalScratch'); // additional guard in case we are trying to compare null with undefined if (scratch || lexical) { if (scratch !== lexical) { // lexical can dynamically set direction on loading editor state (e.g. "rtl"/"ltr") per the DOM context // and we need to ignore this as a change from the user; see https://github.com/facebook/lexical/issues/4998 const scratchChildNodes = scratch ? JSON.parse(scratch).root?.children : []; const lexicalChildNodes = lexical ? JSON.parse(lexical).root?.children : []; // // nullling is typically faster than delete scratchChildNodes.forEach(child => child.direction = null); lexicalChildNodes.forEach(child => child.direction = null); if (JSON.stringify(scratchChildNodes) === JSON.stringify(lexicalChildNodes)) { return false; } this._leaveModalReason = {reason: 'lexical is different', context: {current: lexical, scratch}}; return true; } } // new+unsaved posts always return `hasDirtyAttributes: true` // so we need a manual check to see if any if (post.get('isNew')) { let changedAttributes = Object.keys(post.changedAttributes()); if (changedAttributes.length) { this._leaveModalReason = {reason: 'post.changedAttributes.length > 0', context: post.changedAttributes()}; } return changedAttributes.length ? true : false; } // we've covered all the non-tracked cases we care about so fall // back on Ember Data's default dirty attribute checks let {hasDirtyAttributes} = post; if (hasDirtyAttributes) { this._leaveModalReason = {reason: 'post.hasDirtyAttributes === true', context: post.changedAttributes()}; } return hasDirtyAttributes; } _showSaveNotification(prevStatus, status, delayed) { // scheduled messaging is completely custom if (status === 'scheduled') { return this._showScheduledNotification(delayed); } let notifications = this.notifications; let message = messageMap.success.post[prevStatus][status]; let actions, type, path; type = this.get('post.displayName'); if (status === 'published' || status === 'scheduled') { path = this.get('post.url'); actions = `View on site`; } message = capitalizeFirstLetter(type) + ' ' + message.toLowerCase(); notifications.showNotification(message, {type: 'success', actions: (actions && htmlSafe(actions)), delayed}); } async _showScheduledNotification(delayed) { let { publishedAtUTC, previewUrl, emailOnly, newsletter, displayName } = this.post; let publishedAtBlogTZ = moment.tz(publishedAtUTC, this.settings.timezone); let title = capitalizeFirstLetter(displayName) + ' scheduled'; let description = emailOnly ? ['Will be sent'] : ['Will be published']; if (newsletter) { const recipientCount = await this.membersCountCache.countString(this.post.fullRecipientFilter, {newsletter}); description.push(`${!emailOnly ? 'and delivered ' : ''}to ${recipientCount}`); } description.push(`on ${publishedAtBlogTZ.format('D MMM YYYY')}`); let timeZoneLabel = ''; if (publishedAtBlogTZ.utcOffset() === 0) { timeZoneLabel = '(UTC)'; } else { timeZoneLabel = `(UTC${publishedAtBlogTZ.format('Z').replace(/([+-])0/, '$1').replace(/:00/, '')})`; } description.push(`at ${publishedAtBlogTZ.format('HH:mm')} ${timeZoneLabel}`); description = htmlSafe(description.join(' ')); let actions = htmlSafe(`Show preview`); return this.notifications.showNotification(title, {description, actions, type: 'success', delayed}); } _showErrorAlert(prevStatus, status, error, delay) { let message = messageMap.errors.post[prevStatus][status]; let notifications = this.notifications; let errorMessage; function isString(str) { return toString.call(str) === '[object String]'; } if (isServerUnreachableError(error)) { errorMessage = 'Unable to connect, please check your internet connection and try again'; } else if (error && isString(error)) { errorMessage = error; } else if (error && isEmberArray(error)) { // This is here because validation errors are returned as an array // TODO: remove this once validations are fixed errorMessage = error[0]; } else if (error && error.payload && error.payload.errors && error.payload.errors[0].message) { return this.notifications.showAPIError(error, {key: 'post.save'}); } else { errorMessage = 'Unknown Error'; } message += `: ${errorMessage}`; message = htmlSafe(message); notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'}); } }