diff --git a/src/main/scala/com/zestia/capsulemcp/model/CsvSerialisable.scala b/src/main/scala/com/zestia/capsulemcp/model/CsvSerialisable.scala new file mode 100644 index 0000000..12f14df --- /dev/null +++ b/src/main/scala/com/zestia/capsulemcp/model/CsvSerialisable.scala @@ -0,0 +1,8 @@ +package com.zestia.capsulemcp.model + +trait CsvSerialisable: + def renderCsv: String + +trait PartiallyCsvSerialisable: + def renderCsv: String + diff --git a/src/main/scala/com/zestia/capsulemcp/model/FieldValue.scala b/src/main/scala/com/zestia/capsulemcp/model/FieldValue.scala index 70cba7f..f4d72a5 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/FieldValue.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/FieldValue.scala @@ -85,16 +85,19 @@ final case class FieldValueString( id: Long, value: String, definition: FieldDefinition -) extends FieldValue derives JsonDecoder, JsonEncoder +) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = s"${definition.name}=${value}" final case class FieldValueNumber( id: Long, value: Long, definition: FieldDefinition -) extends FieldValue derives JsonDecoder, JsonEncoder +) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = s"${definition.name}=${value}" final case class FieldValueBoolean( id: Long, value: Boolean, definition: FieldDefinition -) extends FieldValue derives JsonDecoder, JsonEncoder +) extends FieldValue with CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = s"${definition.name}=${value}" diff --git a/src/main/scala/com/zestia/capsulemcp/model/Party.scala b/src/main/scala/com/zestia/capsulemcp/model/Party.scala index bd8cde6..74cc341 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/Party.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/Party.scala @@ -12,22 +12,38 @@ final case class Address( state: Option[String], country: Option[String], zip: Option[String] -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = + def nonEmpty(opt: Option[String]): Option[String] = + opt.map(_.trim).filter(_.nonEmpty) + + val parts: List[(String, String)] = + List( + nonEmpty(`type`).map(t => "type" -> t), + nonEmpty(street).map(s => "street" -> s), + nonEmpty(city).map(c => "city" -> c), + nonEmpty(state).map(s => "state" -> s), + nonEmpty(country).map(c => "country" -> c), + nonEmpty(zip).map(z => "zip" -> z) + ).flatten + + parts.map { case (k, v) => s"$k=$v" }.mkString(";") final case class EmailAddress( id: Long, `type`: Option[String], address: String -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = + `type`.map(t => s"$t=$address").getOrElse(address) final case class PhoneNumber( id: Long, `type`: Option[String], number: String -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = + `type`.map(t => s"$t=$number").getOrElse(number) final case class Website( id: Long, @@ -35,8 +51,8 @@ final case class Website( address: String, `type`: Option[String], url: String -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = s"${service}=${address}" enum PartyType: case person, organisation @@ -106,4 +122,7 @@ final case class Organisation( owner: Option[User], team: Option[Team], missingImportantFields: Option[Boolean] -) extends Party derives JsonDecoder, JsonEncoder +) extends Party + with PartiallyCsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = + s"id=$id${name.map(n => s";name=$n").getOrElse("")}" diff --git a/src/main/scala/com/zestia/capsulemcp/model/Responses.scala b/src/main/scala/com/zestia/capsulemcp/model/Responses.scala index ccd6215..63c9633 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/Responses.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/Responses.scala @@ -21,3 +21,5 @@ case class FieldDefinitionsResponse(definitions: List[FieldDefinition]) case class UsersResponse(users: List[User]) derives JsonDecoder, JsonEncoder case class TeamsResponse(teams: List[Team]) derives JsonDecoder, JsonEncoder + +case class ResponseWrapper(result: String, meta: Meta) derives JsonEncoder diff --git a/src/main/scala/com/zestia/capsulemcp/model/Tag.scala b/src/main/scala/com/zestia/capsulemcp/model/Tag.scala index 7f3ca0e..48f7238 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/Tag.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/Tag.scala @@ -6,5 +6,6 @@ case class Tag( id: Long, name: String, description: Option[String] -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, + JsonEncoder: + override def renderCsv: String = name diff --git a/src/main/scala/com/zestia/capsulemcp/model/Team.scala b/src/main/scala/com/zestia/capsulemcp/model/Team.scala index d5e80b1..15e50ae 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/Team.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/Team.scala @@ -5,5 +5,5 @@ import zio.json.* case class Team( id: Long, name: Option[String] -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, JsonEncoder: + override def renderCsv: String = name.getOrElse("") diff --git a/src/main/scala/com/zestia/capsulemcp/model/User.scala b/src/main/scala/com/zestia/capsulemcp/model/User.scala index 84bbfe5..e56645a 100644 --- a/src/main/scala/com/zestia/capsulemcp/model/User.scala +++ b/src/main/scala/com/zestia/capsulemcp/model/User.scala @@ -6,5 +6,6 @@ case class User( id: Long, username: String, name: String -) derives JsonDecoder, - JsonEncoder +) extends CsvSerialisable derives JsonDecoder, + JsonEncoder: + override def renderCsv: String = name diff --git a/src/main/scala/com/zestia/capsulemcp/server/McpServer.scala b/src/main/scala/com/zestia/capsulemcp/server/McpServer.scala index 50a42d0..c4068e3 100644 --- a/src/main/scala/com/zestia/capsulemcp/server/McpServer.scala +++ b/src/main/scala/com/zestia/capsulemcp/server/McpServer.scala @@ -8,7 +8,7 @@ import com.zestia.capsulemcp.model.filter.Filter import com.zestia.capsulemcp.model.filter.* import com.zestia.capsulemcp.model.filter.SimpleCondition.* import com.zestia.capsulemcp.model.Pagination -import com.zestia.capsulemcp.util.{FileLogger, FileLogging} +import com.zestia.capsulemcp.util.{Csv, FileLogger, FileLogging, UnwrapList} import com.tjclp.fastmcp.core.{Tool, ToolParam} import com.tjclp.fastmcp.macros.RegistrationMacro.* import com.tjclp.fastmcp.server.FastMcpServer @@ -81,11 +81,19 @@ object CapsuleMcpServer extends FileLogging: ) pagination: Pagination, @ToolParam("array of zero or more conditions") filter: Filter ): String = { - filterRequest[ContactsResponse]( + val response = filterRequest[ContactsResponse]( "parties/filters/results", filter, pagination - ).toJson + ) + + import UnwrapList.given + val parties: List[Party] = + summon[UnwrapList[ContactsResponse, Party]].apply(response) + val rows: List[Product] = parties.collect { case p: Product => p } + val csv = Csv.render(rows) + + ResponseWrapper(csv, response.meta).toJson } @Tool( diff --git a/src/main/scala/com/zestia/capsulemcp/util/Csv.scala b/src/main/scala/com/zestia/capsulemcp/util/Csv.scala new file mode 100644 index 0000000..c1cb62f --- /dev/null +++ b/src/main/scala/com/zestia/capsulemcp/util/Csv.scala @@ -0,0 +1,82 @@ +package com.zestia.capsulemcp.util + +import com.zestia.capsulemcp.model.* + +object Csv extends FileLogging { + + def render[T <: Product]( + rows: List[T], + fieldsToInclude: List[String] = Nil + ): String = { + val delimiter: Char = ',' + if (rows.isEmpty) return "" + + // Derive headers from the first row if not given + val allNames = rows.head.productElementNames.toList + + logger.info(s"Rendering ${rows.size} rows with ${allNames.size} columns") + val headers = + if (fieldsToInclude.nonEmpty) fieldsToInclude + else allNames + + logger.info(s"Rendering ${headers.size} columns: ${headers.mkString(",")}") + + // Precompute index map + val nameToIndex: Map[String, Int] = allNames.zipWithIndex.toMap + + // For each row, extract the requested columns by index + try { + val lines = rows.map { r => + val nameToValue: Map[String, Any] = + r.productElementNames.zip(r.productIterator).toMap + + headers + .map { h => + val v = nameToValue.getOrElse(h, "") + escape(cellString(v)) + } + .mkString(delimiter.toString) + } + + (headers.map(escape).mkString(delimiter.toString) :: lines) + .mkString("\n") + + } catch { + case t: Throwable => + logger.error(s"Unexpected error writing to CSV: $t") + + "Unexpected error writing to CSV" + } + } + + private def escape(s: String): String = { + val needsQuotes = + s.exists(ch => ch == ',' || ch == '"' || ch == '\n' || ch == '\r') + val escapedQuotes = + if (!needsQuotes) s else "\"" + s.replace("\"", "\"\"") + "\"" + escapedQuotes.replaceAll( + "\n", + " " + ) // escape newlines allowed in places like addresses + } + + private def cellString(value: Any): String = { + value match { + case null => "" + case None => "" + case Some(v) => cellString(v) + case xs: Iterable[_] => xs.map(cellString).mkString(";") + case csvSerialisable: CsvSerialisable => csvSerialisable.renderCsv + case csvSerialisable: PartiallyCsvSerialisable => + csvSerialisable.renderCsv + case p: Product => + p.productIterator + .map(cellString) + .mkString(";") // default to flatten any other Products + case s: String => s + case b: Boolean => b.toString + case n: java.lang.Number => n.toString + case other => other.toString + } + } +} diff --git a/src/main/scala/com/zestia/capsulemcp/util/UnwrapList.scala b/src/main/scala/com/zestia/capsulemcp/util/UnwrapList.scala new file mode 100644 index 0000000..195f806 --- /dev/null +++ b/src/main/scala/com/zestia/capsulemcp/util/UnwrapList.scala @@ -0,0 +1,12 @@ +package com.zestia.capsulemcp.util + +import com.zestia.capsulemcp.model._ + +trait UnwrapList[W, A] { def apply(w: W): List[A] } + +object UnwrapList { + given UnwrapList[ContactsResponse, Party] = (w: ContactsResponse) => w.parties + given UnwrapList[OpportunitiesResponse, Opportunity] = + (w: OpportunitiesResponse) => w.opportunities + given UnwrapList[ProjectsResponse, Project] = (w: ProjectsResponse) => w.kases +}