UNPKG

paradigm-core

Version:
1,516 lines (1,150 loc) 39 kB
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, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&#039;") } } /** * 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