common/app/model/content/Atom.scala (397 lines of code) (raw):
package model.content
import com.gu.contentatom.thrift.atom.media.{Asset => AtomApiMediaAsset, MediaAtom => AtomApiMediaAtom}
import com.gu.contentatom.thrift.atom.timeline.{TimelineItem => TimelineApiItem}
import com.gu.contentatom.thrift.{
AtomData,
Atom => AtomApiAtom,
Image => AtomApiImage,
ImageAsset => AtomApiImageAsset,
atom => atomapi,
}
import com.madgag.scala.collection.decorators.MapDecorator
import enumeratum._
import model.{ImageAsset, ImageMedia, ShareLinkMeta}
import org.apache.commons.lang3.time.DurationFormatUtils
import org.joda.time.format.DateTimeFormat
import org.joda.time.{DateTime, DateTimeZone, Duration}
import play.api.libs.json.{JsError, JsSuccess, Json, OFormat}
import quiz._
import views.support.GoogleStructuredData
sealed trait Atom {
def id: String
}
// ----------------------------------------
// AudioAtom
// ----------------------------------------
final case class AudioAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.audio.AudioAtom,
) extends Atom
object AudioAtom {
def make(atom: AtomApiAtom): AudioAtom = {
val audio = atom.data.asInstanceOf[AtomData.Audio].audio
AudioAtom(atom.id, atom, audio)
}
}
// ----------------------------------------
// ChartAtom
// ----------------------------------------
final case class ChartAtom(
override val id: String,
atom: AtomApiAtom,
title: String,
css: String,
html: String,
mainJS: Option[String],
) extends Atom
object ChartAtom {
def make(atom: AtomApiAtom): ChartAtom = {
ChartAtom(atom.id, atom, atom.title.getOrElse("Chart"), "", atom.defaultHtml, None)
}
}
// ----------------------------------------
// CommonsDivisionAtom
// ----------------------------------------
final case class CommonsDivisionAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.commonsdivision.CommonsDivision,
) extends Atom
object CommonsDivisionAtom {
def make(atom: AtomApiAtom): CommonsDivisionAtom = {
val commonsdivision = atom.data.asInstanceOf[AtomData.CommonsDivision].commonsDivision
CommonsDivisionAtom(atom.id, atom, commonsdivision)
}
}
// ----------------------------------------
// ExplainerAtom
// ----------------------------------------
final case class ExplainerAtom(
override val id: String,
labels: Seq[String],
title: String,
body: String,
atom: AtomApiAtom,
) extends Atom
object ExplainerAtom {
def make(atom: AtomApiAtom): ExplainerAtom = {
val explainer = atom.data.asInstanceOf[AtomData.Explainer].explainer
ExplainerAtom(atom.id, explainer.tags.getOrElse(Nil).toSeq, explainer.title, explainer.body, atom)
}
}
// ----------------------------------------
// InteractiveAtom
// ----------------------------------------
final case class InteractiveAtom(
override val id: String,
`type`: String,
title: String,
css: String,
html: String,
mainJS: Option[String],
docData: Option[String],
placeholderUrl: Option[String],
) extends Atom
object InteractiveAtom {
def make(atom: AtomApiAtom): InteractiveAtom = {
val interactive = atom.data.asInstanceOf[AtomData.Interactive].interactive
InteractiveAtom(
id = atom.id,
`type` = interactive.`type`,
title = interactive.title,
css = interactive.css,
html = interactive.html,
mainJS = interactive.mainJS,
docData = interactive.docData,
placeholderUrl = interactive.placeholderUrl,
)
}
}
// ----------------------------------------
// GuideAtom
// ----------------------------------------
final case class GuideAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.guide.GuideAtom,
image: Option[ImageMedia],
) extends Atom {
def credit: Option[String] =
for {
img <- image
asset <- img.allImages.headOption
credit <- asset.credit
} yield credit
}
object GuideAtom {
def make(atom: AtomApiAtom): GuideAtom = {
val guide = atom.data.asInstanceOf[AtomData.Guide].guide
GuideAtom(atom.id, atom, guide, guide.guideImage.map(Atoms.atomImageToImageMedia))
}
}
// ----------------------------------------
// MediaAtom
// ----------------------------------------
final case class MediaAtom(
override val id: String,
defaultHtml: String,
assets: Seq[MediaAsset],
title: String,
duration: Option[Long],
source: Option[String],
posterImage: Option[ImageMedia],
expired: Option[Boolean],
activeVersion: Option[Long],
channelId: Option[String],
) extends Atom {
def activeAssets: Seq[MediaAsset] =
activeVersion
.map { version => assets.filter(_.version == version) }
.getOrElse(assets)
def isoDuration: Option[String] = {
duration.map(d => new Duration(Duration.standardSeconds(d)).toString)
}
def formattedDuration: Option[String] = {
duration.map { d =>
val jodaDuration = new Duration(Duration.standardSeconds(d))
val oneHour = new Duration(Duration.standardHours(1))
val durationPattern = if (jodaDuration.isShorterThan(oneHour)) "mm:ss" else "HH:mm:ss"
val formattedDuration = DurationFormatUtils.formatDuration(jodaDuration.getMillis, durationPattern, true)
"^0".r.replaceFirstIn(formattedDuration, "") // strip leading zero
}
}
}
final case class MediaAsset(
id: String,
version: Long,
platform: MediaAssetPlatform,
mimeType: Option[String],
)
sealed trait MediaAssetPlatform extends EnumEntry
object MediaAtom extends common.GuLogging {
def make(atom: AtomApiAtom): MediaAtom = {
val id = atom.id
val defaultHtml = atom.defaultHtml
val mediaAtom = atom.data.asInstanceOf[AtomData.Media].media
MediaAtom.mediaAtomMake(id, defaultHtml, mediaAtom)
}
def mediaAtomMake(id: String, defaultHtml: String, mediaAtom: AtomApiMediaAtom): MediaAtom = {
val expired: Option[Boolean] = for {
metadata <- mediaAtom.metadata
expiryDate <- metadata.expiryDate
} yield new DateTime(expiryDate).withZone(DateTimeZone.UTC).isBeforeNow
MediaAtom(
id = id,
defaultHtml = defaultHtml,
assets = mediaAtom.assets.map(mediaAssetMake).toSeq,
title = mediaAtom.title,
duration = mediaAtom.duration,
source = mediaAtom.source,
posterImage = mediaAtom.posterImage.map(imageMediaMake(_, mediaAtom.title)),
expired = expired,
activeVersion = mediaAtom.activeVersion,
channelId = mediaAtom.metadata.flatMap(_.channelId),
)
}
def imageMediaMake(capiImage: AtomApiImage, caption: String): ImageMedia = {
ImageMedia(capiImage.assets.map(mediaImageAssetMake(_, caption)).toSeq)
}
def mediaAssetMake(mediaAsset: AtomApiMediaAsset): MediaAsset = {
MediaAsset(
id = mediaAsset.id,
version = mediaAsset.version,
platform = MediaAssetPlatform.withName(mediaAsset.platform.name),
mimeType = mediaAsset.mimeType,
)
}
def mediaImageAssetMake(mediaImage: AtomApiImageAsset, caption: String): ImageAsset = {
ImageAsset(
mediaType = "image",
mimeType = mediaImage.mimeType,
url = Some(mediaImage.file),
fields = Map(
"height" -> mediaImage.dimensions.map(_.height).map(_.toString),
"width" -> mediaImage.dimensions.map(_.width).map(_.toString),
"caption" -> Some(caption),
"altText" -> Some(caption),
).collect { case (k, Some(v)) => (k, v) },
)
}
}
object MediaAssetPlatform extends Enum[MediaAssetPlatform] with PlayJsonEnum[MediaAssetPlatform] {
val values = findValues
case object Youtube extends MediaAssetPlatform
case object Facebook extends MediaAssetPlatform
case object Dailymotion extends MediaAssetPlatform
case object Mainstream extends MediaAssetPlatform
case object Url extends MediaAssetPlatform
}
// ----------------------------------------
// ProfileAtom
// ----------------------------------------
final case class ProfileAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.profile.ProfileAtom,
image: Option[ImageMedia],
) extends Atom {
def credit: Option[String] =
for {
img <- image
asset <- img.allImages.headOption
credit <- asset.credit
} yield credit
}
object ProfileAtom {
def make(atom: AtomApiAtom): ProfileAtom = {
val profile = atom.data.asInstanceOf[AtomData.Profile].profile
ProfileAtom(atom.id, atom, profile, profile.headshot.map(Atoms.atomImageToImageMedia))
}
}
// ----------------------------------------
// QandaAtom
// ----------------------------------------
final case class QandaAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.qanda.QAndAAtom,
image: Option[ImageMedia],
) extends Atom {
def credit: Option[String] =
for {
img <- image
asset <- img.allImages.headOption
credit <- asset.credit
} yield credit
}
object QandaAtom {
def make(atom: AtomApiAtom): QandaAtom = {
val qanda = atom.data.asInstanceOf[AtomData.Qanda].qanda
QandaAtom(atom.id, atom, qanda, qanda.eventImage.map(Atoms.atomImageToImageMedia))
}
}
// ----------------------------------------
// QuizAtom
// ----------------------------------------
final case class QuizAtom(
override val id: String,
title: String,
path: String,
quizType: String,
content: QuizContent,
revealAtEnd: Boolean,
shareLinks: ShareLinkMeta,
) extends Atom
object QuizAtom extends common.GuLogging {
implicit val assetFormat: OFormat[Asset] = Json.format[Asset]
implicit val imageFormat: OFormat[Image] = Json.format[Image]
private def transformAssets(quizAsset: Option[atomapi.quiz.Asset]): Option[QuizImageMedia] =
quizAsset.flatMap { asset =>
val parseResult = Json.parse(asset.data).validate[Image]
parseResult match {
case parsed: JsSuccess[Image] =>
val image = parsed.get
val typeData = image.fields.mapV(value => value.toString) - "caption"
val assets = for {
plainAsset <- image.assets
} yield {
ImageAsset(
fields = typeData ++ plainAsset.fields.mapV(value => value.toString),
mediaType = plainAsset.assetType,
mimeType = plainAsset.mimeType,
url = plainAsset.secureUrl.orElse(plainAsset.url),
)
}
if (assets.nonEmpty) Some(QuizImageMedia(ImageMedia(allImages = assets))) else None
case error: JsError =>
log.warn("Quiz atoms: asset json read errors: " + JsError.toFlatForm(error).toString())
None
}
}
def extractQuestions(quiz: atomapi.quiz.QuizAtom): Seq[Question] =
quiz.content.questions.map { question =>
val answers = question.answers.map { answer =>
Answer(
id = answer.id,
text = answer.answerText,
revealText = answer.revealText.flatMap(revealText => if (revealText != "") Some(revealText) else None),
weight = answer.weight.toInt,
buckets = answer.bucket.getOrElse(Nil).toSeq,
imageMedia = transformAssets(answer.assets.headOption),
)
}
Question(
id = question.id,
text = question.questionText,
answers = answers.toSeq,
imageMedia = transformAssets(question.assets.headOption),
)
}.toSeq
def extractResultGroups(resultGroups: Option[com.gu.contentatom.thrift.atom.quiz.ResultGroups]): Seq[ResultGroup] =
resultGroups
.map(_.groups.map { resultGroup =>
ResultGroup(
id = resultGroup.id,
title = resultGroup.title,
shareText = resultGroup.share,
minScore = resultGroup.minScore,
)
})
.getOrElse(Nil)
.toSeq
def extractContent(questions: Seq[Question], quiz: atomapi.quiz.QuizAtom): QuizContent =
QuizContent(
questions = questions,
resultGroups = extractResultGroups(quiz.content.resultGroups),
resultBuckets = quiz.content.resultBuckets
.map(resultBuckets => {
resultBuckets.buckets.map(resultBucket => {
ResultBucket(
id = resultBucket.id,
title = resultBucket.title,
shareText = resultBucket.share,
description = resultBucket.description,
)
})
})
.getOrElse(Nil)
.toSeq,
)
def make(path: String, atom: AtomApiAtom, shareLinks: ShareLinkMeta): QuizAtom = {
val quiz = atom.data.asInstanceOf[AtomData.Quiz].quiz
val questions = extractQuestions(quiz)
val content = extractContent(questions, quiz)
QuizAtom(
id = quiz.id,
path = path,
title = quiz.title,
quizType = quiz.quizType,
content = content,
revealAtEnd = quiz.revealAtEnd,
shareLinks = shareLinks,
)
}
}
// ----------------------------------------
// ReviewAtom
// ----------------------------------------
final case class ReviewAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.review.ReviewAtom,
) extends Atom
object ReviewAtom {
def make(atom: AtomApiAtom): ReviewAtom = ReviewAtom(atom.id, atom, atom.data.asInstanceOf[AtomData.Review].review)
def getLargestImageUrl(images: Seq[com.gu.contentatom.thrift.Image]): Option[String] = {
for {
image <- images.headOption
media = model.content.MediaAtom.imageMediaMake(image, "")
url <- GoogleStructuredData.bestSrcFor(media)
} yield url
}
}
// ----------------------------------------
// TimelineAtom
// ----------------------------------------
final case class TimelineAtom(
override val id: String,
atom: AtomApiAtom,
data: atomapi.timeline.TimelineAtom,
events: Seq[TimelineItem],
) extends Atom
final case class TimelineItem(
title: String,
date: DateTime,
body: Option[String],
toDate: Option[Long],
)
object TimelineAtom {
def make(atom: AtomApiAtom): TimelineAtom =
TimelineAtom(
atom.id,
atom,
atom.data.asInstanceOf[AtomData.Timeline].timeline,
events = atom.data.asInstanceOf[AtomData.Timeline].timeline.events.toSeq map TimelineItem.make,
)
def renderFormattedDate(date: Long, format: Option[String]): String = {
format match {
case Some("month-year") => DateTimeFormat.forPattern("MMMM yyyy").print(date)
case Some("year") => DateTimeFormat.forPattern("yyyy").print(date)
case _ => DateTimeFormat.forPattern("d MMMM yyyy").print(date)
}
}
}
object TimelineItem {
def make(item: TimelineApiItem): TimelineItem =
TimelineItem(
item.title,
new DateTime(item.date),
item.body,
item.toDate,
)
}