Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Format to the Scala CAPI client #312

Merged
merged 10 commits into from
Mar 1, 2021
Original file line number Diff line number Diff line change
@@ -1,44 +1,50 @@
package com.gu.contentapi.client.utils

import com.gu.contentapi.client.model.v1._
import com.gu.contentapi.client.utils.format._

import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter

object CapiModelEnrichment {

implicit class RichCapiDateTime(val cdt: CapiDateTime) extends AnyVal {
def toOffsetDateTime: OffsetDateTime = OffsetDateTime.parse(cdt.iso8601)
}
type ContentFilter = Content => Boolean

implicit class RichOffsetDateTime(val dt: OffsetDateTime) extends AnyVal {
def toCapiDateTime: CapiDateTime = CapiDateTime.apply(dt.toInstant.toEpochMilli, DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dt))
}
def getFromPredicate[T](content: Content, predicates: List[(ContentFilter, T)]): Option[T] =
predicates.collectFirst { case (predicate, t) if predicate(content) => t }

implicit class RichContent(val content: Content) extends AnyVal {
def tagExistsWithId(tagId: String): ContentFilter = content => content.tags.exists(tag => tag.id == tagId)

def designType: DesignType = {
def displayHintExistsWithName(displayHintName: String): ContentFilter = content => content.fields.flatMap(_.displayHint).contains(displayHintName)

val defaultDesignType = Article
def isLiveBloggingNow: ContentFilter = content => content.fields.flatMap(_.liveBloggingNow).contains(true)

type ContentFilter = Content => Boolean
val isImmersive: ContentFilter = content => displayHintExistsWithName("immersive")(content)

val isImmersive: ContentFilter = c => c.fields.flatMap(_.displayHint).contains("immersive")
val isMedia: ContentFilter = content => tagExistsWithId("type/audio")(content) || tagExistsWithId("type/video")(content) || tagExistsWithId("type/gallery")(content)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AR also has type/picture in this category, see guardian/apps-rendering#1146.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. I'll add it to a card for changes to make on a future version


def tagExistsWithId(tagId: String): ContentFilter = c => c.tags.exists(tag => tag.id == tagId)
val isReview: ContentFilter = content => tagExistsWithId("tone/reviews")(content) || tagExistsWithId("tone/livereview")(content) || tagExistsWithId("tone/albumreview")(content)

val isMedia: ContentFilter = c => tagExistsWithId("type/audio")(c) || tagExistsWithId("type/video")(c) || tagExistsWithId("type/gallery")(c)
val isLiveBlog: ContentFilter = content => isLiveBloggingNow(content) && tagExistsWithId("tone/minutebyminute")(content)

val isReview: ContentFilter = c => tagExistsWithId("tone/reviews")(c) || tagExistsWithId("tone/livereview")(c) || tagExistsWithId("tone/albumreview")(c)
val isDeadBlog: ContentFilter = content => !isLiveBloggingNow(content) && tagExistsWithId("tone/minutebyminute")(content)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need guardian/types#83 to match up with Types


def isComment: ContentFilter = c => tagExistsWithId("tone/comment")(c) || tagExistsWithId("tone/letters")(c)
implicit class RichCapiDateTime(val cdt: CapiDateTime) extends AnyVal {
def toOffsetDateTime: OffsetDateTime = OffsetDateTime.parse(cdt.iso8601)
}

implicit class RichOffsetDateTime(val dt: OffsetDateTime) extends AnyVal {
def toCapiDateTime: CapiDateTime = CapiDateTime.apply(dt.toInstant.toEpochMilli, DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(dt))
}

def liveBloggingNow: Boolean = content.fields.flatMap(_.liveBloggingNow).contains(true)
implicit class RichContent(val content: Content) extends AnyVal {

val liveBlog: ContentFilter = liveBloggingNow && tagExistsWithId("tone/minutebyminute")(_)
def designType: DesignType = {

val deadBlog: ContentFilter = !liveBloggingNow && tagExistsWithId("tone/minutebyminute")(_)
val isComment: ContentFilter = content => tagExistsWithId("tone/comment")(content) || tagExistsWithId("tone/letters")(content)
val defaultDesignType: DesignType = Article

val predicates: List[(ContentFilter, DesignType)] = List (
val predicates: List[(ContentFilter, DesignType)] = List(
tagExistsWithId("tone/advertisement-features") -> AdvertisementFeature,
tagExistsWithId("tone/matchreports") -> MatchReport,
tagExistsWithId("tone/quizzes") -> Quiz,
Expand All @@ -51,12 +57,146 @@ object CapiModelEnrichment {
tagExistsWithId("tone/analysis") -> Analysis,
isComment -> Comment,
tagExistsWithId("tone/features") -> Feature,
liveBlog -> Live,
deadBlog -> Article
isLiveBlog -> Live,
isDeadBlog -> Article
buck06191 marked this conversation as resolved.
Show resolved Hide resolved
)

val result = predicates.collectFirst { case (predicate, design) if predicate(content) => design }
val result = getFromPredicate(content, predicates)
result.getOrElse(defaultDesignType)
}
}

implicit class RenderingFormat(val content: Content) extends AnyVal {


def design: Design = {

val defaultDesign: Design = ArticleDesign

val isPhotoEssay: ContentFilter = content => content.fields.flatMap(_.displayHint).contains("photoessay")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you want to reuse your displayHintExistsWithName function here?

val isPhotoEssay: ContentFilter = content => displayHintExistsWithName("photoessay")(content)


val isInteractive: ContentFilter = content => content.`type` == ContentType.Interactive

val predicates: List[(ContentFilter, Design)] = List(
tagExistsWithId("artanddesign/series/guardian-print-shop") -> PrintShopDesign,
isMedia -> MediaDesign,
isReview -> ReviewDesign,
tagExistsWithId("tone/analysis") -> AnalysisDesign,
tagExistsWithId("tone/comment") -> CommentDesign,
tagExistsWithId("tone/letters") -> LetterDesign,
tagExistsWithId("tone/features") -> FeatureDesign,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. We don't currently have Letter as a Design in our decideDesign.ts function.
@oliverlloyd do you know if whether @guardian/types is correct or if DCR is?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@guardian/types trumps DCR. We haven't yet updated the version of the /types package we're using but when we do we will add support for Letter

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Letter is a recent addition: guardian/types#69

tagExistsWithId("tone/recipes") -> RecipeDesign,
tagExistsWithId("tone/matchreports") -> MatchReportDesign,
tagExistsWithId("tone/interview") -> InterviewDesign,
tagExistsWithId("tone/editorials") -> EditorialDesign,
tagExistsWithId("tone/quizzes") -> QuizDesign,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we also missing Interactive design? https://github.com/guardian/types/blob/main/src/format.ts#L32

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we technically support an Interactive design on DCR? It's not in our decideDesign function at least. If it does need supporting, I can add it in here and that way we'll just need to add it to the decideDesign function to translate it from the Scala JSON output to whatever Typescript needs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the be an Interactive Design type? I'm not sure what it represents? @JamieB-gu is there a need for this in Apps or could we remove it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's in the apps-rendering decision functions, so I'll add it in. If it's in here and then removed, it's not a difficult thing to rip out (which is part of the motivation for this work in the first place).

I think that the work here should be done with a focus on targeting a specific version of @guardian/types, and at this point this is ever so slightly ahead of the current version by having different Designs for dead and live blogs, as well as removing the Column display type. But after this initial step of aligning the two, the goal should be to update the two in parallel, with references to the appropriate version of each within the changelog

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design.Interactive is at the moment derived from ContentType.INTERACTIVE. I believe it's there so that we can build the empty placeholder articles for full-page interactives (ng-interactive/type/interactive - example here). Note that we explicitly render these without any styles because the interactive code takes over.

I'm curious as to how DCR models these kinds of articles if it doesn't use the Design 🤔?

isInteractive -> InteractiveDesign,
isPhotoEssay -> PhotoEssayDesign,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oliverlloyd are we keeping Photo Essays as a separate Design, or is this temporary until we subsume MultiImage into a normal body element?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is still at least one design aspects of Photo Essays that is unique: coloured captions. So unless we lose that it's here to stay

isLiveBlog -> LiveBlogDesign,
isDeadBlog -> DeadBlogDesign
)

val result = getFromPredicate(content, predicates)
result.getOrElse(defaultDesign)
}

def theme: Theme = {
val defaultTheme: Theme = NewsPillar

val specialReportTags: Set[String] = Set(
"business/series/undercover-in-the-chicken-industry",
"business/series/britains-debt-timebomb",
"world/series/this-is-europe",
"environment/series/the-polluters",
"news/series/hsbc-files",
"news/series/panama-papers",
"us-news/homan-square",
"uk-news/series/the-new-world-of-work",
"world/series/the-new-arrivals",
"news/series/nauru-files",
"us-news/series/counted-us-police-killings",
"australia-news/series/healthcare-in-detention",
"society/series/this-is-the-nhs"
)

def isPillar(pillar: String): ContentFilter = content => content.pillarName.contains(pillar)

val isSpecialReport: ContentFilter = content => content.tags.exists(t => specialReportTags(t.id))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens for special reports assigned in the targeting tool?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I did this off the back of different decision functions I found across the different codebases. If there's one missing, then we'll need to implement it in a future version. I imagine the process of integrating this all into the platforms will highlight missing areas and then we can update this to reflect that .

val isOpinion: ContentFilter = content => (tagExistsWithId("tone/comment")(content) && isPillar("News")(content)) ||
isPillar("Opinion")(content)
val isCulture: ContentFilter = content => isPillar("Arts")(content) || isPillar("Books")(content)

val predicates: List[(ContentFilter, Theme)] = List(
isOpinion -> OpinionPillar,
isPillar("Sport") -> SportPillar,
isCulture -> CulturePillar,
isPillar("Lifestyle") -> LifestylePillar,
isSpecialReport -> SpecialReportTheme,
tagExistsWithId("tone/advertisement-features") -> Labs,
)

val result = getFromPredicate(content, predicates)
result.getOrElse(defaultTheme)
}

def display: Display = {

val defaultDisplay = StandardDisplay

def hasShowcaseImage: ContentFilter = content => {
val hasShowcaseImage = for {
blocks <- content.blocks
main <- blocks.main
mainMedia = main.elements.head
imageTypeData <- mainMedia.imageTypeData
imageRole <- imageTypeData.role
} yield {
imageRole == "showcase"
}
hasShowcaseImage.getOrElse(false)
}

def hasShowcaseEmbed: ContentFilter = content => {

def isMainEmbed(elem: Element): Boolean = elem.relation == "main" && elem.`type` == ElementType.Embed

def hasShowcaseAsset(assets: scala.collection.Seq[Asset]): Boolean = {
val isShowcaseAsset = for {
embedAsset <- assets.find(asset => asset.`type` == AssetType.Embed)
typeData <- embedAsset.typeData
role <- typeData.role
} yield {
role == "showcase"
}
isShowcaseAsset.getOrElse(false)
}

val hasShowcaseEmbed = for {
elements <- content.elements
mainEmbed <- elements.find(isMainEmbed)
} yield {
hasShowcaseAsset(mainEmbed.assets)
}

hasShowcaseEmbed.getOrElse(false)
}

val isShowcase: ContentFilter = content => displayHintExistsWithName("column")(content) ||
displayHintExistsWithName("showcase")(content) ||
hasShowcaseImage(content) ||
hasShowcaseEmbed(content)

val isNumberedList: ContentFilter = displayHintExistsWithName("numberedList")

val predicates: List[(ContentFilter, Display)] = List(
isImmersive -> ImmersiveDisplay,
isShowcase -> ShowcaseDisplay,
isNumberedList -> NumberedListDisplay
Comment on lines +192 to +194

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A note here that we need to update https://github.com/guardian/types/blob/main/src/format.ts#L37-L43 to remove Column Display.

)

val result = getFromPredicate(content, predicates)
result.getOrElse(defaultDisplay)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.gu.contentapi.client.utils.format

sealed trait Design

case object ArticleDesign extends Design
case object MediaDesign extends Design
case object ReviewDesign extends Design
case object AnalysisDesign extends Design
case object CommentDesign extends Design
case object LetterDesign extends Design
case object FeatureDesign extends Design
case object LiveBlogDesign extends Design
case object DeadBlogDesign extends Design
case object RecipeDesign extends Design
case object MatchReportDesign extends Design
case object InterviewDesign extends Design
case object EditorialDesign extends Design
case object QuizDesign extends Design
case object InteractiveDesign extends Design
case object PhotoEssayDesign extends Design
case object PrintShopDesign extends Design
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gu.contentapi.client.utils.format

sealed trait Display

case object StandardDisplay extends Display
case object ImmersiveDisplay extends Display
case object ShowcaseDisplay extends Display
case object NumberedListDisplay extends Display
case object ColumnDisplay extends Display
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.gu.contentapi.client.utils.format

sealed trait Theme

sealed trait Pillar extends Theme
sealed trait Special extends Theme

case object NewsPillar extends Pillar
case object OpinionPillar extends Pillar
case object SportPillar extends Pillar
case object CulturePillar extends Pillar
case object LifestylePillar extends Pillar
case object SpecialReportTheme extends Special
case object Labs extends Special
Loading