diff --git a/README.md b/README.md index 3de5d9f..6087e25 100644 --- a/README.md +++ b/README.md @@ -92,3 +92,46 @@ val json = JsonSchema[Profile].schema Nested case classes are inlined (no `$ref`) and work to arbitrary depth. > **Limitation:** Mutually recursive case classes (e.g. `A` contains `B`, `B` contains `A`) will cause a compile-time stack overflow. Workaround: provide an explicit `given JsonSchema[B]` before deriving `A`. + +## Excel + +An optional module (`excel`) exposes Scala functions as Excel custom functions via an HTTP server. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Excel │ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ Functions runtime │ │ Task pane │ │ +│ │ (functions.html) │ │ (taskpane.html) │ │ +│ │ │ │ │ │ +│ │ on startup │ │ on load / "Reload Functions" │ │ +│ │ ┌────────────────┐ │ │ ┌────────────────────────────┐ │ │ +│ │ │loadAndRegister │──┼────────────┼─▶│ GET /functions.json │ │ │ +│ │ └────────────────┘ │ │ └────────────────────────────┘ │ │ +│ │ │ │ │ re-render list │ │ +│ │ poll every 2 s │ │ ▼ │ │ +│ │ ┌────────────────┐ │ signal │ OfficeRuntime.storage │ │ +│ │ │OfficeRuntime │◀─┼────────────┼─ .setItem("cf-reload-signal") │ │ +│ │ │.storage.getItem│ │ │ │ │ +│ │ └───────┬────────┘ │ └──────────────────────────────────┘ │ +│ │ │ detected │ │ +│ │ ▼ │ │ +│ │ ┌────────────────┐ │ │ +│ │ │loadAndRegister │ │ (only new IDs — tracked in registeredIds Set) │ +│ │ │ (additive) │ │ │ +│ │ └───────┬────────┘ │ │ +│ │ │ │ │ +│ └──────────┼───────────┘ │ +└─────────────┼───────────────────────────────────────────────────────────────┘ + │ POST /invoke {functionId, params} + ▼ + ┌─────────────────┐ + │ Scala server │ + │ (http4s/ember) │ + └─────────────────┘ +``` + +- **`/functions.json`** — served at startup from the in-memory function list; always reflects the running server's functions. +- **`/functions.js`** — fetches `/functions.json` at runtime and calls `CustomFunctions.associate()` dynamically; polls `OfficeRuntime.storage` every 2 s for a reload signal. +- **Reload without add-in restart** — clicking "Reload Functions" signals the runtime to re-register new functions and refreshes the taskpane list. Formula-bar autocomplete for brand-new functions still requires a full add-in reload (Excel limitation). diff --git a/excel-example/manifest.xml b/excel-example/manifest.xml index 671235b..896f181 100644 --- a/excel-example/manifest.xml +++ b/excel-example/manifest.xml @@ -30,8 +30,8 @@ Sideloading instructions: - - + + @@ -111,9 +111,9 @@ Sideloading instructions: - - - + + + diff --git a/excel/src/main/resources/functions-core.js b/excel/src/main/resources/functions-core.js new file mode 100644 index 0000000..0daf6d6 --- /dev/null +++ b/excel/src/main/resources/functions-core.js @@ -0,0 +1,25 @@ +let registeredIds = new Set(); + +async function loadAndRegister() { + const data = await fetch("/functions.json").then(r => r.json()); + for (const fn of (data.functions || [])) { + if (registeredIds.has(fn.id)) continue; + registeredIds.add(fn.id); + CustomFunctions.associate(fn.id, async function(...args) { + const params = {}; + fn.parameters.forEach((p, i) => { params[p.name] = args[i]; }); + const resp = await axios.post(CENTRAL_URL, { functionId: fn.id, params }); + return resp.data; + }); + } +} + +loadAndRegister(); + +setInterval(async () => { + const sig = await OfficeRuntime.storage.getItem("cf-reload-signal"); + if (sig) { + await OfficeRuntime.storage.removeItem("cf-reload-signal"); + await loadAndRegister(); + } +}, 2000); diff --git a/excel/src/main/resources/taskpane.css b/excel/src/main/resources/taskpane.css new file mode 100644 index 0000000..b859c78 --- /dev/null +++ b/excel/src/main/resources/taskpane.css @@ -0,0 +1,84 @@ +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: 'Segoe UI', Tahoma, sans-serif; + font-size: 13px; + color: #323130; + background: #fff; +} + +#header { + background: #217346; + color: #fff; + padding: 12px 14px; + font-size: 15px; + font-weight: 600; +} + +#search-box { + padding: 8px 10px; + border-bottom: 1px solid #edebe9; +} + +#search-box input { + width: 100%; + padding: 5px 8px; + border: 1px solid #c8c6c4; + border-radius: 2px; + font-size: 13px; + outline: none; +} + +#search-box input:focus { border-color: #217346; } + +#count { + padding: 4px 14px; + font-size: 11px; + color: #a19f9d; + background: #faf9f8; + border-bottom: 1px solid #edebe9; +} + +.fn-card { + padding: 10px 14px; + border-bottom: 1px solid #edebe9; +} + +.fn-card:hover { background: #f3f2f1; } + +.fn-sig { + font-family: 'Cascadia Code', Consolas, monospace; + color: #217346; + font-weight: 600; + font-size: 12px; +} + +.fn-desc { color: #605e5c; margin: 3px 0 5px; font-size: 12px; } + +.params { list-style: none; padding: 0; margin: 0; } + +.params li { font-size: 11px; color: #605e5c; padding: 1px 0; } + +.pname { font-family: monospace; font-weight: 600; color: #323130; } + +.popt { color: #a19f9d; font-style: italic; } + +#empty { padding: 20px; text-align: center; color: #a19f9d; display: none; } + +#reload-btn { + display: block; + width: calc(100% - 20px); + margin: 8px 10px; + padding: 6px 12px; + background: #217346; + color: #fff; + border: none; + border-radius: 2px; + font-size: 13px; + cursor: pointer; +} + +#reload-btn:hover { background: #185c37; } + +#reload-btn:disabled { background: #a19f9d; cursor: default; } diff --git a/excel/src/main/resources/taskpane.js b/excel/src/main/resources/taskpane.js new file mode 100644 index 0000000..dc20cc8 --- /dev/null +++ b/excel/src/main/resources/taskpane.js @@ -0,0 +1,59 @@ +let fns = []; + +function sig(f) { + const ps = f.parameters.map(p => p.optional ? "[" + p.name + "]" : p.name).join(", "); + return "=" + NS_PREFIX + f.name + "(" + ps + ")"; +} + +function card(f) { + const div = document.createElement("div"); + div.className = "fn-card"; + const ps = f.parameters.map(p => { + const opt = p.optional ? ' (optional)' : ""; + const desc = p.description ? " — " + p.description : ""; + return '
  • ' + p.name + "" + opt + desc + "
  • "; + }).join(""); + div.innerHTML = + '
    ' + sig(f) + "
    " + + '
    ' + f.description + "
    " + + (ps ? '
      ' + ps + "
    " : ""); + return div; +} + +function render(list) { + const el = document.getElementById("list"); + el.innerHTML = ""; + list.forEach(f => el.appendChild(card(f))); + document.getElementById("empty").style.display = list.length ? "none" : "block"; + document.getElementById("count").textContent = + list.length + " function" + (list.length === 1 ? "" : "s"); +} + +function doFilter() { + const q = document.getElementById("q").value.toLowerCase(); + render(q + ? fns.filter(f => f.name.toLowerCase().includes(q) || f.description.toLowerCase().includes(q)) + : fns); +} + +fetch("/functions.json").then(r => r.json()).then(data => { + fns = data.functions || []; + render(fns); +}); + +async function reload() { + const btn = document.getElementById("reload-btn"); + btn.disabled = true; + btn.textContent = "Reloading…"; + try { + await OfficeRuntime.storage.setItem("cf-reload-signal", "1"); + const data = await fetch("/functions.json").then(r => r.json()); + fns = data.functions || []; + doFilter(); + btn.textContent = "Reload Functions"; + } catch(e) { + btn.textContent = "Error — retry"; + } finally { + btn.disabled = false; + } +} diff --git a/excel/src/main/scala/jsonschema/excel/Excel.scala b/excel/src/main/scala/jsonschema/excel/Excel.scala index f4d13f4..8823ea2 100644 --- a/excel/src/main/scala/jsonschema/excel/Excel.scala +++ b/excel/src/main/scala/jsonschema/excel/Excel.scala @@ -15,7 +15,7 @@ import io.circe.Json */ class Excel(functions: List[ExcelFunction.Def], centralUrl: String, namespace: String = ""): - def functionsJs(): String = ExcelJsGenerator.generate(functions, centralUrl, true) + def functionsJs(): String = ExcelJsGenerator.generate(centralUrl, true) def functionsJson(): Json = ExcelFunctionsManifest(functions).toJson @@ -23,4 +23,8 @@ class Excel(functions: List[ExcelFunction.Def], centralUrl: String, namespace: S def taskpaneHtml(): String = ExcelHtml.taskpaneHtml(namespace) - def iconPng(size: Int): Array[Byte] = ExcelIcon.png(size) + def taskpaneCss(): String = ExcelHtml.taskpaneCss + + def taskpaneJs(): String = ExcelHtml.taskpaneJs + + val iconSvg: String = ExcelHtml.iconSvg diff --git a/excel/src/main/scala/jsonschema/excel/ExcelHtml.scala b/excel/src/main/scala/jsonschema/excel/ExcelHtml.scala index 42d04c5..280d5ae 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelHtml.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelHtml.scala @@ -25,139 +25,36 @@ object ExcelHtml: | | | - | + | | | | | + | |
    |
    |
    No matching functions.
    - | + | + | | | |""".stripMargin - // ── Styles ────────────────────────────────────────────────────────────────── + val taskpaneCss: String = + scala.io.Source.fromInputStream( + getClass.getResourceAsStream("/taskpane.css") + ).mkString - private val css: String = - """| - | * { box-sizing: border-box; } - | - | body { - | margin: 0; - | font-family: 'Segoe UI', Tahoma, sans-serif; - | font-size: 13px; - | color: #323130; - | background: #fff; - | } - | - | #header { - | background: #217346; - | color: #fff; - | padding: 12px 14px; - | font-size: 15px; - | font-weight: 600; - | } - | - | #search-box { - | padding: 8px 10px; - | border-bottom: 1px solid #edebe9; - | } - | - | #search-box input { - | width: 100%; - | padding: 5px 8px; - | border: 1px solid #c8c6c4; - | border-radius: 2px; - | font-size: 13px; - | outline: none; - | } - | - | #search-box input:focus { border-color: #217346; } - | - | #count { - | padding: 4px 14px; - | font-size: 11px; - | color: #a19f9d; - | background: #faf9f8; - | border-bottom: 1px solid #edebe9; - | } - | - | .fn-card { - | padding: 10px 14px; - | border-bottom: 1px solid #edebe9; - | } - | - | .fn-card:hover { background: #f3f2f1; } - | - | .fn-sig { - | font-family: 'Cascadia Code', Consolas, monospace; - | color: #217346; - | font-weight: 600; - | font-size: 12px; - | } - | - | .fn-desc { color: #605e5c; margin: 3px 0 5px; font-size: 12px; } - | - | .params { list-style: none; padding: 0; margin: 0; } - | - | .params li { font-size: 11px; color: #605e5c; padding: 1px 0; } - | - | .pname { font-family: monospace; font-weight: 600; color: #323130; } - | - | .popt { color: #a19f9d; font-style: italic; } - | - | #empty { padding: 20px; text-align: center; color: #a19f9d; display: none; } - |""".stripMargin - - // ── Client-side script ────────────────────────────────────────────────────── + val taskpaneJs: String = + scala.io.Source.fromInputStream( + getClass.getResourceAsStream("/taskpane.js") + ).mkString - private def js(nsPrefix: String): String = - s"""| - | const NS_PREFIX = "$nsPrefix"; - | let fns = []; - | - | function sig(f) { - | const ps = f.parameters.map(p => p.optional ? "[" + p.name + "]" : p.name).join(", "); - | return "=" + NS_PREFIX + f.name + "(" + ps + ")"; - | } - | - | function card(f) { - | const div = document.createElement("div"); - | div.className = "fn-card"; - | const ps = f.parameters.map(p => { - | const opt = p.optional ? ' (optional)' : ""; - | const desc = p.description ? " — " + p.description : ""; - | return '
  • ' + p.name + "" + opt + desc + "
  • "; - | }).join(""); - | div.innerHTML = - | '
    ' + sig(f) + "
    " + - | '
    ' + f.description + "
    " + - | (ps ? '
      ' + ps + "
    " : ""); - | return div; - | } - | - | function render(list) { - | const el = document.getElementById("list"); - | el.innerHTML = ""; - | list.forEach(f => el.appendChild(card(f))); - | document.getElementById("empty").style.display = list.length ? "none" : "block"; - | document.getElementById("count").textContent = - | list.length + " function" + (list.length === 1 ? "" : "s"); - | } - | - | function doFilter() { - | const q = document.getElementById("q").value.toLowerCase(); - | render(q - | ? fns.filter(f => f.name.toLowerCase().includes(q) || f.description.toLowerCase().includes(q)) - | : fns); - | } - | - | fetch("/functions.json").then(r => r.json()).then(data => { - | fns = data.functions || []; - | render(fns); - | }); - |""".stripMargin + val iconSvg: String = + """ + | + | f + |""".stripMargin diff --git a/excel/src/main/scala/jsonschema/excel/ExcelIcon.scala b/excel/src/main/scala/jsonschema/excel/ExcelIcon.scala deleted file mode 100644 index 132e9c4..0000000 --- a/excel/src/main/scala/jsonschema/excel/ExcelIcon.scala +++ /dev/null @@ -1,47 +0,0 @@ -package jsonschema.excel - -import java.io.ByteArrayOutputStream -import java.util.zip.CRC32 -import java.util.zip.Deflater - -/** Generates a solid-colour PNG icon at the requested pixel size. */ -object ExcelIcon: - - def png(size: Int): Array[Byte] = - val ihdr = int32(size) ++ int32(size) ++ Array(8, 2, 0, 0, 0).map(_.toByte) - val out = ByteArrayOutputStream() - out.write(Array(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a).map(_.toByte)) - out.write(chunk("IHDR", ihdr)) - out.write(chunk("IDAT", deflate(scanlines(size)))) - out.write(chunk("IEND", Array.emptyByteArray)) - out.toByteArray() - - // ── Helpers ───────────────────────────────────────────────────────────────── - - // Excel-green #217346 scanlines: filter-byte 0 followed by RGB pixels per row - private def scanlines(size: Int): Array[Byte] = - val raw = ByteArrayOutputStream(size * (1 + size * 3)) - (0 until size).foreach: _ => - raw.write(0) - (0 until size).foreach: _ => - raw.write(0x21); raw.write(0x73); raw.write(0x46) - raw.toByteArray() - - private def deflate(data: Array[Byte]): Array[Byte] = - val d = Deflater() - val buf = Array.ofDim[Byte](8192) - val out = ByteArrayOutputStream() - d.setInput(data) - d.finish() - while !d.finished() do out.write(buf, 0, d.deflate(buf)) - out.toByteArray() - - private def chunk(tag: String, data: Array[Byte]): Array[Byte] = - val tagBytes = tag.getBytes("US-ASCII") - val crc = CRC32() - crc.update(tagBytes) - crc.update(data) - int32(data.length) ++ tagBytes ++ data ++ int32(crc.getValue.toInt) - - private def int32(n: Int): Array[Byte] = - Array((n >> 24).toByte, (n >> 16).toByte, (n >> 8).toByte, n.toByte) diff --git a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala index 4482d71..60e285b 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelJsGenerator.scala @@ -14,32 +14,19 @@ object ExcelJsGenerator: conn.setReadTimeout(10_000) scala.io.Source.fromInputStream(conn.getInputStream).mkString - private def renderFunction(fn: ExcelFunction.Def): String = - val paramNames = fn.parameters.map(_.name).mkString(", ") - val paramLines = fn.parameters.map(p => s"${p.name}: ${p.name},").mkString("\n") - s"""|CustomFunctions.associate( - | "${fn.id}", - | async function($paramNames) { - | const response = await axios.post(CENTRAL_URL, { - | functionId: "${fn.id}", - | params: { - | $paramLines - | }, - | }); - | return response.data; - | } - |);""".stripMargin + private def functionsCore(): String = + scala.io.Source.fromInputStream( + getClass.getResourceAsStream("/functions-core.js") + ).mkString def generate( - functions: List[ExcelFunction.Def], centralUrl: String, selfContained: Boolean = false ): String = - val bodies = functions.map(renderFunction).mkString("\n\n") val core = s"""|const CENTRAL_URL = "$centralUrl"; | - |$bodies + |${functionsCore()} |""".stripMargin if selfContained then s"${Axios.jsSource()}\n\n$core" else core diff --git a/excel/src/main/scala/jsonschema/excel/ExcelRoutes.scala b/excel/src/main/scala/jsonschema/excel/ExcelRoutes.scala index 82e4664..ee7497d 100644 --- a/excel/src/main/scala/jsonschema/excel/ExcelRoutes.scala +++ b/excel/src/main/scala/jsonschema/excel/ExcelRoutes.scala @@ -15,21 +15,20 @@ object ExcelRoutes: json <- IO.pure(excel.functionsJson().noSpaces) html <- IO.pure(excel.functionsHtml()) taskpane <- IO.pure(excel.taskpaneHtml()) - icon16 <- IO.blocking(excel.iconPng(16)) - icon32 <- IO.blocking(excel.iconPng(32)) - icon80 <- IO.blocking(excel.iconPng(80)) + css <- IO.pure(excel.taskpaneCss()) + taskpaneJs <- IO.pure(excel.taskpaneJs()) yield HttpRoutes.of[IO]: case GET -> Root / "functions.js" => - Ok(js).map(_.withContentType(`Content-Type`(MediaType.unsafeParse("text/javascript")))) + Ok(js).map(_.withContentType(`Content-Type`(MediaType.text.javascript))) case GET -> Root / "functions.json" => Ok(json).map(_.withContentType(`Content-Type`(MediaType.application.json))) case GET -> Root / "functions.html" => Ok(html).map(_.withContentType(`Content-Type`(MediaType.text.html))) case GET -> Root / "taskpane.html" => Ok(taskpane).map(_.withContentType(`Content-Type`(MediaType.text.html))) - case GET -> Root / "icon-16.png" => - Ok(icon16).map(_.withContentType(`Content-Type`(MediaType.image.png))) - case GET -> Root / "icon-32.png" => - Ok(icon32).map(_.withContentType(`Content-Type`(MediaType.image.png))) - case GET -> Root / "icon-80.png" => - Ok(icon80).map(_.withContentType(`Content-Type`(MediaType.image.png))) + case GET -> Root / "taskpane.css" => + Ok(css).map(_.withContentType(`Content-Type`(MediaType.text.css))) + case GET -> Root / "taskpane.js" => + Ok(taskpaneJs).map(_.withContentType(`Content-Type`(MediaType.text.javascript))) + case GET -> Root / "icon.svg" => + Ok(excel.iconSvg).map(_.withContentType(`Content-Type`(MediaType.image.`svg+xml`))) diff --git a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala index d94c7a3..984d908 100644 --- a/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala +++ b/excel/src/test/scala/jsonschema/excel/ExcelFunctionBuilderTest.scala @@ -112,34 +112,31 @@ class ExcelFunctionBuilderTest extends FunSuite: // ── JS generator tests ───────────────────────────────────────────────────── test("ExcelJsGenerator.generate output contains const CENTRAL_URL header"): - val js = ExcelJsGenerator.generate(Nil, "https://example.com/route") + val js = ExcelJsGenerator.generate("https://example.com/route") assert(js.contains("""const CENTRAL_URL = "https://example.com/route";""")) - test("generated JS uses axios.post and returns response.data"): - @Description("req2 desc") - case class Req2(ticker: String) derives JsonSchema - val fn = ExcelFunction.from[Req2]("MY.FUNC") - val js = ExcelJsGenerator.generate(List(fn), "https://example.com") - assert(js.contains("CustomFunctions.associate(")) - assert(js.contains(""""MY.FUNC"""")) - assert(js.contains("async function(ticker)")) + test("generated JS dynamically fetches functions.json and uses CustomFunctions.associate"): + val js = ExcelJsGenerator.generate("https://example.com") + assert(js.contains("fetch(\"/functions.json\")")) + assert(js.contains("CustomFunctions.associate(fn.id")) assert(js.contains("axios.post(CENTRAL_URL")) - assert(js.contains("return response.data")) + assert(js.contains("resp.data")) - test("request body shape: { functionId: ..., params: { field: field } }"): - @Description("req3 desc") - case class Req3(ticker: String, amount: Double) derives JsonSchema - val fn = ExcelFunction.from[Req3]("MY.FUNC2") - val js = ExcelJsGenerator.generate(List(fn), "https://example.com") - assert(js.contains("""functionId: "MY.FUNC2"""")) - assert(js.contains("ticker: ticker,")) - assert(js.contains("amount: amount,")) + test("generated JS maps parameter names from function metadata"): + val js = ExcelJsGenerator.generate("https://example.com") + assert(js.contains("params[p.name] = args[i]")) + assert(js.contains("fn.parameters.forEach")) + + test("generated JS polls OfficeRuntime.storage for reload signal"): + val js = ExcelJsGenerator.generate("https://example.com") + assert(js.contains("OfficeRuntime.storage.getItem(\"cf-reload-signal\")")) + assert(js.contains("loadAndRegister()")) test("selfContained = false does not include axios source inline"): - val js = ExcelJsGenerator.generate(Nil, "https://example.com", selfContained = false) + val js = ExcelJsGenerator.generate("https://example.com", selfContained = false) assert(!js.contains("axios/lib") && !js.contains("var axios")) - test("full round-trip: two functions, both CustomFunctions.associate calls present"): + test("full round-trip: JS contains CENTRAL_URL and dynamic registration"): @Description("Gets price") case class PricingRequest(ticker: String, date: Option[String]) derives JsonSchema @Description("Computes VaR") @@ -150,8 +147,8 @@ class ExcelFunctionBuilderTest extends FunSuite: ) val excel = new Excel(fns, "https://central-api.example.com/route") val js = excel.`functionsJs`() - assert(js.contains(""""PRICING.GET_PRICE"""")) - assert(js.contains(""""RISK.COMPUTE_VAR"""")) + assert(js.contains("""const CENTRAL_URL = "https://central-api.example.com/route";""")) + assert(js.contains("loadAndRegister()")) val manifestJson = excel.functionsJson() assertEquals( manifestJson.hcursor.downField("functions").as[List[io.circe.Json]].map(_.size),