import Controller from '@ember/controller'; import PostModel from 'ghost-admin/models/post'; import boundOneWay from 'ghost-admin/utils/bound-one-way'; import config from 'ghost-admin/config/environment'; import isNumber from 'ghost-admin/utils/isNumber'; import {alias, mapBy} from '@ember/object/computed'; import {computed} from '@ember/object'; import {inject as controller} from '@ember/controller'; import {copy} from '@ember/object/internals'; import {htmlSafe} from '@ember/string'; import {isBlank} from '@ember/utils'; import {isArray as isEmberArray} from '@ember/array'; import {isInvalidError} from 'ember-ajax/errors'; import {isVersionMismatchError} from 'ghost-admin/services/ajax'; import {inject as service} from '@ember/service'; import {task, taskGroup, timeout} from 'ember-concurrency'; const DEFAULT_TITLE = '(Untitled)'; // 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; // this array will hold properties we need to watch for this.hasDirtyAttributes let watchedProps = [ 'post.scratch', '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}`); }); // TODO: This has to be moved to the I18n localization file. // This structure is supposed to be close to the i18n-localization which will be used soon. 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: 'Updated failed', draft: 'Unscheduling failed', published: 'Publish failed' } } }, success: { post: { published: { published: 'Updated.', draft: 'Saved.', scheduled: 'Scheduled.' }, draft: { published: 'Published!', draft: 'Saved.', scheduled: 'Scheduled.' }, scheduled: { scheduled: 'Updated.', draft: 'Unscheduled.', published: 'Published!' } } } }; export default Controller.extend({ application: controller(), feature: service(), notifications: service(), router: service(), slugGenerator: service(), session: service(), ui: service(), /* public properties -----------------------------------------------------*/ infoMessage: null, leaveEditorTransition: null, shouldFocusEditor: false, showDeletePostModal: false, showLeaveEditorModal: false, showReAuthenticateModal: false, // koenig related properties wordcount: null, /* private properties ----------------------------------------------------*/ // set by setPost and _postSaved, used in hasDirtyAttributes _previousTagNames: null, /* computed properties ---------------------------------------------------*/ post: alias('model'), // store the desired post status locally without updating the model, // the model will only be updated when a save occurs willPublish: boundOneWay('post.isPublished'), willSchedule: boundOneWay('post.isScheduled'), // updateSlug and save 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 saveTasks: taskGroup().enqueue(), _tagNames: mapBy('post.tags', 'name'), hasDirtyAttributes: computed(...watchedProps, { get() { return this._hasDirtyAttributes(); }, set(key, value) { return value; } }), _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () { let autosave = this.get('_autosave.isRunning'); let timedsave = this.get('_timedSave.isRunning'); return autosave || timedsave; }), _canAutosave: computed('post.isDraft', function () { return config.environment !== 'test' && this.get('post.isDraft'); }), /* actions ---------------------------------------------------------------*/ actions: { updateScratch(mobiledoc) { this.set('post.scratch', mobiledoc); // save 3 seconds after last edit this.get('_autosave').perform(); // force save at 60 seconds this.get('_timedSave').perform(); }, updateTitleScratch(title) { this.set('post.titleScratch', title); }, // updates local willPublish/Schedule values, does not get applied to // the post's `status` value until a save is triggered 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); } }, save(options) { return this.get('save').perform(options); }, // used to prevent unexpected background saves. Triggered when opening // publish menu, starting a manual save, and when leaving the editor cancelAutosave() { this.get('_autosave').cancelAll(); this.get('_timedSave').cancelAll(); }, toggleLeaveEditorModal(transition) { let leaveTransition = this.get('leaveEditorTransition'); // "cancel" was clicked in the "are you sure?" modal so we just // reset the saved transition and remove the modal if (!transition && this.get('showLeaveEditorModal')) { this.set('leaveEditorTransition', null); this.set('showLeaveEditorModal', false); return; } if (!leaveTransition || transition.targetName === leaveTransition.targetName) { this.set('leaveEditorTransition', transition); // if a save is running, wait for it to finish then transition if (this.get('saveTasks.isRunning')) { return this.get('saveTasks.last').then(() => { transition.retry(); }); } // if an autosave is scheduled, cancel it, save then transition if (this.get('_autosaveRunning')) { this.send('cancelAutosave'); this.get('autosave').cancelAll(); return this.get('autosave').perform().then(() => { transition.retry(); }); } // we genuinely have unsaved data, show the modal this.set('showLeaveEditorModal', true); } }, // called by the "are you sure?" modal leaveEditor() { let transition = this.get('leaveEditorTransition'); if (!transition) { this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'}); return; } // perform cleanup and reset manually, ensures the transition will succeed this.reset(); return transition.retry(); }, toggleDeletePostModal() { if (!this.get('post.isNew')) { this.toggleProperty('showDeletePostModal'); } }, toggleReAuthenticateModal() { this.toggleProperty('showReAuthenticateModal'); }, 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 if (this.post.isDraft) { this._koenig.cleanup(); } }, updateWordCount(counts) { this.set('wordCount', counts); } }, /* Public tasks ----------------------------------------------------------*/ // separate task for autosave so that it doesn't override a manual save autosave: task(function* () { if (!this.get('save.isRunning')) { return yield this.get('save').perform({ silent: true, backgroundSave: true }); } }).drop(), // save tasks cancels autosave before running, although this cancels the // _xSave tasks that will also cancel the autosave task save: task(function* (options = {}) { let prevStatus = this.get('post.status'); let isNew = this.get('post.isNew'); let status; this.send('cancelAutosave'); if (options.backgroundSave && !this.get('hasDirtyAttributes')) { return; } 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.get('willSchedule') && !this.get('willPublish')) ? 'draft' : 'published'; } else { if (this.get('willPublish') && !this.get('post.isScheduled')) { status = 'published'; } else if (this.get('willSchedule') && !this.get('post.isPublished')) { status = 'scheduled'; } else { status = 'draft'; } } } // ensure we remove any blank cards when performing a full save if (!options.backgroundSave) { if (this._koenig) { this._koenig.cleanup(); this.set('hasDirtyAttributes', true); } } // Set the properties that are indirected // set mobiledoc 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 mobiledoc get updated simultaneously this.set('post.mobiledoc', copy(this.get('post.scratch'), true)); this.set('post.status', status); // 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')); if (!this.get('post.slug')) { this.get('saveTitle').cancelAll(); yield this.get('generateSlug').perform(); } try { let post = yield this.get('post').save(options); if (!options.silent) { this._showSaveNotification(prevStatus, post.get('status'), isNew ? true : false); } this.get('post').set('statusScratch', null); // redirect to edit route if saving a new record if (isNew && post.get('id')) { if (!this.get('leaveEditorTransition')) { this.replaceRoute('editor.edit', post); } return true; } return post; } catch (error) { // re-throw if we have a general server error if (error && !isInvalidError(error)) { this.send('error', error); return; } this.set('post.status', prevStatus); if (!options.silent) { let errorOrMessages = error || this.get('post.errors.messages'); this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); // simulate a validation error for upstream tasks throw undefined; } return this.get('post'); } }).group('saveTasks'), /* * triggered by a user manually changing slug */ updateSlug: task(function* (_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.get('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.get('post').save(); }).group('saveTasks'), // used in the PSM so that saves are sequential and don't trigger collision // detection errors savePost: task(function* () { try { return yield this.get('post').save(); } catch (error) { if (error) { let status = this.get('post.status'); this._showErrorAlert(status, status, error); } throw error; } }).group('saveTasks'), saveTitle: task(function* () { let post = this.get('post'); let currentTitle = post.get('title'); let newTitle = post.get('titleScratch').trim(); if (currentTitle && newTitle && newTitle === currentTitle) { return; } // this is necessary to force a save when the title is blank this.set('hasDirtyAttributes', true); // generate a slug if a post is new and doesn't have a title yet or // if the title is still '(Untitled)' if ((post.get('isNew') && !currentTitle) || currentTitle === DEFAULT_TITLE) { yield this.get('generateSlug').perform(); } if (this.get('post.isDraft')) { yield this.get('autosave').perform(); } }), generateSlug: task(function* () { let title = this.get('post.titleScratch'); // Only set an "untitled" slug once per post if (title === DEFAULT_TITLE && this.get('post.slug')) { return; } try { let slug = yield this.get('slugGenerator').generateSlug('post', title); if (!isBlank(slug)) { this.set('post.slug', slug); } } 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.get('notifications').showAPIError(error); } } }).enqueue(), /* 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.get('post')) { // set autofocus as change signal to the persistent editor on new->edit this.set('shouldFocusEditor', post.get('isNew')); return; } // reset everything ready for a new post this.reset(); this.set('post', post); // autofocus the editor if we have a new post this.set('shouldFocusEditor', 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('scratch', post.get('mobiledoc')); this._previousTagNames = this.get('_tagNames'); this._attachModelHooks(); // 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.get('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. Triggers `toggleLeaveEditorModal` action // which will either finish autosave then retry transition or abort and show // the "are you sure?" modal willTransition(transition) { let post = this.get('post'); // exit early and allow transition if we have no post, occurs if reset // has already been called as in the `leaveEditor` action if (!post) { return; } // 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 if (this._koenig && post.isDraft) { this._koenig.cleanup(); } let hasDirtyAttributes = this.get('hasDirtyAttributes'); let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew'); let fromNewToEdit = this.get('router.currentRouteName') === 'editor.new' && transition.targetName === 'editor.edit' && transition.intent.contexts && transition.intent.contexts[0] && transition.intent.contexts[0].id === post.get('id'); let deletedWithoutChanges = state.isDeleted && (state.isSaving || !state.hasDirtyAttributes); // 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 (!fromNewToEdit && !deletedWithoutChanges && hasDirtyAttributes) { transition.abort(); this.send('toggleLeaveEditorModal', transition); return; } // 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 !== 'editor.edit') { this.reset(); } }, // called when the editor route is left or the post model is swapped reset() { let post = this.get('post'); // make sure the save tasks aren't still running in the background // after leaving the edit route this.send('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(); } // remove the create/update event handlers that were added to the post this._detachModelHooks(); } this._previousTagNames = []; this.set('post', null); this.set('hasDirtyAttributes', false); this.set('shouldFocusEditor', false); this.set('leaveEditorTransition', null); this.set('infoMessage', null); this.set('wordCount', null); // 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 _autosave: task(function* () { if (!this.get('_canAutosave')) { return; } // force an instant save on first body edit for new posts if (this.get('post.isNew')) { return this.get('autosave').perform(); } yield timeout(AUTOSAVE_TIMEOUT); this.get('autosave').perform(); }).restartable(), // save at 60 seconds even if the user doesn't stop typing _timedSave: task(function* () { if (!this.get('_canAutosave')) { return; } while (config.environment !== 'test' && true) { yield timeout(TIMEDSAVE_TIMEOUT); this.get('autosave').perform(); } }).drop(), /* Private methods -------------------------------------------------------*/ _hasDirtyAttributes() { let post = this.get('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')) { return true; } // post.tags is an array so hasDirtyAttributes doesn't pick up // changes unless the array ref is changed let currentTags = this.getWithDefault('_tagNames', []).join(''); let previousTags = this.getWithDefault('_previousTagNames', []).join(''); if (currentTags !== previousTags) { return true; } // titleScratch isn't an attr so needs a manual dirty check if (this.get('titleScratch') !== this.get('title')) { return true; } // scratch isn't an attr so needs a manual dirty check let mobiledoc = post.get('mobiledoc'); let scratch = post.get('scratch'); // additional guard in case we are trying to compare null with undefined if (scratch || mobiledoc) { let mobiledocJSON = JSON.stringify(mobiledoc); let scratchJSON = JSON.stringify(scratch); if (scratchJSON !== mobiledocJSON) { 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()); 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 return post.get('hasDirtyAttributes'); }, // post.save() is called in multiple places, rather than remembering to // add a .then in every instance we use model hooks to update our local // values used for `hasDirtyAttributes` _attachModelHooks() { let post = this.get('post'); if (post) { post.on('didCreate', this, this._postSaved); post.on('didUpdate', this, this._postSaved); } }, _detachModelHooks() { let post = this.get('post'); if (post) { post.off('didCreate', this, this._postSaved); post.off('didUpdate', this, this._postSaved); } }, _postSaved() { let post = this.get('post'); // remove any unsaved tags // NOTE: `updateTags` changes `hasDirtyAttributes => true`. // For a saved post it would otherwise be false. post.updateTags(); this._previousTagNames = this.get('_tagNames'); // 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 = JSON.stringify(post.get('scratch')) === JSON.stringify(post.get('mobiledoc')); if (titlesMatch && bodiesMatch) { this.set('hasDirtyAttributes', false); } }, _showSaveNotification(prevStatus, status, delay) { let message = messageMap.success.post[prevStatus][status]; let notifications = this.get('notifications'); let type, path; if (status === 'published') { type = this.get('post.page') ? 'Page' : 'Post'; path = this.get('post.url'); } else { type = 'Preview'; path = this.get('post.previewUrl'); } message += ` View ${type}`; notifications.showNotification(message.htmlSafe(), {delayed: delay}); }, _showErrorAlert(prevStatus, status, error, delay) { let message = messageMap.errors.post[prevStatus][status]; let notifications = this.get('notifications'); let errorMessage; function isString(str) { /* global toString */ return toString.call(str) === '[object String]'; } 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) { errorMessage = error.payload.errors[0].message; } else { errorMessage = 'Unknown Error'; } message += `: ${errorMessage}`; message = htmlSafe(message); notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'}); } });