paradigm-core
Version:
Paradigm Core Modules
1,516 lines (1,150 loc) • 39 kB
JavaScript
const HtmlToArticleJson = require('@mattersmedia/html-to-article-json')
const convertToAppleNews = require('@mattersmedia/article-json-to-apple-news')
const convertToFbia = require('@mattersmedia/article-json-to-fbia')
const {
r,
RootModel,
ComposeError,
ApplicationsPlugin: {ApplicationModel},
DigitalAssetsPlugin: {DigitalAssetModel},
UsersPlugin: {UserModel},
} = require('structure-core')
const errorCodes = require('../../../lib/error-codes')
const slug = require('../../../lib/slugs')
const RevisionModel = require('./document-revision')
const defaultAppleNewsOpts = require('../lib/default-apple-news-opts')
const errorComposer = new ComposeError(errorCodes)
const htmlToArticleJson = HtmlToArticleJson()
function formatDateString(dateString) {
return new Date(dateString).toLocaleDateString(
'en-us',
{year: 'numeric', month: 'long', day: 'numeric'}
)
}
function getDay(date) {
var day = new Date(date).getUTCDate()
if(day < 10) day = `0${day}`
return day
}
function getMonth(date) {
var month = new Date(date).getUTCMonth() + 1
if(month < 10) month = `0${month}`
return month
}
function getYear(date) {
return new Date(date).getUTCFullYear()
}
function escapeHtml(unsafe) {
if (unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
}
}
/**
* DocumentModel Class
*
* @public
* @class DocumentModel
*/
class DocumentModel extends RootModel {
/**
* DocumentModel constructor
*
* @public
* @constructor
* @param {Object} options - Options
*/
constructor(options = {}) {
const superOptions = Object.assign({}, {
localKey: 'documentId',
table: 'documents',
relations: {
belongsTo: [
{
node: 'templates',
link: {
foreignKey: 'templateId',
localKey: 'documentId'
},
joinTable: 'link_templates_documents'
},
{
node: 'users',
link: {
foreignKey: 'userId',
localKey: 'documentId'
},
joinTable: 'link_users_documents'
}
],
hasMany: [
{
node: 'document_revisions',
link: {
foreignKey: 'documentRevisionId',
localKey: 'documentId'
},
joinTable: 'link_documents_revisions'
},
{
node: 'taxonomies',
link: {
foreignKey: 'taxonomyId',
localKey: 'documentId'
},
joinTable: 'link_documents_taxonomies'
}
],
hasManyAndBelongsTo: [
{
node: 'categories',
link: {
foreignKey: 'categoryId',
localKey: 'documentId'
},
joinTable: 'link_categories_documents'
}
],
},
RevisionModel
}, options)
superOptions.relations = options.relations || superOptions.relations
super(superOptions)
}
/**
* Check the existence of a key/value pair, with key being an index
*
* @public
*/
checkExistence(key, val, options = {}) {
const applicationId = this.applicationId
let exists = false
return new Promise( async (resolve, reject) => {
try {
const res = await r
.table(this.table)
.getAll(applicationId, {index: 'applicationId'})
.filter(function(doc) {
return r
.expr(['draft', 'published', 'scheduled']).contains(doc('status'))
.and(doc(key).eq(val))
})
.limit(1)
if(res.length > 0) {
exists = true
}
resolve({exists})
}
catch(e) {
this.logger.error(e)
resolve({exists})
}
})
}
count(item = '', options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
return new Promise( async (resolve, reject) => {
try {
const count = r
.table(this.table)
.getAll([applicationId, item], {index: 'link_application_status'})
.count()
resolve(count)
}
catch(e) {
this.logger.error(e)
reject(e)
}
})
}
/**
* Create, or save, a document
*
* @public
* @param {Object} pkg - The data to save for the document
*/
create(pkg = {}, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
/*
NOTE:
When a document is created with fields, we need to take those fields and place them on a revision
*/
let fields = []
if(pkg.fields) {
fields = pkg.fields.slice(0)
delete pkg.fields
}
if(!pkg.activeRevisionId) pkg.activeRevisionId = null
if(!pkg.categories) pkg.categories = []
if(!pkg.categoryIds) pkg.categoryIds = []
if(!pkg.featuredImage) pkg.featuredImage = null
if(!pkg.featuredImageId) pkg.featuredImageId = ''
if(!pkg.facebookImageId) pkg.facebookImageId = ''
if(!pkg.revisionIds) pkg.revisionIds = []
if(!pkg.twitterImageId) pkg.twitterImageId = ''
if(typeof pkg.status === 'undefined') {
pkg.status = 'draft'
}
return new Promise( async (resolve, reject) => {
// Cannot have a published doc with no fields
if(this.isPublished(pkg.status) && fields.length === 0) {
return reject(errorComposer.compose('DOCUMENT_MISSING_FIELDS', {
applicationId,
fields,
organizationId
}, {status: 400}))
}
const updatePkg = Object.assign({}, pkg, {
fields
})
try {
// Create document
const doc = await RootModel.prototype.create.call(this, pkg)
const update = await this.updateById(doc.id, updatePkg)
resolve(update)
} catch(e) {
this.logger.error(e)
reject(e)
}
})
}
deleteById(id, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
return this.updateById(id, {
status: 'deleted'
})
}
generatePermalink(doc, categories = [], publishedAt, slug = '') {
let permalink = null
try {
const channel = (categories.length > 0) ? categories[0].slug : 'archive' // Not a huge fan of this
const year = getYear(publishedAt)
const month = getMonth(publishedAt)
const day = getDay(publishedAt)
const sid = doc.sid
permalink = `/${channel}/${year}/${month}/${day}/${sid}/${slug}`
}
catch(e) {
this.logger.error('Could not create permalink', e)
throw new Error(e)
}
return permalink
}
generatePermalink2(doc, categories = []) {
let permalink = null
try {
let channel = (doc.categories && doc.categories.length > 0) ? doc.categories[0].slug : 'archive' // Not a huge fan of this
if(categories instanceof Array && categories.length > 0) {
channel = categories[0].slug
}
const year = getYear(doc.publishedAt)
const month = getMonth(doc.publishedAt)
const day = getDay(doc.publishedAt)
const sid = doc.sid
const slug = doc.slug
permalink = `/${channel}/${year}/${month}/${day}/${sid}/${slug}`
}
catch(e) {
this.logger.error('Could not create permalink; docId', {id: doc.id, err: e})
}
return permalink
}
/**
* Get the document's active revision
*
* @public
* @param {Object} doc - The document
*/
getActiveRevision(doc) {
return new Promise( async (resolve, reject) => {
if(!doc.activeRevisionId) return resolve({
fields: [],
title: doc.title
})
var revision = await new this.options.RevisionModel().getById(doc.activeRevisionId)
if(!revision.id) {
this.logger.error('Could not get document revision for', {id: doc.id})
return reject(revision)
}
resolve(revision)
})
}
_getAllDocFilter(args = {}) {
const {
categoryIds,
ignoreDocumentIds,
order,
status,
tags,
taxonomyOperator,
userId,
title,
} = args
return function _getAllDocFilterFunction(doc) {
return r
// Do not want these documents
.branch(
r.expr(ignoreDocumentIds.length).gt(0),
r.branch(
r.expr(ignoreDocumentIds).contains(doc('id')).not(),
true,
false
),
true
)
// Filter by title if supplied
.and(
r.branch(
r.expr(title.length).gt(0),
doc('title').match(`(?i)${title}`),
true
)
)
// Only documents with correct user ID
.and(
r.branch(
r.expr(userId.length).gt(0),
r.expr(userId).eq(doc('userId')),
true
)
)
// Only documents with correct status
.and(r.expr(status).contains(doc('status')))
// Document contains the following categoryIds
.and(
r.branch(
r.expr(categoryIds.length).gt(0),
r.branch(
r.expr(taxonomyOperator).eq('and'),
doc('categoryIds').contains(r.args(categoryIds)),
doc('categoryIds').contains(function(categoryId) {
return r.expr(categoryIds).contains(categoryId)
})
),
true
)
)
// Document contains the following tags
.and(
r.branch(
r.expr(tags.length).gt(0).and(r.expr(taxonomyOperator).eq('and')),
r.branch(
doc('tags').contains(r.args(tags)),
true,
doc('tags').contains(function(tag) {
return r.expr(tags).contains(tag)
})
),
true
)
)
// Need documents with categor(ies) if ordering by categories
.and(
r.branch(
r.expr(order.by).eq('categories'),
doc('categoryIds').isEmpty().not(),
true
)
)
}
}
_getAllDocQuery(ids = [], options = {}) {
const applicationId = this.applicationId
const page = options.page
const limit = options.limit
return new Promise( async (resolve, reject) => {
try {
const {
categorySlugs,
expand,
ignoreDocumentIds,
order,
status,
tags,
taxonomyOperator,
userId,
title
} = options
const categoryIds2SlugMap = {}
const categoriesSlugsMap = new Map()
let categoryIds = options.categoryIds || []
const documentIds = ids
const categoriesList = await r
.table('categories')
.getAll(applicationId, {index: 'applicationId'})
for(let i = 0, l = categoriesList.length; i < l; i++) {
const category = categoriesList[i]
if(category.slug.length > 0) {
categoryIds2SlugMap[category.id] = category.slug
categoriesSlugsMap.set(category.slug, category)
}
}
// Convert slugs to ids
if(categorySlugs.length > 0) {
for(let i = 0, l = categorySlugs.length; i < l; i++) {
const slug = categorySlugs[i]
const category = categoriesSlugsMap.get(slug)
if(category && category.id) {
categoryIds.push(category.id)
}
}
}
// A category was requested, but does not exist
if(taxonomyOperator === 'and' && categoryIds.length === 0) {
return resolve({
pagination: {
pages: 0,
current: 1,
next: false,
previous: false,
total: 0,
},
results: []
})
}
let query = r
.table(this.table)
if(documentIds.length > 0) {
// A specific set of documents was requested
query = query
.getAll(r.args(documentIds))
} else {
// Default to getting all docs within application
query = query
.getAll(applicationId, {index: 'applicationId'})
}
query = query
.filter(this._getAllDocFilter({
categoryIds,
expand,
ignoreDocumentIds,
order,
status,
tags,
taxonomyOperator,
userId,
title,
}))
switch(order.by) {
case 'categories':
query = query
.map(function(doc) {
return doc.merge({
primaryCategoryId: doc('categoryIds')(0),
primaryCategorySlug: r.expr(categoryIds2SlugMap)(doc('categoryIds')(0))
})
})
.orderBy(r[order.dir]('primaryCategorySlug'))
break
case 'users':
break
default:
query = query
.orderBy(r[order.dir](order.by))
}
const res = await this.paginate(query, {page, limit})
resolve(res)
} catch(e) {
this.logger.error(e)
reject(e)
}
})
}
_getAllMetadataQuery(docs, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
const daModel = new DigitalAssetModel({
applicationId,
logger: this.logger,
organizationId
})
const userModel = new UserModel({
applicationId,
logger: this.logger,
organizationId
})
return new Promise( async (resolve, reject) => {
try {
const expand = options.expand
const categoriesIds = []
const usersIds = []
const digitalAssetIds = []
const revisionIds = []
const usersMap = new Map()
const categoriesMap = new Map()
const digitalAssetsMap = new Map()
const revisionsMap = new Map()
for(let i = 0, l = docs.length; i < l; i++) {
const doc = docs[i]
doc.categories = []
const categoryIds = doc.categoryIds || []
for(let j = 0, k = categoryIds.length; j < k; j++) {
const categoryId = categoryIds[j]
if(categoryId && categoriesIds.indexOf(categoryId) == -1) {
categoriesIds.push(categoryId)
}
}
if(expand) {
if(doc.featuredImageId && digitalAssetIds.indexOf(doc.featuredImageId) == -1) {
digitalAssetIds.push(doc.featuredImageId)
}
if(doc.facebookImageId && digitalAssetIds.indexOf(doc.facebookImageId) == -1) {
digitalAssetIds.push(doc.facebookImageId)
}
if(doc.twitterImageId && digitalAssetIds.indexOf(doc.twitterImageId) == -1) {
digitalAssetIds.push(doc.twitterImageId)
}
if(doc.activeRevisionId && revisionIds.indexOf(doc.activeRevisionId) == -1) {
revisionIds.push(doc.activeRevisionId)
}
}
if(doc.userId && usersIds.indexOf(doc.userId) == -1) {
usersIds.push(doc.userId)
}
}
// Get document metadata (except digital assets)
const metadata = await Promise
.all([
userModel.getAll(usersIds),
r.table('categories').getAll(r.args(categoriesIds)),
(expand) ? r.table('document_revisions').getAll(r.args(revisionIds)) : []
])
const usersRes = metadata[0] || []
const categoriesRes = metadata[1] || []
const revisionsRes = metadata[2] || []
for(let i = 0, l = usersRes.length; i < l; i++) {
const user = usersRes[i]
usersMap.set(user.id, user)
}
for(let i = 0, l = categoriesRes.length; i < l; i++) {
const category = categoriesRes[i]
categoriesMap.set(category.id, category)
}
for(let i = 0, l = revisionsRes.length; i < l; i++) {
const revision = revisionsRes[i]
revisionsMap.set(revision.id, revision)
}
// Set document metadata & get digital asset Ids
for(let i = 0, l = docs.length; i < l; i++) {
const doc = docs[i]
doc.categories = []
doc.user = (doc.userId) ? usersMap.get(doc.userId) : false
const categoryIds = doc.categoryIds || []
for(let j = 0, k = categoryIds.length; j < k; j++) {
const categoryId = categoryIds[j]
doc.categories.push(categoriesMap.get(categoryId))
}
if(!doc.permalink) {
doc.permalink = this.generatePermalink2(doc)
}
// Get the digital asset Ids to fetch for all fields in all document active revisions
if(expand) {
const revision = (doc.activeRevisionId) ? revisionsMap.get(doc.activeRevisionId) : {}
doc.fields = (revision && revision.fields) ? revision.fields : []
for(let i = 0, l = doc.fields.length; i < l; i++) {
const field = doc.fields[i]
if(field.props && field.props.digitalAssetId && field.props.digitalAssetId.length > 0) {
digitalAssetIds.push(field.props.digitalAssetId)
}
}
// Get digital assets
const digitalAssetsRes = await daModel.getAll(digitalAssetIds, {
applicationId: options.applicationId,
limit: digitalAssetIds.length,
logger: this.logger,
organizationId: options.organizationId
})
for(let i = 0, l = digitalAssetsRes.length; i < l; i++) {
const da = digitalAssetsRes[i]
digitalAssetsMap.set(da.id, da)
}
// Merge each digital asset to each document
for(let i = 0, l = docs.length; i < l; i++) {
const doc = docs[i]
const digitalAssetTypes = {}
const fields = doc.fields || []
doc.featuredImage = (doc.featuredImageId) ? digitalAssetsMap.get(doc.featuredImageId) : null
if(!doc.featuredImageId) doc.featuredImageId = '' // Helps React stay happy
for(let j = 0, k = fields.length; j < k; j++) {
const field = fields[j]
if(field.props && field.props.digitalAssetId && field.props.digitalAssetId.length > 0) {
const digitalAsset = digitalAssetsMap.get(field.props.digitalAssetId)
if(digitalAsset && digitalAsset.provider_name) {
let daType = digitalAsset.provider_name
if(daType) {
daType = daType.toLowerCase()
if(!digitalAssetTypes[daType]) digitalAssetTypes[daType] = 0
digitalAssetTypes[daType]++
}
}
field.props.digitalAsset = digitalAsset
}
}
doc.digitalAssetTypes = digitalAssetTypes
}
}
}
resolve(docs)
}
catch(e) {
this.logger.error(e)
reject(e)
}
})
}
/**
* Get all documents
*
* @public
*/
getAll(ids = [], options = {}) {
return new Promise( async (resolve, reject) => {
try {
if(!options.order) options.order = {}
options = {
applicationId: this.applicationId || options.applicationId,
categorySlugs: (options.categorySlugs) ? ((options.categorySlugs instanceof Array) ? options.categorySlugs : options.categorySlugs.split(',').map(slug => slug.toLowerCase())) : [],
expand: (options.expand && (options.expand === false || options.expand == 'false')) ? false : true, // Default to true
ignoreDocumentIds: (options.ignoreDocumentIds) ? ((options.ignoreDocumentIds instanceof Array) ? options.ignoreDocumentIds : options.ignoreDocumentIds.split(',')) : [],
order: {
by: options.orderBy || 'publishedAt',
dir: options.orderDir || 'desc'
},
page: options.page,
limit: options.limit,
organizationId: this.organizationId || options.organizationId,
status: (options.status) ? ((options.status instanceof Array) ? options.status : options.status.split(',').map(item => item.toLowerCase())) : ['draft', 'published', 'scheduled'],
tags: (options.tags) ? ((options.tags instanceof Array) ? options.tags : options.tags.split(',').map(tag => tag.toLowerCase())) : [],
taxonomyOperator: (options.taxonomyOperator && options.taxonomyOperator == 'and') ? 'and' : 'or',
userId: options.userId || '',
title: options.title || '',
}
const res = await this._getAllDocQuery(ids, options)
res.results = await this._getAllMetadataQuery(res.results, options)
resolve(res)
} catch(e) {
this.logger.error(e)
reject(e)
}
})
}
/**
* Get document by ID
*
* @public
* @param {String} id
*/
getById(id, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
const daModel = new DigitalAssetModel({
applicationId,
logger: this.logger,
organizationId
})
const userModel = new UserModel({
applicationId,
logger: this.logger,
organizationId
})
return new Promise( async (resolve, reject) => {
if(!id) {
this.logger.debug('Document getById was requested without id', {
applicationId,
organizationId
})
return reject(false)
}
try {
let doc
// Short Id
if(id.length < 10) {
doc = await r
.table(this.table)
.getAll([applicationId, id], {index: 'link_application_sid'})
.limit(1)
if(doc) {
doc = doc[0]
}
else {
return reject(false)
}
}
// Long Id
else {
doc = await RootModel.prototype.getById.call(this, id)
}
if(!doc) {
this.logger.debug(`Document ${id} was requested but not found`)
return reject({
code: 'DOCUMENT_NOT_FOUND'
})
}
const metadata = await Promise
.all([
(doc.userId) ? userModel.getById(doc.userId) : false,
this.getCategories(doc.id, doc),
this.getActiveRevision(doc)
])
const categories = metadata[1]
const revision = metadata[2]
const user = metadata[0]
const fields = revision.fields || []
const digitalAssetIds = []
const digitalAssetTypes = {}
if(doc.featuredImageId) {
digitalAssetIds.push(doc.featuredImageId)
}
if(doc.facebookImageId) {
digitalAssetIds.push(doc.facebookImageId)
}
if(doc.twitterImageId) {
digitalAssetIds.push(doc.twitterImageId)
}
// Get digital asset Ids
for(let i = 0, l = fields.length; i < l; i++) {
const field = fields[i]
if(field.props && field.props.digitalAssetId && field.props.digitalAssetId.length > 0) {
digitalAssetIds.push(field.props.digitalAssetId)
}
}
let digitalAssetsForFields = []
if (digitalAssetIds.length > 0) {
digitalAssetsForFields = await daModel.getAll(digitalAssetIds)
}
digitalAssetsForFields = digitalAssetsForFields || []
const digitalAssetMap = new Map()
for(let i = 0, l = digitalAssetsForFields.length; i < l; i++) {
const da = digitalAssetsForFields[i]
digitalAssetMap.set(da.id, da)
}
if(doc.featuredImageId) {
doc.featuredImage = digitalAssetMap.get(doc.featuredImageId)
}
if(doc.facebookImageId) {
doc.facebookImage = digitalAssetMap.get(doc.facebookImageId)
}
if(doc.twitterImageId) {
doc.twitterImage = digitalAssetMap.get(doc.twitterImageId)
}
// Get digital asset Ids
for(let i = 0, l = fields.length; i < l; i++) {
const field = fields[i]
if(field.props && field.props.digitalAssetId && field.props.digitalAssetId.length > 0) {
const digitalAsset = digitalAssetMap.get(field.props.digitalAssetId)
if(digitalAsset && digitalAsset.provider_name) {
let daType = digitalAsset.provider_name
daType = daType.toLowerCase()
if(!digitalAssetTypes[daType]) digitalAssetTypes[daType] = 0
digitalAssetTypes[daType]++
}
field.props.digitalAsset = digitalAsset
}
}
Object.assign(doc, {
categories,
digitalAssetTypes,
fields,
user
})
if(!doc.permalink) {
doc.permalink = this.generatePermalink2(doc)
}
resolve(doc)
}
catch(e) {
this.logger.error('Could not get document', {
err: e,
id
})
return reject(e)
}
})
}
getByPermalink(permalink) {
return new Promise( async (resolve, reject) => {
try {
const result = await r
.table(this.table)
.getAll(permalink, {index: 'permalink'})
.limit(1)
if(result instanceof Array && result.length > 0) {
resolve(result[0])
}
else {
resolve(null)
}
}
catch(e) {
this.logger.error('Could not get by permalink', e, {permalink})
reject(e)
}
})
}
/**
* Get document categories
*
* @public
* @param {String} id
*/
getCategories(id, doc, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
return new Promise( async (resolve, reject) => {
try {
if(!doc) {
doc = await this.getById(id)
}
const categoryIds = doc.categoryIds || []
const categoryRecords = await r.table('categories').getAll(r.args(categoryIds))
const categoryRecordsMap = {}
for(let i = 0, l = categoryRecords.length; i < l; i++) {
const category = categoryRecords[i]
categoryRecordsMap[category.id] = category
}
const categories = []
for(let i = 0, l = doc.categoryIds.length; i < l; i++) {
const categoryId = doc.categoryIds[i]
categories.push(categoryRecordsMap[categoryId])
}
resolve(categories)
}
catch(e) {
this.logger.error(e)
reject(e)
}
})
}
isPublished(pStatus, dStatus) {
const status = (['published', 'scheduled'].indexOf(pStatus) > -1) || // Document will be published
(['draft', 'deleted'].indexOf(pStatus) == -1 && ['published', 'scheduled'].indexOf(dStatus) > -1) // Document stays published
return status
}
prepareUpdatePkg(pkg = {}, doc = {}) {
if(pkg.publishedAt) {
try {
pkg.publishedAt = r.ISO8601(pkg.publishedAt)
}
catch(e) {
this.logger.error(e)
}
}
// Document has been marked as published, but did not supply a published date
else if(this.isPublished(pkg.status, doc.status) && !pkg.publishedAt && !doc.publishedAt) {
pkg.publishedAt = r.now()
}
// If slug was given
if(pkg.slug) {
pkg.slug = slug(pkg.slug)
}
// If no slug was given, and document does not have a slug
else if(doc.title && !doc.slug) {
pkg.slug = slug(doc.title)
}
// If the slug to be saved is not the slug that is published
if(pkg.slug && doc.slug && pkg.slug != doc.slug) {
pkg.previousSlugs = doc.previousSlugs || []
// Keep a copy of the old slug
if(pkg.previousSlugs.indexOf(doc.slug) == -1) {
pkg.previousSlugs.push(doc.slug)
}
}
}
/**
* Update document and revision
*
* @public
* @param {String} id
* @param {Object} pkg - The data to update for the document
*/
updateById(id, pkg = {}, options = {}) {
const applicationId = this.applicationId
const organizationId = this.organizationId
return new Promise( async (resolve, reject) => {
try {
var doc = null
if(id.length <= 10) {
doc = await r.table(this.table).filter({sid: id}).limit(1)
}
else {
doc = await r.table(this.table).get(id)
}
if(!doc || (doc && doc.length && doc.length == 0)) {
const message = 'Could update by id; could not locate document'
this.logger.error(message, {
id
})
return reject({
id, message
})
}
const pkgPublishedAt = Object.assign({}, pkg).publishedAt
const publishedAt = pkgPublishedAt || doc.publishedAt
this.prepareUpdatePkg(pkg, doc)
let fields = null
let revision = null
if(pkg.fields instanceof Array && pkg.fields.length > 0) {
fields = pkg.fields.slice(0)
delete pkg.fields
// In case `digitalAsset` property is sent back, don't save it
for(let i = 0, l = fields.length; i < l; i++) {
const field = fields[i]
if(field.props && field.props.digitalAsset) {
delete field.props.digitalAsset
}
}
// Create revision
const revisionPkg = {
fields,
organizationId: pkg.organizationId,
userId: pkg.userId
}
revisionPkg[this.localKey] = doc.id
revision = await new this.options.RevisionModel({logger: this.logger}).create(revisionPkg)
const revisionIds = doc.revisionIds || []
revisionIds.push(revision.id)
Object.assign(pkg, {
activeRevisionId: revision.id,
revisionIds
})
} else if (this.isPublished(pkg.status, doc.status)) {
// If the document is published or being published, if no fields are
// being provided, there must be an existing revision with fields
const activeRevision = await this.getActiveRevision(doc)
const fields = pkg.fields || activeRevision.fields || []
if (fields.length === 0) {
return reject(errorComposer.compose('DOCUMENT_MISSING_FIELDS', {
applicationId,
id,
organizationId
}, {status: 400}))
}
}
let update = await RootModel.prototype.updateById.call(this, doc.id, pkg)
const categories = await this.getCategories(update.id, update)
const permalink = this.generatePermalink2(update, categories)
update = await RootModel.prototype.updateById.call(this, doc.id, {permalink})
if(fields && revision && revision.fields) {
update.fields = revision.fields
}
update.permalink = permalink
resolve(update)
} catch(e) {
this.logger.error('Could not update document', {id, err: e})
reject(e)
}
})
}
updateMany(pkgs = []) {
return new Promise( async (resolve, reject) => {
try {
for(let i = 0, l = pkgs.length; i < l; i++) {
const pkg = pkgs[i]
this.prepareUpdatePkg(pkg)
}
const res = await r
.table(this.table)
.insert(pkgs, {
conflict: 'update',
returnChanges: true
})
const changes = []
for(let i = 0, l = res.changes.length; i < l; i++) {
const change = res.changes[i].new_val
changes.push(change)
}
resolve(changes)
}
catch(e) {
this.logger.error(e)
reject(e)
}
})
}
/**
* Generate article JSON for a document.
*
* @private
* @param {Object} doc - the document to generate body JSON for
*/
generateArticleJsonBody(doc) {
let body = []
for (const field of doc.fields) {
if (!field.props) {
continue
}
let components = []
if (field.props.header) {
components = components.concat(htmlToArticleJson(field.props.header))
}
if (field.props.copy) {
components = components.concat(htmlToArticleJson(field.props.copy))
}
if (field.props.value) {
components = components.concat(htmlToArticleJson(field.props.value))
}
if (field.props.digitalAsset) {
const mimetype = field.props.digitalAsset.mimetype
if (field.props.digitalAsset.html) {
components = components.concat(
htmlToArticleJson(field.props.digitalAsset.html)
)
} else if (mimetype && mimetype.match('image')) {
components = components.concat(
htmlToArticleJson(`<img src='http:${field.props.digitalAsset.url}' />`)
)
} else if (mimetype && mimetype.match('video')) {
components = components.concat(
htmlToArticleJson(`<video src='http:${field.props.digitalAsset.url}' />`)
)
}
}
body = body.concat(components)
}
if (doc.excerpt) {
const excerptJson = htmlToArticleJson(doc.excerpt)
body = excerptJson.concat(body)
}
return body
}
/*
* Sometimes embeds produce unescaped text, so ensure that they, and any
* other text components are properly escaped
*
* @private
* @param {Object} components - article json components to escape
* @returns {Object} escaped components
*/
ensureArticleJsonEscaped(components) {
for (const component of components) {
if (component.user) {
if (typeof component.user === 'string') {
component.user = escapeHtml(component.user)
}
if (typeof component.user === 'object' && component.user.name) {
component.user.name = escapeHtml(component.user.name)
}
}
if (component.children) {
component.children = this.ensureArticleJsonEscaped(component.children)
}
if (component.content) {
component.content = escapeHtml(component.content)
}
if (component.text) {
if (component.text instanceof Array) {
for (const text of component.text) {
if (text.content) {
text.content = escapeHtml(text.content)
}
}
} else {
component.text = escapeHtml(component.text)
}
}
}
return components
}
/**
* Generate 'Apple News Format' article JSON for a document.
*
* For more details on the format, see
* https://developer.apple.com/library/content/documentation/General/Conceptual/Apple_News_Format_Ref
*
* @private
* @param {Object} document ID - ID of the document to generate article JSON for
* @returns {Object} JSON representation of an apple news article
*/
async getAppleNewsFormat(documentId) {
const documentModel = new DocumentModel({
organizationId: this.organizationId,
applicationId: this.applicationId,
logger: this.logger,
})
const doc = await documentModel.getById(documentId)
const opts = Object.assign(defaultAppleNewsOpts, {
identifier: doc.slug,
})
const pkg = {
title: doc.title,
author: {name: `${doc.user.firstName} ${doc.user.lastName}`},
publishedDate: doc.publishedAt,
modifiedDate: doc.updatedAt,
canonicalURL: doc.permalink,
body: this.generateArticleJsonBody(doc)
}
if (doc.excerpt) {
const excerptJson = htmlToArticleJson(doc.excerpt)
opts.excerpt = excerptJson[0].children[0].content
}
if (doc.featuredImage) {
pkg.headerEmbed = htmlToArticleJson(`<img src='http:${doc.featuredImage.url}' />`)[0]
}
return convertToAppleNews(pkg, opts)
}
/**
* Generate Facebook Instant Article markup for a document.
*
* For more details on the format, see
* https://developers.facebook.com/docs/instant-articles/guides/format-overview
*
* @private
* @param {Object} document ID - ID of the document to generate article markup for
* @returns {Object} JSON representation of a facebook instant article
*/
async getFacebookIAFormat(documentId) {
const documentModel = new DocumentModel({
organizationId: this.organizationId,
applicationId: this.applicationId,
logger: this.logger,
})
const doc = await documentModel.getById(documentId)
const applicationModel = new ApplicationModel({
organizationId: doc.organizationId,
logger: this.logger,
})
const app = await applicationModel.getById(doc.applicationId)
const canonicalUrl = `http://${app.host}${app.facebook && app.facebook.fbiaPath || ''}${doc.permalink}`
const articleJson = this.generateArticleJsonBody(doc)
const escapedArticleJson = this.ensureArticleJsonEscaped(articleJson)
let body = convertToFbia(escapedArticleJson)
body = body.replace(/<\/img>/g, '') // Facebook doesn't accept img close tags
body = body.replace(/<\/?article>/g, '') // We need to add some more data to the article
const markup = (
`<!DOCTYPE html>`
+ `<html lang="en" prefix="op: http://media.facebook.com/op#">`
+ `<head>`
+ `<meta charset="utf-8"/>`
+ `<meta property="op:markup_version" content="v1.0">`
+ `<meta property="fb:use_automatic_ad_placement" content="enable=true ad_density=default">`
+ `<title>${doc.title}</title>`
+ `<link rel="canonical" href="${canonicalUrl}">`
+ `</head>`
+ `<body>`
+ `<article>`
+ `<header>`
+ `<h1>${doc.title}</h1>`
+ `<time class="op-published" datetime="${doc.publishedAt.toISOString()}">${formatDateString(doc.publishedAt)}</time>`
+ `<time class="op-modified" datetime="${doc.updatedAt.toISOString()}">${formatDateString(doc.updatedAt)}</time>`
+ `<address><a rel="facebook" href="${doc.user.facebook}">${doc.user.firstName} ${escapeHtml(doc.user.lastName)}</a></address>`
+ (doc.featuredImage ? `<figure><img src="http:${doc.featuredImage.url}"><figcaption>${escapeHtml(doc.featuredImage.attributionName)}</figcaption></figure>` : '')
+ `</header>`
+ body
+ `</article>`
+ `</body>`
+ `</html>`
)
return {
markup,
canonicalUrl
}
}
}
module.exports = DocumentModel