common/app/model/dotcomrendering/DotcomRenderingUtils.scala (267 lines of code) (raw):
package model.dotcomrendering
import com.github.nscala_time.time.Imports.DateTime
import com.gu.contentapi.client.model.v1.ElementType.Text
import com.gu.contentapi.client.model.v1.{Block => APIBlock, BlockElement => ClientBlockElement, Blocks => APIBlocks}
import com.gu.contentapi.client.utils.format.LiveBlogDesign
import com.gu.contentapi.client.utils.{AdvertisementFeature, DesignType}
import common.Edition
import conf.switches.Switches
import conf.{Configuration, Static}
import model.content.Atom
import model.dotcomrendering.pageElements.{PageElement, TextCleaner}
import model.pressed.{PressedContent, SpecialReport}
import model.{
ArticleDateTimes,
CanonicalLiveBlog,
ContentFormat,
ContentPage,
ContentType,
GUDateTimeFormatNew,
LiveBlogPage,
Pillar,
}
import org.joda.time.format.DateTimeFormat
import org.jsoup.Jsoup
import play.api.libs.json._
import play.api.mvc.RequestHeader
import views.support.AffiliateLinksCleaner
import java.net.URLEncoder
sealed trait DotcomRenderingMatchType
object DotcomRenderingMatchType {
implicit val matchTypesWrites: Writes[DotcomRenderingMatchType] = (matchType: DotcomRenderingMatchType) =>
JsString(matchType.toString)
}
case object CricketMatchType extends DotcomRenderingMatchType
case object FootballMatchType extends DotcomRenderingMatchType
case class DotcomRenderingMatchData(matchUrl: String, matchType: DotcomRenderingMatchType)
object DotcomRenderingUtils {
def makeMatchData(articlePage: ContentPage): Option[DotcomRenderingMatchData] = {
makeFootballMatch(articlePage).orElse(makeCricketMatch(articlePage))
}
def makeCricketMatch(articlePage: ContentPage): Option[DotcomRenderingMatchData] = {
val cricketDate = articlePage.item.content.cricketMatchDate
val cricketTeam = articlePage.item.content.cricketTeam
(cricketDate, cricketTeam) match {
case (Some(date), Some(team)) =>
Some(
DotcomRenderingMatchData(
s"${Configuration.ajax.url}/sport/cricket/match-scoreboard/$date/${team}.json",
CricketMatchType,
),
)
case _ => None
}
}
def makeFootballMatch(articlePage: ContentPage): Option[DotcomRenderingMatchData] = {
def extraction1(references: JsValue): Option[IndexedSeq[JsValue]] = {
val sequence = references match {
case JsArray(elements) => Some(elements)
case _ => None
}
sequence
}.map(_.toIndexedSeq)
def entryToDataPair(entry: JsValue): Option[(String, String)] = {
/*
Examples:
{
"esa-football-team": "/\" + \"football/\" + \"team/\" + \"331"
}
{
"pa-football-competition": "500"
}
{
"pa-football-team": "26305"
}
*/
val obj = entry.as[JsObject]
obj.fields.map(pair => (pair._1, pair._2.as[String])).headOption
}
val optionalUrl: Option[String] = for {
references <- articlePage.getJavascriptConfig.get("references")
entries1 <- extraction1(references)
entries2 =
entries1
.map(entryToDataPair)
.filter(_.isDefined)
.map(_.get) // .get is fundamentally dangerous but fine in this case because we filtered the Nones out.
.filter(_._1 == "pa-football-team")
} yield {
val pageId = URLEncoder.encode(articlePage.metadata.id, "UTF-8")
entries2.toList match {
case e1 :: e2 :: _ =>
val year = articlePage.item.trail.webPublicationDate.toString(DateTimeFormat.forPattern("yyy"))
val month = articlePage.item.trail.webPublicationDate.toString(DateTimeFormat.forPattern("MM"))
val day = articlePage.item.trail.webPublicationDate.toString(DateTimeFormat.forPattern("dd"))
s"${Configuration.ajax.url}/football/api/match-nav/$year/$month/$day/${e1._2}/${e2._2}.json?dcr=true&page=$pageId"
case _ => ""
}
}
// We need one more transformation because we could have a Some(""), which we don't want
optionalUrl match {
case Some(url) if url.nonEmpty => Some(DotcomRenderingMatchData(url, FootballMatchType))
case _ => None
}
}
def assetURL(bundlePath: String): String = {
// This function exists because for some reasons `Static` behaves differently in { PROD and CODE } versus LOCAL
if (Configuration.environment.isProd || Configuration.environment.isCode) {
Static(bundlePath)
} else {
s"${Configuration.site.host}${Static(bundlePath)}"
}
}
// note: this is duplicated in the onward service (DotcomponentsOnwardsModels - if duplicating again consider moving to common! :()
def findPillar(pillar: Option[Pillar], designType: Option[DesignType]): String = {
pillar
.map { pillar =>
if (designType.contains(AdvertisementFeature)) "labs"
else if (pillar.toString.toLowerCase == "arts") "culture"
else pillar.toString.toLowerCase()
}
.getOrElse("news")
}
def getKeyEventsIfFiltered(filterKeyEvents: Boolean, blocks: APIBlocks): Option[List[APIBlock]] = {
if (filterKeyEvents) {
blocks.requestedBodyBlocks.flatMap { requested =>
for {
keyEvents <- requested.get(CanonicalLiveBlog.timeline)
summaries <- requested.get(CanonicalLiveBlog.summary)
} yield {
val res: Seq[APIBlock] = (keyEvents.toSeq ++ summaries.toSeq)
orderBlocks(res).toList
}
}
} else None
}
def getLatest60Blocks(blocks: APIBlocks): Option[List[APIBlock]] = {
blocks.requestedBodyBlocks.flatMap(_.get(CanonicalLiveBlog.firstPage).map(_.toList))
}
def blocksForLiveblogPage(
liveblog: LiveBlogPage,
blocks: APIBlocks,
filterKeyEvents: Boolean,
): Seq[APIBlock] = {
// When the key events filter is on, we'd need all of the key events rather than just the latest 60 blocks
val allBlocks =
getKeyEventsIfFiltered(filterKeyEvents, blocks)
.orElse(getLatest60Blocks(blocks))
.getOrElse(List.empty)
// For the newest page, the latest 60 blocks are requested, but for other page,
// all of the blocks have been requested and returned in the blocks.body bit
// of the response so we use those
val relevantBlocks = if (allBlocks.isEmpty) blocks.body.getOrElse(Nil) else allBlocks
val ids = liveblog.currentPage.currentPage.blocks.map(_.id).toSet
relevantBlocks.filter(block => ids(block.id))
}.toSeq
def stringContainsAffiliateableLinks(textString: String): Boolean = {
AffiliateLinksCleaner.stringContainsAffiliateableLinks(textString)
}
def blockElementsToPageElements(
capiElems: Seq[ClientBlockElement],
request: RequestHeader,
article: ContentType,
affiliateLinks: Boolean,
isMainBlock: Boolean,
isImmersive: Boolean,
campaigns: Option[JsValue],
calloutsUrl: Option[String],
): List[PageElement] = {
val atoms: Iterable[Atom] = article.atoms.map(_.all).getOrElse(Seq())
val edition = Edition(request)
val elems = capiElems.toList
.flatMap(el =>
PageElement.make(
element = el,
addAffiliateLinks = affiliateLinks,
pageUrl = request.uri,
atoms = atoms,
isMainBlock,
isImmersive,
campaigns,
calloutsUrl,
article.elements.thumbnail,
edition,
article.trail.webPublicationDate,
),
)
.filter(PageElement.isSupported)
val withTagLinks =
if (article.content.isPaidContent) elems
else TextCleaner.tagLinks(elems, article.content.tags, article.content.showInRelated, edition)
withTagLinks
}
def isSpecialReport(page: ContentPage): Boolean =
page.item.content.cardStyle == SpecialReport
def secondaryDateString(content: ContentType, request: RequestHeader): String = {
def format(dt: DateTime, req: RequestHeader): String = GUDateTimeFormatNew.formatDateTimeForDisplay(dt, req)
val firstPublicationDate = content.fields.firstPublicationDate
val webPublicationDate = content.trail.webPublicationDate
val isModified = content.content.hasBeenModified && (!firstPublicationDate.contains(webPublicationDate))
if (isModified) {
"First published on " + format(firstPublicationDate.getOrElse(webPublicationDate), request)
} else {
"Last modified on " + format(content.fields.lastModified, request)
}
}
def withoutNull(json: JsValue): JsValue = {
json match {
case JsObject(fields) => JsObject(fields.filterNot { case (_, value) => value == JsNull })
case other => other
}
}
def withoutDeepNull(json: JsValue): JsValue = {
json match {
case JsObject(fields) =>
JsObject(fields.collect {
case (key, value) if value != JsNull => key -> withoutDeepNull(value)
})
case JsArray(values) =>
JsArray(values.collect {
case value if value != JsNull => withoutDeepNull(value)
})
case other => other
}
}
def shouldAddAffiliateLinks(content: ContentType): Boolean = {
val contentHtml = Jsoup.parse(content.fields.body)
val bodyElements = contentHtml.select("body").first().children()
/** On smaller devices, the disclaimer is inserted before paragraph 2 of the article body and floats left. This
* logic ensures there are two clear paragraphs of text at the top of the article. We don't support inserting the
* disclaimer next to other element types. It also ensures the second paragraph is long enough to accommodate the
* disclaimer appearing alongside it.
*/
if (bodyElements.size >= 2) {
val firstEl = bodyElements.get(0)
val secondEl = bodyElements.get(1)
if (firstEl.tagName == "p" && secondEl.tagName == "p" && secondEl.text().length >= 150) {
AffiliateLinksCleaner.shouldAddAffiliateLinks(
switchedOn = Switches.AffiliateLinks.isSwitchedOn,
showAffiliateLinks = content.content.fields.showAffiliateLinks,
alwaysOffTags = Configuration.affiliateLinks.alwaysOffTags,
tagPaths = content.content.tags.tags.map(_.id),
)
} else false
} else false
}
def contentDateTimes(content: ContentType): ArticleDateTimes = {
ArticleDateTimes(
webPublicationDate = content.trail.webPublicationDate,
firstPublicationDate = content.fields.firstPublicationDate,
hasBeenModified = content.content.hasBeenModified,
lastModificationDate = content.fields.lastModified,
)
}
def getModifiedContent(content: ContentType, forceLive: Boolean): ContentFormat = {
val originalFormat = content.metadata.format.getOrElse(ContentFormat.defaultContentFormat)
if (forceLive) {
originalFormat.copy(design = LiveBlogDesign)
} else {
originalFormat
}
}
def getMostRecentBlockId(blocks: APIBlocks): Option[String] = {
blocks.requestedBodyBlocks
.flatMap(_.get(CanonicalLiveBlog.firstPage))
.getOrElse(blocks.body.getOrElse(Seq.empty))
.headOption
.map(block => s"block-${block.id}")
}
def orderBlocks(blocks: Seq[APIBlock]): Seq[APIBlock] =
blocks.sortBy(block => block.firstPublishedDate.orElse(block.createdDate).map(_.dateTime)).reverse
def ensureSummaryTitle(block: APIBlock): APIBlock = {
if (block.attributes.summary.contains(true) && block.title.isEmpty) {
block.copy(title = Some("Summary"))
} else block
}
def getStoryPackage(
faciaItems: Seq[PressedContent],
requestHeader: RequestHeader,
): Option[OnwardCollectionResponse] = {
faciaItems match {
case Nil => None
case _ =>
Some(
OnwardCollectionResponse(
heading = "More on this story",
trails = faciaItems.map(faciaItem => Trail.pressedContentToTrail(faciaItem)(requestHeader)).take(10),
),
)
}
}
}