common/app/model/dotcomrendering/DotcomRenderingDataModel.scala (620 lines of code) (raw):

package model.dotcomrendering import com.gu.contentapi.client.model.v1.{Block => APIBlock, Blocks => APIBlocks} import com.gu.contentapi.client.utils.AdvertisementFeature import com.gu.contentapi.client.utils.format.{ImmersiveDisplay, InteractiveDesign} import common.Maps.RichMap import common.commercial.EditionCommercialProperties import common.{CanonicalLink, Chronos, Edition, Localisation, RichRequestHeader} import conf.Configuration import crosswords.CrosswordPageWithContent import experiments.ActiveExperiments import model.dotcomrendering.DotcomRenderingUtils._ import model.dotcomrendering.pageElements.{AudioBlockElement, ImageBlockElement, PageElement, Role, TextCleaner} import model.liveblog.BlockAttributes import model.{ ArticleDateTimes, Badges, CanonicalLiveBlog, ContentFormat, ContentPage, CrosswordData, DotcomContentType, GUDateTimeFormatNew, GalleryPage, ImageContentPage, InteractivePage, LiveBlogPage, MediaPage, PageWithStoryPackage, } import navigation._ import play.api.libs.json._ import play.api.mvc.RequestHeader import services.NewsletterData import views.support.{CamelCase, ContentLayout, JavaScriptPage} // ----------------------------------------------------------------- // DCR DataModel // ----------------------------------------------------------------- case class DotcomRenderingDataModel( version: Int, headline: String, standfirst: String, webTitle: String, affiliateLinksDisclaimer: Option[String], mainMediaElements: List[PageElement], main: String, filterKeyEvents: Boolean, pinnedPost: Option[Block], keyEvents: List[Block], mostRecentBlockId: Option[String], blocks: List[Block], pagination: Option[Pagination], author: Author, byline: Option[String], webPublicationDate: String, webPublicationDateDisplay: String, // TODO remove webPublicationSecondaryDateDisplay: String, editionLongForm: String, editionId: String, pageId: String, canonicalUrl: String, // Format and previous flags format: ContentFormat, designType: String, tags: List[Tag], pillar: String, isImmersive: Boolean, isLegacyInteractive: Boolean, sectionLabel: String, sectionUrl: String, sectionName: Option[String], subMetaSectionLinks: List[SubMetaLink], subMetaKeywordLinks: List[SubMetaLink], shouldHideAds: Boolean, isAdFreeUser: Boolean, webURL: String, linkedData: List[LinkedData], openGraphData: Map[String, String], twitterData: Map[String, String], config: JsObject, guardianBaseURL: String, contentType: String, hasRelated: Boolean, hasStoryPackage: Boolean, storyPackage: Option[OnwardCollectionResponse], beaconURL: String, isCommentable: Boolean, commercialProperties: Map[String, EditionCommercialProperties], pageType: PageType, starRating: Option[Int], audioArticleImage: Option[PageElement], trailText: String, nav: Nav, showBottomSocialButtons: Boolean, pageFooter: PageFooter, publication: String, shouldHideReaderRevenue: Boolean, slotMachineFlags: String, // slot machine (temporary for contributions development) contributionsServiceUrl: String, badge: Option[DCRBadge], matchUrl: Option[String], // Optional url used for match data matchType: Option[DotcomRenderingMatchType], isSpecialReport: Boolean, // Indicates whether the page is a special report. promotedNewsletter: Option[NewsletterData], showTableOfContents: Boolean, lang: Option[String], isRightToLeftLang: Boolean, crossword: Option[CrosswordData], ) object DotcomRenderingDataModel { implicit val pageElementWrites: Writes[PageElement] = PageElement.pageElementWrites implicit val writes: Writes[DotcomRenderingDataModel] = new Writes[DotcomRenderingDataModel] { def writes(model: DotcomRenderingDataModel) = { val obj = Json.obj( "version" -> model.version, "headline" -> model.headline, "standfirst" -> model.standfirst, "webTitle" -> model.webTitle, "affiliateLinksDisclaimer" -> model.affiliateLinksDisclaimer, "mainMediaElements" -> model.mainMediaElements, "main" -> model.main, "filterKeyEvents" -> model.filterKeyEvents, "pinnedPost" -> model.pinnedPost, "keyEvents" -> model.keyEvents, "mostRecentBlockId" -> model.mostRecentBlockId, "blocks" -> model.blocks, "pagination" -> model.pagination, "author" -> model.author, "byline" -> model.byline, "webPublicationDate" -> model.webPublicationDate, "webPublicationDateDeprecated" -> model.webPublicationDate, "webPublicationDateDisplay" -> model.webPublicationDateDisplay, "webPublicationSecondaryDateDisplay" -> model.webPublicationSecondaryDateDisplay, "editionLongForm" -> model.editionLongForm, "editionId" -> model.editionId, "pageId" -> model.pageId, "canonicalUrl" -> model.canonicalUrl, "format" -> model.format, "designType" -> model.designType, "tags" -> model.tags, "pillar" -> model.pillar, "isLegacyInteractive" -> model.isLegacyInteractive, "isImmersive" -> model.isImmersive, "sectionLabel" -> model.sectionLabel, "sectionUrl" -> model.sectionUrl, "sectionName" -> model.sectionName, "subMetaSectionLinks" -> model.subMetaSectionLinks, "subMetaKeywordLinks" -> model.subMetaKeywordLinks, "shouldHideAds" -> model.shouldHideAds, "isAdFreeUser" -> model.isAdFreeUser, "webURL" -> model.webURL, "linkedData" -> model.linkedData, "openGraphData" -> model.openGraphData, "twitterData" -> model.twitterData, "config" -> model.config, "guardianBaseURL" -> model.guardianBaseURL, "contentType" -> model.contentType, "hasRelated" -> model.hasRelated, "hasStoryPackage" -> model.hasStoryPackage, "storyPackage" -> model.storyPackage, "beaconURL" -> model.beaconURL, "isCommentable" -> model.isCommentable, "commercialProperties" -> model.commercialProperties, "pageType" -> model.pageType, "starRating" -> model.starRating, "audioArticleImage" -> model.audioArticleImage, "trailText" -> model.trailText, "nav" -> model.nav, "showBottomSocialButtons" -> model.showBottomSocialButtons, "pageFooter" -> model.pageFooter, "publication" -> model.publication, "shouldHideReaderRevenue" -> model.shouldHideReaderRevenue, "slotMachineFlags" -> model.slotMachineFlags, "contributionsServiceUrl" -> model.contributionsServiceUrl, "badge" -> model.badge, "matchUrl" -> model.matchUrl, "matchType" -> model.matchType, "isSpecialReport" -> model.isSpecialReport, "promotedNewsletter" -> model.promotedNewsletter, "showTableOfContents" -> model.showTableOfContents, "lang" -> model.lang, "isRightToLeftLang" -> model.isRightToLeftLang, "crossword" -> model.crossword, ) ElementsEnhancer.enhanceDcrObject(obj) } } def toJson(model: DotcomRenderingDataModel): JsValue = { val jsValue = Json.toJson(model) withoutNull(jsValue) } def forInteractive( page: InteractivePage, blocks: APIBlocks, request: RequestHeader, pageType: PageType, ): DotcomRenderingDataModel = { val baseUrl = if (request.isAmp) Configuration.amp.baseUrl else Configuration.dotcom.baseUrl apply( page = page, request = request, pagination = None, linkedData = LinkedData.forInteractive( interactive = page.item, baseURL = baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ), mainBlock = blocks.main, bodyBlocks = blocks.body.getOrElse(Nil).toSeq, pageType = pageType, hasStoryPackage = page.related.hasStoryPackage, storyPackage = getStoryPackage(page.related.faciaItems, request), pinnedPost = None, keyEvents = Nil, newsletter = None, ) } def forArticle( page: PageWithStoryPackage, // for now, any non-liveblog page type blocks: APIBlocks, request: RequestHeader, pageType: PageType, newsletter: Option[NewsletterData], ): DotcomRenderingDataModel = { val baseUrl = if (request.isAmp) Configuration.amp.baseUrl else Configuration.dotcom.baseUrl val linkedData = LinkedData.forArticle( article = page.article, baseURL = baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) apply( page = page, request = request, pagination = None, linkedData = linkedData, mainBlock = blocks.main, bodyBlocks = blocks.body.getOrElse(Nil).toSeq, pageType = pageType, hasStoryPackage = page.related.hasStoryPackage, storyPackage = getStoryPackage(page.related.faciaItems, request), pinnedPost = None, keyEvents = Nil, newsletter = newsletter, ) } def forMedia( mediaPage: MediaPage, request: RequestHeader, pageType: PageType, blocks: APIBlocks, ) = { val linkedData = LinkedData.forArticle( article = mediaPage.media, baseURL = Configuration.dotcom.baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) apply( page = mediaPage, request = request, pageType = pageType, linkedData = linkedData, mainBlock = blocks.main, bodyBlocks = blocks.body.getOrElse(Nil).toSeq, hasStoryPackage = mediaPage.related.hasStoryPackage, storyPackage = getStoryPackage(mediaPage.related.faciaItems, request), ) } def forImageContent( imageContentPage: ImageContentPage, request: RequestHeader, pageType: PageType, mainBlock: Option[APIBlock], ) = { val linkedData = LinkedData.forArticle( article = imageContentPage.image, baseURL = Configuration.dotcom.baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) apply( page = imageContentPage, request = request, pageType = pageType, linkedData = linkedData, mainBlock = mainBlock, bodyBlocks = Seq.empty, hasStoryPackage = imageContentPage.related.hasStoryPackage, storyPackage = getStoryPackage(imageContentPage.related.faciaItems, request), ) } def forGallery( galleryPage: GalleryPage, request: RequestHeader, pageType: PageType, blocks: APIBlocks, ) = { val linkedData = LinkedData.forArticle( article = galleryPage.gallery, baseURL = Configuration.dotcom.baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) apply( page = galleryPage, request = request, pageType = pageType, linkedData = linkedData, mainBlock = blocks.main, bodyBlocks = blocks.body.getOrElse(Nil).toSeq, hasStoryPackage = galleryPage.related.hasStoryPackage, storyPackage = getStoryPackage(galleryPage.related.faciaItems, request), ) } def forCrossword( crosswordPage: CrosswordPageWithContent, request: RequestHeader, pageType: PageType, ): DotcomRenderingDataModel = { val linkedData = LinkedData.forArticle( article = crosswordPage.item, baseURL = Configuration.dotcom.baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) apply( page = crosswordPage, request = request, pageType = pageType, linkedData = linkedData, mainBlock = None, bodyBlocks = Seq.empty, crossword = Some(crosswordPage.crossword), ) } def keyEventsFallback( blocks: APIBlocks, ): Seq[APIBlock] = { blocks.requestedBodyBlocks match { case Some(requestedBlocks) => val keyEvent = requestedBlocks.getOrElse(CanonicalLiveBlog.timeline, Seq.empty[APIBlock]) val summaryEvent = requestedBlocks.getOrElse(CanonicalLiveBlog.summary, Seq.empty[APIBlock]) keyEvent.toSeq ++ summaryEvent.toSeq case None => Seq.empty[APIBlock] } } def forLiveblog( page: LiveBlogPage, blocks: APIBlocks, request: RequestHeader, pageType: PageType, filterKeyEvents: Boolean, forceLive: Boolean, newsletter: Option[NewsletterData], ): DotcomRenderingDataModel = { val pagination = page.currentPage.pagination.map(paginationInfo => { Pagination( currentPage = page.currentPage.currentPage.pageNumber, totalPages = paginationInfo.numberOfPages, newest = paginationInfo.newest.map(_.suffix), newer = paginationInfo.newer.map(_.suffix), oldest = paginationInfo.oldest.map(_.suffix), older = paginationInfo.older.map(_.suffix), ) }) val bodyBlocks = blocksForLiveblogPage(page, blocks, filterKeyEvents).map(ensureSummaryTitle) val allTimelineBlocks = blocks.body match { case Some(allBlocks) if allBlocks.nonEmpty => allBlocks.filter(block => block.attributes.keyEvent.contains(true) || block.attributes.summary.contains(true)) case _ => keyEventsFallback(blocks) } val timelineBlocks = orderBlocks(allTimelineBlocks.toSeq).map(ensureSummaryTitle) val baseUrl = if (request.isAmp) Configuration.amp.baseUrl else Configuration.dotcom.baseUrl val linkedData = LinkedData.forLiveblog( liveblog = page, blocks = bodyBlocks, baseURL = baseUrl, fallbackLogo = Configuration.images.fallbackLogo, ) val pinnedPost = blocks.requestedBodyBlocks .flatMap(_.get("body:pinned")) .getOrElse(blocks.body.fold(Seq.empty[APIBlock])(_.filter(_.attributes.pinned.contains(true)).toSeq)) .headOption .map(ensureSummaryTitle) val mostRecentBlockId = getMostRecentBlockId(blocks) apply( page = page, request = request, pagination = pagination, linkedData = linkedData, mainBlock = blocks.main, bodyBlocks = bodyBlocks, pageType = pageType, hasStoryPackage = page.related.hasStoryPackage, storyPackage = getStoryPackage(page.related.faciaItems, request), // todo pinnedPost = pinnedPost, keyEvents = timelineBlocks, filterKeyEvents = filterKeyEvents, mostRecentBlockId = mostRecentBlockId, forceLive = forceLive, newsletter = newsletter, ) } def apply( page: ContentPage, request: RequestHeader, pageType: PageType, linkedData: List[LinkedData], bodyBlocks: Seq[APIBlock], mainBlock: Option[APIBlock] = None, hasStoryPackage: Boolean = false, storyPackage: Option[OnwardCollectionResponse] = None, newsletter: Option[NewsletterData] = None, keyEvents: Seq[APIBlock] = Seq.empty, pagination: Option[Pagination] = None, pinnedPost: Option[APIBlock] = None, filterKeyEvents: Boolean = false, mostRecentBlockId: Option[String] = None, forceLive: Boolean = false, crossword: Option[CrosswordData] = None, ): DotcomRenderingDataModel = { val edition = Edition.edition(request) val content = page.item val isImmersive = content.metadata.format.exists(_.display == ImmersiveDisplay) val isPaidContent = content.metadata.designType.contains(AdvertisementFeature) /** @deprecated – Use byline instead */ val author: Author = Author( byline = content.trail.byline, twitterHandle = content.tags.contributors.headOption.flatMap(_.properties.twitterHandle), ) def hasAffiliateLinks( blocks: Seq[APIBlock], ): Boolean = { blocks.exists(block => DotcomRenderingUtils.stringContainsAffiliateableLinks(block.bodyHtml)) } val shouldAddAffiliateLinks = DotcomRenderingUtils.shouldAddAffiliateLinks(content) val shouldAddDisclaimer = hasAffiliateLinks(bodyBlocks) val contentDateTimes: ArticleDateTimes = ArticleDateTimes( webPublicationDate = content.trail.webPublicationDate, firstPublicationDate = content.fields.firstPublicationDate, hasBeenModified = content.content.hasBeenModified, lastModificationDate = content.fields.lastModified, ) val switches: Map[String, Boolean] = conf.switches.Switches.all .filter(_.exposeClientSide) .foldLeft(Map.empty[String, Boolean])((acc, switch) => { acc + (CamelCase.fromHyphenated(switch.name) -> switch.isSwitchedOn) }) val config = Config( switches = switches, abTests = ActiveExperiments.getJsMap(request), ampIframeUrl = assetURL("data/vendor/amp-iframe.html"), googletagUrl = Configuration.googletag.jsLocation, stage = common.Environment.stage, frontendAssetsFullURL = Configuration.assets.fullURL(common.Environment.stage), ) val combinedConfig: JsObject = { val jsPageConfig: Map[String, JsValue] = JavaScriptPage.getMap(page, Edition(request), pageType.isPreview, request) Json.toJsObject(config).deepMerge(JsObject(jsPageConfig)) } val calloutsUrl: Option[String] = combinedConfig.fields.toList .find(_._1 == "calloutsUrl") .flatMap(entry => entry._2.asOpt[String]) val dcrTags = content.tags.tags.map(Tag.apply) val audioImageBlock: Option[ImageBlockElement] = if (page.metadata.contentType.contains(DotcomContentType.Audio)) { for { thumbnail <- page.item.elements.thumbnail } yield { val imageData = thumbnail.images.allImages.headOption .map { d => Map( "copyright" -> "", "alt" -> d.altText.getOrElse(""), "caption" -> d.caption.getOrElse(""), "credit" -> d.credit.getOrElse(""), ) } .getOrElse(Map.empty) ImageBlockElement( thumbnail.images, imageData, Some(true), Role(Some("inline")), Seq.empty, ) } } else { None } def toDCRBlock(isMainBlock: Boolean = false) = { block: APIBlock => Block( block = block, page = page, shouldAddAffiliateLinks = shouldAddAffiliateLinks, request = request, isMainBlock = isMainBlock, calloutsUrl = calloutsUrl, dateTimes = contentDateTimes, tags = dcrTags, ) } val mainMediaElements = { val pageElements = mainBlock .map(toDCRBlock(isMainBlock = true)) .toList .flatMap(_.elements) page.metadata.contentType match { case Some(DotcomContentType.Audio) => pageElements .map { case AudioBlockElement(assets, _) => AudioBlockElement(assets, content.elements.mainAudio.map(_.properties.id)) case pageElement => pageElement } case _ => pageElements } } val bodyBlocksDCR = bodyBlocks .filter(_.published || pageType.isPreview) // TODO lift? .map(toDCRBlock()) .toList val keyEventsDCR = keyEvents.map(toDCRBlock()) val pinnedPostDCR = pinnedPost.map(toDCRBlock()) val commercial: Commercial = { val editionCommercialProperties = content.metadata.commercial .map { _.perEdition.mapKeys(_.id) } .getOrElse(Map.empty[String, EditionCommercialProperties]) val prebidIndexSites = (for { commercial <- content.metadata.commercial sites <- commercial.prebidIndexSites } yield sites.toList).getOrElse(List()) Commercial( editionCommercialProperties, prebidIndexSites, content.metadata.commercial, pageType, ) } val modifiedFormat = getModifiedContent(content, forceLive) val isLegacyInteractive = modifiedFormat.design == InteractiveDesign && content.trail.webPublicationDate .isBefore(Chronos.javaTimeLocalDateTimeToJodaDateTime(InteractiveSwitchOver.date)) val matchData = makeMatchData(page) def addAffiliateLinksDisclaimerDCR(shouldAddAffiliateLinks: Boolean, shouldAddDisclaimer: Boolean) = { if (shouldAddAffiliateLinks && shouldAddDisclaimer) { Some("true") } else { None } } DotcomRenderingDataModel( affiliateLinksDisclaimer = addAffiliateLinksDisclaimerDCR(shouldAddAffiliateLinks, shouldAddDisclaimer), audioArticleImage = audioImageBlock, author = author, badge = Badges.badgeFor(content).map(badge => DCRBadge(badge.seriesTag, badge.imageUrl)), beaconURL = Configuration.debug.beaconUrl, blocks = bodyBlocksDCR, byline = content.trail.byline, commercialProperties = commercial.editionCommercialProperties, config = combinedConfig, contentType = content.metadata.contentType.map(_.name).getOrElse(""), contributionsServiceUrl = Configuration.contributionsService.url, designType = content.metadata.designType.map(_.toString).getOrElse("Article"), editionId = edition.id, editionLongForm = Edition(request).displayName, format = modifiedFormat, guardianBaseURL = Configuration.site.host, hasRelated = content.content.showInRelated, hasStoryPackage = hasStoryPackage, storyPackage = storyPackage, headline = content.trail.headline, isAdFreeUser = views.support.Commercial.isAdFree(request), isCommentable = content.trail.isCommentable, isImmersive = isImmersive, isLegacyInteractive = isLegacyInteractive, isSpecialReport = isSpecialReport(page), filterKeyEvents = filterKeyEvents, pinnedPost = pinnedPostDCR, keyEvents = keyEventsDCR.toList, mostRecentBlockId = mostRecentBlockId, linkedData = linkedData, main = content.fields.main, mainMediaElements = mainMediaElements, matchUrl = matchData.map(_.matchUrl), matchType = matchData.map(_.matchType), nav = Nav(page, edition), openGraphData = page.getOpenGraphProperties, pageFooter = PageFooter(FooterLinks.getFooterByEdition(Edition(request))), pageId = content.metadata.id, canonicalUrl = CanonicalLink(request, content.metadata.webUrl), pageType = pageType, // TODO this info duplicates what is already elsewhere in format? pagination = pagination, pillar = findPillar(content.metadata.pillar, content.metadata.designType), publication = content.content.publication, sectionLabel = Localisation(content.content.sectionLabelName.getOrElse(""))(request), sectionName = content.metadata.section.map(_.value), sectionUrl = content.content.sectionLabelLink.getOrElse(""), shouldHideAds = content.content.shouldHideAdverts, shouldHideReaderRevenue = content.fields.shouldHideReaderRevenue.getOrElse(isPaidContent), showBottomSocialButtons = ContentLayout.showBottomSocialButtons(content), slotMachineFlags = request.slotMachineFlags, standfirst = TextCleaner.sanitiseLinks(edition)(content.fields.standfirst.getOrElse("")), starRating = content.content.starRating, subMetaKeywordLinks = content.content.submetaLinks.keywords.map(SubMetaLink.apply), subMetaSectionLinks = content.content.submetaLinks.sectionLabels.map(SubMetaLink.apply).filter(_.title.trim.nonEmpty), tags = dcrTags, trailText = TextCleaner.sanitiseLinks(edition)(content.trail.fields.trailText.getOrElse("")), twitterData = page.getTwitterProperties, version = 3, webPublicationDate = content.trail.webPublicationDate.toString, webPublicationDateDisplay = GUDateTimeFormatNew.formatDateTimeForDisplay(content.trail.webPublicationDate, request), webPublicationSecondaryDateDisplay = secondaryDateString(content, request), webTitle = content.metadata.webTitle, webURL = content.metadata.webUrl, promotedNewsletter = newsletter, showTableOfContents = content.fields.showTableOfContents.getOrElse(false), lang = content.fields.lang, isRightToLeftLang = content.fields.isRightToLeftLang, crossword = crossword, ) } }