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 ? '" : "");
+ 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.
- |
+ |
+ |
|
|