Ghost/ghost/admin/app/models/post.js

452 lines
16 KiB
JavaScript
Raw Normal View History

2015-02-13 07:22:32 +03:00
import Ember from 'ember';
import Model, {attr, belongsTo, hasMany} from '@ember-data/model';
import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
import moment from 'moment-timezone';
import {BLANK_DOC as BLANK_MOBILEDOC} from 'koenig-editor/components/koenig-editor';
import {compare, isBlank} from '@ember/utils';
import {computed, observer} from '@ember/object';
import {equal, filterBy, reads} from '@ember/object/computed';
import {on} from '@ember/object/evented';
import {inject as service} from '@ember/service';
const BLANK_LEXICAL = '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}';
// ember-cli-shims doesn't export these so we must get them manually
const {Comparable} = Ember;
function statusCompare(postA, postB) {
let status1 = postA.get('status');
let status2 = postB.get('status');
// if any of those is empty
if (!status1 && !status2) {
return 0;
}
if (!status1 && status2) {
return -1;
}
if (!status2 && status1) {
return 1;
}
// We have to make sure, that scheduled posts will be listed first
// after that, draft and published will be sorted alphabetically and don't need
// any manual comparison.
if (status1 === 'scheduled' && (status2 === 'draft' || status2 === 'published')) {
return -1;
}
if (status2 === 'scheduled' && (status1 === 'draft' || status1 === 'published')) {
return 1;
}
return compare(status1.valueOf(), status2.valueOf());
}
function publishedAtCompare(postA, postB) {
let published1 = postA.get('publishedAtUTC');
let published2 = postB.get('publishedAtUTC');
if (!published1 && !published2) {
return 0;
}
if (!published1 && published2) {
return -1;
}
if (!published2 && published1) {
return 1;
}
return compare(published1.valueOf(), published2.valueOf());
}
export default Model.extend(Comparable, ValidationEngine, {
config: service(),
session: service(),
feature: service(),
ghostPaths: service(),
clock: service(),
settings: service(),
membersUtils: service(),
displayName: 'post',
validationType: 'post',
count: attr(),
createdAtUTC: attr('moment-utc'),
excerpt: attr('string'),
customExcerpt: attr('string'),
featured: attr('boolean', {defaultValue: false}),
canonicalUrl: attr('string'),
codeinjectionFoot: attr('string', {defaultValue: ''}),
codeinjectionHead: attr('string', {defaultValue: ''}),
customTemplate: attr('string'),
ogImage: attr('string'),
ogTitle: attr('string'),
ogDescription: attr('string'),
twitterImage: attr('string'),
twitterTitle: attr('string'),
twitterDescription: attr('string'),
emailSubject: attr('string'),
html: attr('string'),
visibility: attr('string'),
metaDescription: attr('string'),
metaTitle: attr('string'),
mobiledoc: attr('json-string', {defaultValue: (modelInstance) => {
if (modelInstance.feature.lexicalEditor) {
return null;
}
// avoid modifying any references in the original blank doc object
return JSON.parse(JSON.stringify(BLANK_MOBILEDOC));
}}),
lexical: attr('string', {defaultValue: (modelInstance) => {
if (modelInstance.feature.lexicalEditor) {
return BLANK_LEXICAL;
}
return null;
}}),
plaintext: attr('string'),
publishedAtUTC: attr('moment-utc'),
slug: attr('string'),
status: attr('string', {defaultValue: 'draft'}),
title: attr('string', {defaultValue: ''}),
updatedAtUTC: attr('moment-utc'),
updatedBy: attr('number'),
url: attr('string'),
uuid: attr('string'),
emailSegment: attr('members-segment-string', {defaultValue: null}),
emailOnly: attr('boolean', {defaultValue: false}),
featureImage: attr('string'),
featureImageAlt: attr('string'),
featureImageCaption: attr('string'),
authors: hasMany('user', {embedded: 'always', async: false}),
createdBy: belongsTo('user', {async: true}),
email: belongsTo('email', {async: false}),
newsletter: belongsTo('newsletter', {embedded: 'always', async: false}),
publishedBy: belongsTo('user', {async: true}),
tags: hasMany('tag', {embedded: 'always', async: false}),
primaryAuthor: reads('authors.firstObject'),
primaryTag: reads('tags.firstObject'),
scratch: null,
lexicalScratch: null,
titleScratch: null,
// HACK: used for validation so that date/time can be validated based on
// eventual status rather than current status
statusScratch: null,
// For use by date/time pickers - will be validated then converted to UTC
// on save. Updated by an observer whenever publishedAtUTC changes.
// Everything that revolves around publishedAtUTC only cares about the saved
// value so this should be almost entirely internal
publishedAtBlogDate: '',
publishedAtBlogTime: '',
canonicalUrlScratch: boundOneWay('canonicalUrl'),
customExcerptScratch: boundOneWay('customExcerpt'),
codeinjectionFootScratch: boundOneWay('codeinjectionFoot'),
codeinjectionHeadScratch: boundOneWay('codeinjectionHead'),
metaDescriptionScratch: boundOneWay('metaDescription'),
metaTitleScratch: boundOneWay('metaTitle'),
ogDescriptionScratch: boundOneWay('ogDescription'),
ogTitleScratch: boundOneWay('ogTitle'),
twitterDescriptionScratch: boundOneWay('twitterDescription'),
twitterTitleScratch: boundOneWay('twitterTitle'),
tiers: attr('member-tier'),
emailSubjectScratch: boundOneWay('emailSubject'),
isPublished: equal('status', 'published'),
isDraft: equal('status', 'draft'),
internalTags: filterBy('tags', 'isInternal', true),
isScheduled: equal('status', 'scheduled'),
isSent: equal('status', 'sent'),
isPost: equal('displayName', 'post'),
isPage: equal('displayName', 'page'),
hasEmail: computed('email', 'emailOnly', function () {
return this.email !== null || this.emailOnly;
}),
willEmail: computed('isScheduled', 'newsletter', 'email', function () {
return this.isScheduled && !!this.newsletter && !this.email;
}),
hasBeenEmailed: computed('isPost', 'isSent', 'isPublished', 'email', function () {
return this.isPost
&& (this.isSent || this.isPublished)
&& this.email && this.email.status !== 'failed';
}),
didEmailFail: computed('isPost', 'isSent', 'isPublished', 'email.status', function () {
return this.isPost
&& (this.isSent || this.isPublished)
&& this.email && this.email.status === 'failed';
}),
showAudienceFeedback: computed('count', function () {
return this.feature.get('audienceFeedback') && this.count.sentiment !== undefined;
}),
showEmailOpenAnalytics: computed('hasBeenEmailed', 'isSent', 'isPublished', function () {
return this.hasBeenEmailed
&& !this.session.user.isContributor
&& this.settings.membersSignupAccess !== 'none'
&& this.settings.editorDefaultEmailRecipients !== 'disabled'
&& this.hasBeenEmailed
&& this.email.trackOpens
&& this.settings.emailTrackOpens;
}),
showEmailClickAnalytics: computed('hasBeenEmailed', 'isSent', 'isPublished', 'email', function () {
return this.hasBeenEmailed
&& !this.session.user.isContributor
&& this.settings.membersSignupAccess !== 'none'
&& this.settings.editorDefaultEmailRecipients !== 'disabled'
&& (this.isSent || this.isPublished)
&& this.email.trackClicks
&& this.settings.emailTrackClicks;
}),
showAttributionAnalytics: computed('isPage', 'emailOnly', 'isPublished', 'membersUtils.isMembersInviteOnly', function () {
return (this.isPage || !this.emailOnly)
&& this.isPublished
&& this.feature.get('memberAttribution')
&& !this.membersUtils.isMembersInviteOnly
&& !this.session.user.isContributor;
}),
showPaidAttributionAnalytics: computed.and('showAttributionAnalytics', 'membersUtils.paidMembersEnabled'),
hasAnalyticsPage: computed('isPost', 'showEmailOpenAnalytics', 'showEmailClickAnalytics', 'showAttributionAnalytics', function () {
return this.isPost
&& (
this.showEmailOpenAnalytics
|| this.showEmailClickAnalytics
|| this.showAttributionAnalytics
);
}),
previewUrl: computed('uuid', 'ghostPaths.url', 'config.blogUrl', function () {
let blogUrl = this.config.blogUrl;
let uuid = this.uuid;
// routeKeywords.preview: 'p'
let previewKeyword = 'p';
// New posts don't have a preview
if (!uuid) {
return '';
}
return this.get('ghostPaths.url').join(blogUrl, previewKeyword, uuid);
}),
isPublic: computed('visibility', function () {
return this.visibility === 'public' ? true : false;
}),
visibilitySegment: computed('visibility', 'isPublic', 'tiers', function () {
if (this.isPublic) {
return this.settings.defaultContentVisibility === 'paid' ? 'status:-free' : 'status:free,status:-free';
} else {
if (this.visibility === 'members') {
return 'status:free,status:-free';
}
if (this.visibility === 'paid') {
return 'status:-free';
}
if (this.visibility === 'tiers' && this.tiers) {
let filter = this.tiers.map((tier) => {
return `tier:${tier.slug}`;
}).join(',');
return filter;
}
return this.visibility;
}
}),
fullRecipientFilter: computed('newsletter.recipientFilter', 'emailSegment', function () {
if (!this.newsletter) {
return this.emailSegment;
}
return `${this.newsletter.recipientFilter}+(${this.emailSegment})`;
}),
// check every second to see if we're past the scheduled time
// will only re-compute if this property is being observed elsewhere
pastScheduledTime: computed('isScheduled', 'publishedAtUTC', 'clock.second', function () {
if (this.isScheduled) {
let now = moment.utc();
let publishedAtUTC = this.publishedAtUTC || now;
let pastScheduledTime = publishedAtUTC.diff(now, 'hours', true) < 0;
// force a recompute
this.get('clock.second');
return pastScheduledTime;
} else {
return false;
}
}),
Scheduler UI refs TryGhost/Ghost#6413 and TryGhost/Ghost#6870 needs TryGhost/Ghost#6861 - **Post Settings Menu (PSM)**:'Publish Date' input accepts a date from now, min. 2 minutes to allow scheduler processing on the server. Also, there will always be some delay between typing the date and clicking on the 'Schedule Post' button. If the user types a future date for an already published post, the date will be reseted and he sees the message, that the post needs to be unpublished first. Once, the date is accepted, the label will change to 'Scheduled Date'. - adds a CP 'timeScheduled' to post model, which will return `true` if the publish time is currently in the future. - **Changes to the button flow in editor**: - if the the CP `timeScheduled` returns true, a different drop-down-menu will be shown: 'Schedule Post' replaces 'Publish Now' and 'Unschedule' replaces 'Unpublish'. - Covering the _edge cases_, especially when a scheduled post is about to be published, while the user is in the editor. - First, a new CP `scheduleCountdown` will return the remaining time, when the estimated publish time is 15 minutes from now. A notification with this live-ticker is shown next to the save button. Once, we reach a 2 minutes limit, another CP `statusFreeze` will return true and causes the save button to only show `Unschedule` in a red state, until we reach the publish time - Once the publish time is reached, a CP `scheduledWillPublish` causes the buttons and the existing code to pretend we're already dealing with a publish post. At the moment, there's no way to make a background-fetch of the now serverside-scheduled post model from the server, so Ember doesn't know about the changed state at that time. - Changes in the editor, which are done during this 'status freeze'-process will be saved back correctly, once the user hits 'Update Post' after the buttons changed back. A click on 'Unpublish' will change the status back to a draft. - The user will get a regular 'toaster' notification that the post has been published. - adds CP `isScheduled` for scheduled posts - adds CP `offset` to component `gh-posts-list-item` and helper `gh-format-time-scheduled` to show schedule date in content overview. - sets timeout in `gh-spin-button` to 10ms for `Ember.testing` - changes error message in `gh-editor-base-controller` to be in one line, seperated with a `:` TODOs: - [x] new sort order for posts (1. scheduled, 2. draft, 3. published) (refs TryGhost/Ghost#6932) - [ ] Move posts sorting from posts controller to model and refactor to use `Ember.comparable` mixin - [x] Flows for draft -> scheduled -> published like described in TryGhost/Ghost#6870 incl. edge cases and button behaviour - [x] Tests - [x] new PSM behaviour for time/date in future - [x] display publishedAt date with timezone offset on posts overview
2016-02-02 10:04:40 +03:00
publishedAtBlogTZ: computed('publishedAtBlogDate', 'publishedAtBlogTime', 'settings.timezone', {
get() {
return this._getPublishedAtBlogTZ();
},
set(key, value) {
let momentValue = value ? moment(value) : null;
this._setPublishedAtBlogStrings(momentValue);
return this._getPublishedAtBlogTZ();
}
Scheduler UI refs TryGhost/Ghost#6413 and TryGhost/Ghost#6870 needs TryGhost/Ghost#6861 - **Post Settings Menu (PSM)**:'Publish Date' input accepts a date from now, min. 2 minutes to allow scheduler processing on the server. Also, there will always be some delay between typing the date and clicking on the 'Schedule Post' button. If the user types a future date for an already published post, the date will be reseted and he sees the message, that the post needs to be unpublished first. Once, the date is accepted, the label will change to 'Scheduled Date'. - adds a CP 'timeScheduled' to post model, which will return `true` if the publish time is currently in the future. - **Changes to the button flow in editor**: - if the the CP `timeScheduled` returns true, a different drop-down-menu will be shown: 'Schedule Post' replaces 'Publish Now' and 'Unschedule' replaces 'Unpublish'. - Covering the _edge cases_, especially when a scheduled post is about to be published, while the user is in the editor. - First, a new CP `scheduleCountdown` will return the remaining time, when the estimated publish time is 15 minutes from now. A notification with this live-ticker is shown next to the save button. Once, we reach a 2 minutes limit, another CP `statusFreeze` will return true and causes the save button to only show `Unschedule` in a red state, until we reach the publish time - Once the publish time is reached, a CP `scheduledWillPublish` causes the buttons and the existing code to pretend we're already dealing with a publish post. At the moment, there's no way to make a background-fetch of the now serverside-scheduled post model from the server, so Ember doesn't know about the changed state at that time. - Changes in the editor, which are done during this 'status freeze'-process will be saved back correctly, once the user hits 'Update Post' after the buttons changed back. A click on 'Unpublish' will change the status back to a draft. - The user will get a regular 'toaster' notification that the post has been published. - adds CP `isScheduled` for scheduled posts - adds CP `offset` to component `gh-posts-list-item` and helper `gh-format-time-scheduled` to show schedule date in content overview. - sets timeout in `gh-spin-button` to 10ms for `Ember.testing` - changes error message in `gh-editor-base-controller` to be in one line, seperated with a `:` TODOs: - [x] new sort order for posts (1. scheduled, 2. draft, 3. published) (refs TryGhost/Ghost#6932) - [ ] Move posts sorting from posts controller to model and refactor to use `Ember.comparable` mixin - [x] Flows for draft -> scheduled -> published like described in TryGhost/Ghost#6870 incl. edge cases and button behaviour - [x] Tests - [x] new PSM behaviour for time/date in future - [x] display publishedAt date with timezone offset on posts overview
2016-02-02 10:04:40 +03:00
}),
clickRate: computed('email.emailCount', 'count.clicks', function () {
if (!this.email || !this.email.emailCount) {
return 0;
}
if (!this.count || !this.count.clicks) {
return 0;
}
return Math.round(this.count.clicks / this.email.emailCount * 100);
}),
_getPublishedAtBlogTZ() {
let publishedAtUTC = this.publishedAtUTC;
let publishedAtBlogDate = this.publishedAtBlogDate;
let publishedAtBlogTime = this.publishedAtBlogTime;
let blogTimezone = this.settings.timezone;
if (!publishedAtUTC && isBlank(publishedAtBlogDate) && isBlank(publishedAtBlogTime)) {
return null;
}
if (publishedAtBlogDate && publishedAtBlogTime) {
let publishedAtBlog = moment.tz(`${publishedAtBlogDate} ${publishedAtBlogTime}`, blogTimezone);
/**
* Note:
* If you create a post and publish it, we send seconds to the database.
* If you edit the post afterwards, ember would send the date without seconds, because
* the `publishedAtUTC` is based on `publishedAtBlogTime`, which is only in seconds.
* The date time picker doesn't use seconds.
*
* This condition prevents the case:
* - you edit a post, but you don't change the published_at time
* - we keep the original date with seconds
*
* See https://github.com/TryGhost/Ghost/issues/8603#issuecomment-309538395.
*/
if (publishedAtUTC && publishedAtBlog.diff(publishedAtUTC.clone().startOf('minutes')) === 0) {
return publishedAtUTC;
}
return publishedAtBlog;
} else {
return moment.tz(this.publishedAtUTC, blogTimezone);
}
},
// TODO: is there a better way to handle this?
// eslint-disable-next-line ghost/ember/no-observers
_setPublishedAtBlogTZ: on('init', observer('publishedAtUTC', 'settings.timezone', function () {
let publishedAtUTC = this.publishedAtUTC;
this._setPublishedAtBlogStrings(publishedAtUTC);
})),
_setPublishedAtBlogStrings(momentDate) {
if (momentDate) {
let blogTimezone = this.settings.timezone;
let publishedAtBlog = moment.tz(momentDate, blogTimezone);
this.set('publishedAtBlogDate', publishedAtBlog.format('YYYY-MM-DD'));
this.set('publishedAtBlogTime', publishedAtBlog.format('HH:mm'));
} else {
this.set('publishedAtBlogDate', '');
this.set('publishedAtBlogTime', '');
}
},
// remove client-generated tags, which have `id: null`.
// Ember Data won't recognize/update them automatically
// when returned from the server with ids.
// https://github.com/emberjs/data/issues/1829
updateTags() {
let tags = this.tags;
let oldTags = tags.filterBy('id', null);
tags.removeObjects(oldTags);
oldTags.invoke('deleteRecord');
},
isAuthoredByUser(user) {
return this.authors.includes(user);
},
// a custom sort function is needed in order to sort the posts list the same way the server would:
// status: scheduled, draft, published
// publishedAt: DESC
// updatedAt: DESC
// id: DESC
compare(postA, postB) {
let updated1 = postA.get('updatedAtUTC');
let updated2 = postB.get('updatedAtUTC');
let idResult,
publishedAtResult,
statusResult,
updatedAtResult;
// when `updatedAt` is undefined, the model is still
// being written to with the results from the server
if (postA.get('isNew') || !updated1) {
return -1;
}
if (postB.get('isNew') || !updated2) {
return 1;
}
// TODO: revisit the ID sorting because we no longer have auto-incrementing IDs
idResult = compare(postA.get('id'), postB.get('id'));
statusResult = statusCompare(postA, postB);
updatedAtResult = compare(updated1.valueOf(), updated2.valueOf());
publishedAtResult = publishedAtCompare(postA, postB);
if (statusResult === 0) {
if (publishedAtResult === 0) {
if (updatedAtResult === 0) {
// This should be DESC
return idResult * -1;
}
// This should be DESC
return updatedAtResult * -1;
}
// This should be DESC
return publishedAtResult * -1;
}
return statusResult;
},
// this is a hook added by the ValidationEngine mixin and is called after
// successful validation and before this.save()
//
// the publishedAtBlog{Date/Time} strings are set separately so they can be
// validated, grab that time if it exists and set the publishedAtUTC
beforeSave() {
let publishedAtBlogTZ = this.publishedAtBlogTZ;
let publishedAtUTC = publishedAtBlogTZ ? publishedAtBlogTZ.utc() : null;
this.set('publishedAtUTC', publishedAtUTC);
}
});