All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.mchange.feedletter.Destination.scala Maven / Gradle / Ivy

package com.mchange.feedletter

import upickle.default._

import com.mchange.conveniences.www.*
import com.mchange.mailutil.Smtp
import scala.collection.StringOps

// Note: CSV headers and fields that need to be quoted
//       are quoted already when generated via `csvRowHeaders` and `toCsvRow`.
//       Just put commas between 'em and newlines at their end
object Destination:
  val  Json = DestinationJson
  type Json = DestinationJson

  trait TypeMetaInfo[T <: Destination]:
    def csvRowHeaders : Seq[String]

  private def q(s : String) = s""""$s""""

  object CsvRowHeaders:
    val Email    = Seq(q("E-Mail"), q("Display Name"))
    val Mastodon = Seq(q("Instance URL"), q("Name"))
    val Sms      = Seq(q("Phone Number"))
  given TypeMetaInfo[Email] = new TypeMetaInfo[Email] { def csvRowHeaders : Seq[String] = CsvRowHeaders.Email }
  given TypeMetaInfo[Mastodon] = new TypeMetaInfo[Mastodon] { def csvRowHeaders : Seq[String] = CsvRowHeaders.Mastodon }
  given TypeMetaInfo[Sms] = new TypeMetaInfo[Sms] { def csvRowHeaders : Seq[String] = CsvRowHeaders.Sms }

  def csvRowHeaders[T <: Destination](using TypeMetaInfo[T]) =
    summon[TypeMetaInfo[T]].csvRowHeaders

  private object Tag:
    def valueOfIgnoreCaseOption( s : String ) : Option[Tag] = Tag.values.find( _.toString.equalsIgnoreCase(s) )
  private enum Tag:
    case Email, Sms, Mastodon

  private enum Key:
    def s = this.toString
    case addressPart,displayNamePart,number,name,instanceUrl,version,`type`,destinationType

  import Key.*

  object Email:
    def apply( address : Smtp.Address ) : Email = Email( address.email, address.displayName )
    def apply( address : String )       : Email = this.apply( Smtp.Address.parseSingle( address ) )
  case class Email( addressPart : String, displayNamePart : Option[String] ) extends Destination:
    lazy val toAddress : Smtp.Address = Smtp.Address( addressPart, displayNamePart )
    lazy val rendered = toAddress.rendered
    override def unique = s"e-mail:${addressPart}"
    override def toFields = Seq( destinationType.s -> Tag.Email.toString, Key.addressPart.s -> this.addressPart) ++ this.displayNamePart.map( dnp => Key.displayNamePart.s -> dnp )
    override def shortDesc : String = this.addressPart 
    override def fullDesc : String = this.rendered
    override def defaultDesc : String = fullDesc
    override def toCsvRow : Seq[String] = Seq( addressPart, q(displayNamePart.getOrElse("")) )

  case class Mastodon( name : String, instanceUrl : String ) extends Destination:
    override def unique = "mastodon:" + wwwFormEncodeUTF8(("name",name),("instanceUrl",instanceUrl))
    override def toFields = Seq( destinationType.s -> Tag.Mastodon.toString, Key.name.s -> this.name, Key.instanceUrl.s -> this.instanceUrl )
    override def shortDesc : String = this.instanceUrl
    override def fullDesc : String = s"Mastodon nicknamed '${name}' instance at ${instanceUrl}"
    override def defaultDesc : String = shortDesc
    override def toCsvRow : Seq[String] = Seq( instanceUrl, q(name) )

  case class Sms( number : String ) extends Destination:
    override def unique = s"sms:${number}"
    override def toFields = Seq( destinationType.s -> Tag.Sms.toString, Key.number.s -> this.number )
    override def shortDesc : String = this.number
    override def fullDesc : String = s"SMS destination '${number}'"
    override def defaultDesc : String = shortDesc
    override def toCsvRow : Seq[String] = Seq( q(number) )

  def materialize( json : Destination.Json ) : Destination = read[Destination]( json.toString )

  object fromFields:
    private class CarefulMap( val rawFields : Seq[(String,String)] ):
      val fields = rawFields.filter( (k,v) => k.trim.nonEmpty && v.trim.nonEmpty ) // neither blank keys or values are acceptabl
      val dupKeys = fields.toSet.groupBy( _(0) ).filter( _(1).size > 1 ).keySet
      val asMap = fields.toMap
      def get( k : String ) : Option[String] =
        if dupKeys(k) then
          val values = fields.collect { case (`k`, v) => v }
          throw new DuplicateKey( s"Cannot resolve unique value for field '$k'. Values: " + values.mkString(", ") )
        else
          asMap.get(k)
    def email( fields : Seq[(String,String)] ) : Option[Destination.Email] = email( CarefulMap(fields) )
    private def email( fmap : CarefulMap ) : Option[Destination.Email] =
      def fromFieldPair( kap : String, kdnp : String ) : Option[Destination.Email] =
        for
          ap <- fmap.get(kap)
        yield
          fmap.get(kdnp).fold( Email(Smtp.Address.parseSingle(ap)) )( dnp => Email( addressPart=ap, displayNamePart=Some(dnp) ) )
      def fromActualFields = fromFieldPair(addressPart.s,displayNamePart.s)
      def fromFriendlierFields = fromFieldPair("address","displayName")
      fromActualFields orElse fromFriendlierFields
    def mastodon( fields : Seq[(String,String)] ) : Option[Destination.Mastodon] = mastodon( CarefulMap( fields ) )
    private def mastodon( fmap : CarefulMap ) : Option[Destination.Mastodon] =
      for
        nm <- fmap.get( name.s )
        iu <- fmap.get( instanceUrl.s )
      yield
        Mastodon( name = nm, instanceUrl = iu )
    def sms( fields : Seq[(String,String)] ) : Option[Destination.Sms] = sms( CarefulMap( fields ) )
    private def sms( fmap : CarefulMap ) : Option[Destination.Sms] =
      for
        nu <- fmap.get( number.s )
      yield
        Sms( number = nu )
    private def byType( tpe : String, fmap : CarefulMap ) : Option[Destination] =
      Tag.valueOfIgnoreCaseOption(tpe) match
        case Some( Tag.Email )    => email(fmap)
        case Some( Tag.Mastodon ) => mastodon(fmap)
        case Some( Tag.Sms )      => sms(fmap)
        case None                 => None
    def apply( fields : Seq[(String,String)] ) : Option[Destination] = apply( CarefulMap(fields) )
    private def apply( fmap : CarefulMap ) : Option[Destination] =
      val tpe = fmap.get(destinationType.s) orElse fmap.get(`type`.s)
      tpe match
        case Some( t ) => byType(t,fmap)
        case None =>
          val destinations = Set( email(fmap), mastodon(fmap), sms(fmap) ).collect { case Some(dest) => dest }
          destinations.size match
            case 0 => None
            case 1 => Some(destinations.head)
            case n => throw new AmbiguousDestination( s"""Fields '${fmap.fields.mkString(", ")}' can be interpreted as multiple ($n) Destinations: ${destinations.mkString(", ")}""" )

  private def toUJsonV1( destination : Destination ) : ujson.Value =
    def emf( email : Destination.Email )   : ujson.Obj = ujson.Obj.from(Seq(addressPart.s->ujson.Str(email.addressPart)) ++ email.displayNamePart.map(dnp=>(displayNamePart.s->ujson.Str(dnp))))
    def smsf( sms : Destination.Sms )      : ujson.Obj = ujson.Obj( number.s -> sms.number )
    def mf( masto : Destination.Mastodon ) : ujson.Obj = ujson.Obj( name.s -> masto.name, "instanceUrl" -> masto.instanceUrl )
    val (fields, tpe) =
      destination match
        case email : Destination.Email    => (emf(email), Tag.Email)
        case sms   : Destination.Sms      => (smsf(sms),  Tag.Sms)
        case masto : Destination.Mastodon => (mf(masto),  Tag.Mastodon)
    val headerFields =
       ujson.Obj(
         version.s -> 1,
         `type`.s -> tpe.toString
       )
    ujson.Obj.from(headerFields.obj ++ fields.obj)

  private def fromUJsonV1( jsonValue : ujson.Value ) : Destination =
    val obj = jsonValue.obj
    val version = obj.get(Key.version.s).map( _.num.toInt )
    if version.nonEmpty && version != Some(1) then
      throw new InvalidDestination(s"Unpickling Destination, found version ${version.get}, expected version 1: " + obj.mkString(", "))
    else
      val tpe = obj(`type`.s).str
      Tag.valueOf(tpe) match
        case Tag.Email    => Destination.Email( addressPart = obj(addressPart.s).str, displayNamePart = obj.get(displayNamePart.s).map(_.str)) 
        case Tag.Sms      => Destination.Sms( number = obj(number.s).str )
        case Tag.Mastodon => Destination.Mastodon( name = obj(name.s).str, instanceUrl = obj(instanceUrl.s).str )

  given ReadWriter[Destination] =
    readwriter[ujson.Value].bimap[Destination](
      destination => toUJsonV1( destination ),
      jsonValue   => fromUJsonV1( jsonValue )
    )

sealed trait Destination extends Jsonable:
   /**
    *  Within any subscribable (subscription definiton), a String that should be
    *  kept unique, in order to avoid the possibility of people becoming annoyingly
    *  multiply subscribed.
    */
  def unique : String
  def json        : Destination.Json = Destination.Json( write[Destination]( this ) )
  def jsonPretty  : Destination.Json = Destination.Json( write[Destination]( this, indent = 4 ) )
  def toFields    : Seq[(String,String)]
  def shortDesc   : String
  def fullDesc    : String
  def defaultDesc : String
  def toCsvRow    : Seq[String]





© 2015 - 2025 Weber Informatics LLC | Privacy Policy