Added KorAP plugin stylesheet support

Change-Id: Iaec401c59da0a2abc179f51b318c11e9fc41ce07
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 1286af0..e456537 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -48,7 +48,9 @@
 	Description string
 	Server      string
 	SDK         string
+	Stylesheet  string
 	ServiceURL  string
+	CookieName  string
 }
 
 type SingleMappingPageData struct {
@@ -338,7 +340,9 @@
 		Description: config.Description,
 		Server:      yamlConfig.Server,
 		SDK:         yamlConfig.SDK,
+		Stylesheet:  yamlConfig.Stylesheet,
 		ServiceURL:  yamlConfig.ServiceURL,
+		CookieName:  yamlConfig.CookieName,
 	}
 }
 
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index 30def87..0d1d2cf 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -1269,6 +1269,7 @@
 	// Check that other defaults were also applied
 	assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
 	assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
+	assert.Equal(t, "https://korap.ids-mannheim.de/css/kalamar-plugin-latest.css", config.Stylesheet)
 	assert.Equal(t, 5725, config.Port)
 	assert.Equal(t, "warn", config.LogLevel)
 }
@@ -1285,6 +1286,7 @@
 
 	// Verify other values from the example config are preserved
 	assert.Equal(t, "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", config.SDK)
+	assert.Equal(t, "https://korap.ids-mannheim.de/css/kalamar-plugin-latest.css", config.Stylesheet)
 	assert.Equal(t, "https://korap.ids-mannheim.de/", config.Server)
 
 	// Verify the mapper was loaded correctly
@@ -1898,6 +1900,7 @@
 
 	mockConfig := &tmconfig.MappingConfig{
 		SDK:        "https://example.com/sdk.js",
+		Stylesheet: "https://example.com/kalamar.css",
 		Server:     "https://example.com/",
 		ServiceURL: "https://example.com/plugin/koralmapper",
 		Lists:      lists,
@@ -1924,6 +1927,7 @@
 
 	// SDK and server
 	assert.Contains(t, htmlContent, `src="https://example.com/sdk.js"`)
+	assert.Contains(t, htmlContent, `href="https://example.com/kalamar.css"`)
 	assert.Contains(t, htmlContent, `data-server="https://example.com/"`)
 
 	// ServiceURL as data attribute
@@ -1983,15 +1987,21 @@
 	assert.Contains(t, htmlContent, `data-default-foundry-b="upos"`)
 	assert.Contains(t, htmlContent, `data-default-layer-b="pos"`)
 
-	// Input fields with correct CSS classes
-	assert.Contains(t, htmlContent, `class="foundryA"`)
-	assert.Contains(t, htmlContent, `class="layerA"`)
-	assert.Contains(t, htmlContent, `class="foundryB"`)
-	assert.Contains(t, htmlContent, `class="layerB"`)
+	// Input fields with request/response-specific CSS classes
+	assert.Contains(t, htmlContent, `class="request-foundryA"`)
+	assert.Contains(t, htmlContent, `class="request-layerA"`)
+	assert.Contains(t, htmlContent, `class="request-foundryB"`)
+	assert.Contains(t, htmlContent, `class="request-layerB"`)
+	assert.Contains(t, htmlContent, `class="response-foundryA"`)
+	assert.Contains(t, htmlContent, `class="response-layerA"`)
+	assert.Contains(t, htmlContent, `class="response-foundryB"`)
+	assert.Contains(t, htmlContent, `class="response-layerB"`)
 
-	// Direction arrow
-	assert.Contains(t, htmlContent, `class="dir-arrow"`)
+	// Direction arrows are independent for request/response
+	assert.Contains(t, htmlContent, `class="request-dir-arrow"`)
+	assert.Contains(t, htmlContent, `class="response-dir-arrow"`)
 	assert.Contains(t, htmlContent, `data-dir="atob"`)
+	assert.Contains(t, htmlContent, `data-dir="btoa"`)
 
 	// Request and response checkboxes
 	assert.Contains(t, htmlContent, `class="request-cb"`)
@@ -2034,8 +2044,8 @@
 	assert.Contains(t, htmlContent, `class="response-cb"`)
 
 	// No foundry/layer inputs (only corpus mappings, no annotation section)
-	assert.NotContains(t, htmlContent, `class="foundryA"`)
-	assert.NotContains(t, htmlContent, `class="dir-arrow"`)
+	assert.NotContains(t, htmlContent, `class="request-foundryA"`)
+	assert.NotContains(t, htmlContent, `class="request-dir-arrow"`)
 }
 
 func TestConfigPageBackwardCompatibility(t *testing.T) {
@@ -2108,6 +2118,7 @@
 
 	mockConfig := &tmconfig.MappingConfig{
 		SDK:        "https://example.com/sdk.js",
+		Stylesheet: "https://example.com/kalamar.css",
 		Server:     "https://example.com/",
 		ServiceURL: "https://example.com/service",
 		Lists:      lists,
@@ -2116,6 +2127,7 @@
 	data := buildConfigPageData(mockConfig)
 
 	assert.Equal(t, "https://example.com/sdk.js", data.SDK)
+	assert.Equal(t, "https://example.com/kalamar.css", data.Stylesheet)
 	assert.Equal(t, "https://example.com/", data.Server)
 	assert.Equal(t, "https://example.com/service", data.ServiceURL)
 
diff --git a/cmd/koralmapper/static/config.html b/cmd/koralmapper/static/config.html
index a946501..6e71be1 100644
--- a/cmd/koralmapper/static/config.html
+++ b/cmd/koralmapper/static/config.html
@@ -3,14 +3,17 @@
 <head>
     <meta charset="UTF-8">
     <title>{{.Title}}</title>
+    <link rel="stylesheet" href="{{.Stylesheet}}">
     <link rel="stylesheet" href="/static/style.css">
     <script src="{{.SDK}}"
             data-server="{{.Server}}"></script>
 </head>
 <body>
-    <div class="container" data-service-url="{{.ServiceURL}}">
+    <div class="container" data-service-url="{{.ServiceURL}}" data-cookie-name="{{.CookieName}}">
         <h1>{{.Title}}</h1>
-        <p>{{.Description}}</p>
+        <section>
+            <p>{{.Description}}</p>
+        </section>
 
         {{if .AnnotationMappings}}
         <section class="mapping-section">
@@ -22,16 +25,20 @@
                 <h3>{{.ID}}</h3>
                 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
                 <div class="mapping-row">
-                    <label><input type="checkbox" class="request-cb" name="request"> Request:</label>
-                    <input type="text" class="foundryA" value="{{.FoundryA}}" size="8">/<input type="text" class="layerA" value="{{.LayerA}}" size="4">
-                    <button type="button" class="dir-arrow" data-dir="atob">&rarr;</button>
-                    <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+                    <label><input type="checkbox" class="request-cb" name="request"> Request</label>
+                    <div class="mapping-fields request-fields">
+                        <input type="text" class="request-foundryA" value="{{.FoundryA}}" placeholder="{{.FoundryA}}" size="8">/<input type="text" class="request-layerA" value="{{.LayerA}}" placeholder="{{.LayerA}}" size="4">
+                        <button type="button" class="request-dir-arrow" data-dir="atob">&rarr;</button>
+                        <input type="text" class="request-foundryB" value="{{.FoundryB}}" placeholder="{{.FoundryB}}" size="8">/<input type="text" class="request-layerB" value="{{.LayerB}}" placeholder="{{.LayerB}}" size="4">
+                    </div>
                 </div>
                 <div class="mapping-row">
-                    <label><input type="checkbox" class="response-cb" name="response"> Response:</label>
-                    <input type="text" class="foundryA" value="{{.FoundryA}}" size="8">/<input type="text" class="layerA" value="{{.LayerA}}" size="4">
-                    <button type="button" class="dir-arrow" data-dir="atob">&rarr;</button>
-                    <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+                    <label><input type="checkbox" class="response-cb" name="response"> Response</label>
+                    <div class="mapping-fields response-fields">
+                        <input type="text" class="response-foundryA" value="{{.FoundryA}}" placeholder="{{.FoundryA}}" size="8">/<input type="text" class="response-layerA" value="{{.LayerA}}" placeholder="{{.LayerA}}" size="4">
+                        <button type="button" class="response-dir-arrow" data-dir="btoa">&larr;</button>
+                        <input type="text" class="response-foundryB" value="{{.FoundryB}}" placeholder="{{.FoundryB}}" size="8">/<input type="text" class="response-layerB" value="{{.LayerB}}" placeholder="{{.LayerB}}" size="4">
+                    </div>
                 </div>
             </div>
             {{end}}
@@ -56,10 +63,17 @@
         </section>
         {{end}}
 
-        <h2>Plugin Information</h2>
-        <p><strong>Version:</strong> <tt>{{.Version}}</tt></p>
-        <p><strong>Build Date:</strong> <tt>{{.Date}}</tt></p>
-        <p><strong>Build Hash:</strong> <tt>{{.Hash}}</tt></p>
+        <section class="mapping-section">
+            <h2>Pipe Configuration</h2>
+            <label class="cfg-line-label" for="request-cfg-preview">Request cfg</label>
+            <input type="text" id="request-cfg-preview" class="cfg-preview request-cfg-preview" readonly value="">
+            <label class="cfg-line-label" for="response-cfg-preview">Response cfg</label>
+            <input type="text" id="response-cfg-preview" class="cfg-preview response-cfg-preview" readonly value="">
+        </section>
+
+        <footer class="version">
+            <p><tt>v{{.Version}} - {{.Date}} {{.Hash}}</tt></p>
+        </footer>
     </div>
     <script src="/static/config.js"></script>
 </body>
diff --git a/cmd/koralmapper/static/config.js b/cmd/koralmapper/static/config.js
index 3918c74..b3cd42b 100644
--- a/cmd/koralmapper/static/config.js
+++ b/cmd/koralmapper/static/config.js
@@ -1 +1,296 @@
 "use strict";
+
+(function () {
+  var container = document.querySelector(".container");
+  if (!container) return;
+
+  var serviceURL = container.dataset.serviceUrl;
+  var cookieName = container.dataset.cookieName || "km-config";
+  var mappingDivs = container.querySelectorAll(".mapping");
+  var requestCfgPreview = container.querySelector(".request-cfg-preview");
+  var responseCfgPreview = container.querySelector(".response-cfg-preview");
+
+  // Cookie helpers
+
+  function readCookie() {
+    var prefix = cookieName + "=";
+    var parts = document.cookie.split("; ");
+    for (var i = 0; i < parts.length; i++) {
+      if (parts[i].indexOf(prefix) === 0) {
+        try {
+          return JSON.parse(decodeURIComponent(parts[i].substring(prefix.length)));
+        } catch (e) {
+          return null;
+        }
+      }
+    }
+    return null;
+  }
+
+  function writeCookie(state) {
+    var value = encodeURIComponent(JSON.stringify(state));
+    document.cookie = cookieName + "=" + value + "; path=/; SameSite=Lax; max-age=31536000";
+  }
+
+  // Form state
+
+  function reverseDir(dir) {
+    return dir === "atob" ? "btoa" : "atob";
+  }
+
+  function rowFieldClasses(mode) {
+    return {
+      foundryA: "." + mode + "-foundryA",
+      layerA: "." + mode + "-layerA",
+      foundryB: "." + mode + "-foundryB",
+      layerB: "." + mode + "-layerB",
+      dirArrow: "." + mode + "-dir-arrow"
+    };
+  }
+
+  function inputValue(parent, selector) {
+    var el = parent.querySelector(selector);
+    return el ? el.value : "";
+  }
+
+  function getModeState(div, mode) {
+    var classes = rowFieldClasses(mode);
+    var arrow = div.querySelector(classes.dirArrow);
+    return {
+      enabled: div.querySelector("." + mode + "-cb").checked,
+      dir: arrow ? arrow.dataset.dir : "atob",
+      foundryA: inputValue(div, classes.foundryA),
+      layerA: inputValue(div, classes.layerA),
+      foundryB: inputValue(div, classes.foundryB),
+      layerB: inputValue(div, classes.layerB)
+    };
+  }
+
+  function getFormState() {
+    var state = { mappings: [] };
+
+    for (var i = 0; i < mappingDivs.length; i++) {
+      var div = mappingDivs[i];
+      var entry = {
+        id: div.dataset.id
+      };
+
+      if (div.dataset.type !== "corpus") {
+        entry.request = getModeState(div, "request");
+        entry.response = getModeState(div, "response");
+      } else {
+        entry.request = { enabled: div.querySelector(".request-cb").checked };
+        entry.response = { enabled: div.querySelector(".response-cb").checked };
+      }
+
+      state.mappings.push(entry);
+    }
+
+    return state;
+  }
+
+  // Restore form from cookie
+
+  function setInputValue(parent, selector, value) {
+    if (value === undefined) return;
+    var el = parent.querySelector(selector);
+    if (el) el.value = value;
+  }
+
+  function setArrowDirection(div, selector, dir) {
+    var arrow = div.querySelector(selector);
+    if (!arrow || !dir) return;
+    arrow.dataset.dir = dir;
+    arrow.textContent = dir === "atob" ? "\u2192" : "\u2190";
+  }
+
+  function restoreModeState(div, mode, modeState) {
+    if (!modeState) return;
+    var classes = rowFieldClasses(mode);
+    var checkbox = div.querySelector("." + mode + "-cb");
+    if (checkbox) checkbox.checked = !!modeState.enabled;
+    setArrowDirection(div, classes.dirArrow, modeState.dir);
+    setInputValue(div, classes.foundryA, modeState.foundryA);
+    setInputValue(div, classes.layerA, modeState.layerA);
+    setInputValue(div, classes.foundryB, modeState.foundryB);
+    setInputValue(div, classes.layerB, modeState.layerB);
+  }
+
+  function restoreFormState(saved) {
+    if (!saved || !saved.mappings) return;
+
+    var byId = {};
+    for (var i = 0; i < saved.mappings.length; i++) {
+      byId[saved.mappings[i].id] = saved.mappings[i];
+    }
+
+    for (var i = 0; i < mappingDivs.length; i++) {
+      var div = mappingDivs[i];
+      var entry = byId[div.dataset.id];
+      if (!entry) continue;
+
+      if (div.dataset.type !== "corpus") {
+        // Backward compatibility with old cookie schema.
+        if (entry.request && typeof entry.request === "object") {
+          restoreModeState(div, "request", entry.request);
+          restoreModeState(div, "response", entry.response);
+        } else {
+          var requestLegacy = {
+            enabled: !!entry.request,
+            dir: entry.dir || "atob",
+            foundryA: entry.foundryA,
+            layerA: entry.layerA,
+            foundryB: entry.foundryB,
+            layerB: entry.layerB
+          };
+          var responseLegacy = {
+            enabled: !!entry.response,
+            dir: reverseDir(entry.dir || "atob"),
+            foundryA: entry.foundryA,
+            layerA: entry.layerA,
+            foundryB: entry.foundryB,
+            layerB: entry.layerB
+          };
+          restoreModeState(div, "request", requestLegacy);
+          restoreModeState(div, "response", responseLegacy);
+        }
+      } else {
+        var requestCb = div.querySelector(".request-cb");
+        var responseCb = div.querySelector(".response-cb");
+        if (requestCb) {
+          requestCb.checked = !!(entry.request && entry.request.enabled);
+        }
+        if (responseCb) {
+          responseCb.checked = !!(entry.response && entry.response.enabled);
+        }
+      }
+    }
+  }
+
+  // cfg parameter building
+
+  // Returns "" when the input matches its default (compact URL).
+  function cfgFieldValue(div, inputSelector, defaultDataAttr) {
+    var el = div.querySelector(inputSelector);
+    if (!el) return "";
+    var val = el.value;
+    var def = div.dataset[defaultDataAttr] || "";
+    return val === def ? "" : val;
+  }
+
+  function buildCfgParam(mode) {
+    var parts = [];
+    var classes = rowFieldClasses(mode);
+
+    for (var i = 0; i < mappingDivs.length; i++) {
+      var div = mappingDivs[i];
+      var cbClass = mode === "request" ? ".request-cb" : ".response-cb";
+      var cb = div.querySelector(cbClass);
+      if (!cb || !cb.checked) continue;
+
+      var id = div.dataset.id;
+      var dir = "atob";
+
+      if (div.dataset.type !== "corpus") {
+        var arrow = div.querySelector(classes.dirArrow);
+        dir = arrow ? arrow.dataset.dir : "atob";
+      }
+
+      if (div.dataset.type !== "corpus") {
+        var fA = cfgFieldValue(div, classes.foundryA, "defaultFoundryA");
+        var lA = cfgFieldValue(div, classes.layerA, "defaultLayerA");
+        var fB = cfgFieldValue(div, classes.foundryB, "defaultFoundryB");
+        var lB = cfgFieldValue(div, classes.layerB, "defaultLayerB");
+
+        if (fA || lA || fB || lB) {
+          parts.push(id + ":" + dir + ":" + fA + ":" + lA + ":" + fB + ":" + lB);
+        } else {
+          parts.push(id + ":" + dir);
+        }
+      } else {
+        parts.push(id + ":" + dir);
+      }
+    }
+
+    return parts.join(";");
+  }
+
+  // Kalamar pipe registration
+
+  var lastQueryPipe = null;
+  var lastResponsePipe = null;
+
+  function registerPipes() {
+    var queryCfg = buildCfgParam("request");
+    var responseCfg = buildCfgParam("response");
+
+    if (requestCfgPreview) {
+      requestCfgPreview.value = queryCfg;
+    }
+    if (responseCfgPreview) {
+      responseCfgPreview.value = responseCfg;
+    }
+
+    var newQueryPipe = queryCfg ? serviceURL + "/query?cfg=" + encodeURIComponent(queryCfg) : "";
+    var newResponsePipe = responseCfg ? serviceURL + "/response?cfg=" + encodeURIComponent(responseCfg) : "";
+
+    if (newQueryPipe === lastQueryPipe && newResponsePipe === lastResponsePipe) return;
+
+    if (typeof KorAPlugin !== "undefined") {
+      if (lastQueryPipe) {
+        KorAPlugin.sendMsg({ action: "pipe", job: "del", service: lastQueryPipe });
+      }
+      if (lastResponsePipe) {
+        KorAPlugin.sendMsg({ action: "pipe", job: "del-after", service: lastResponsePipe });
+      }
+
+      if (newQueryPipe) {
+        KorAPlugin.sendMsg({ action: "pipe", job: "add", service: newQueryPipe });
+      }
+      if (newResponsePipe) {
+        KorAPlugin.sendMsg({ action: "pipe", job: "add-after", service: newResponsePipe });
+      }
+    }
+
+    lastQueryPipe = newQueryPipe;
+    lastResponsePipe = newResponsePipe;
+  }
+
+  // Change handler
+
+  function onChange() {
+    writeCookie(getFormState());
+    registerPipes();
+  }
+
+  // Initialisation
+
+  var saved = readCookie();
+  if (saved) {
+    restoreFormState(saved);
+  }
+
+  var checkboxes = container.querySelectorAll('input[type="checkbox"]');
+  for (var i = 0; i < checkboxes.length; i++) {
+    checkboxes[i].addEventListener("change", onChange);
+  }
+
+  var textInputs = container.querySelectorAll('input[type="text"]');
+  for (var i = 0; i < textInputs.length; i++) {
+    textInputs[i].addEventListener("input", onChange);
+  }
+
+  var arrows = container.querySelectorAll(".request-dir-arrow, .response-dir-arrow");
+  for (var i = 0; i < arrows.length; i++) {
+    (function (arrow) {
+      arrow.addEventListener("click", function () {
+        var next = reverseDir(arrow.dataset.dir);
+        arrow.dataset.dir = next;
+        arrow.textContent = next === "atob" ? "\u2192" : "\u2190";
+        onChange();
+      });
+    })(arrows[i]);
+  }
+
+  registerPipes();
+})();
diff --git a/cmd/koralmapper/static/style.css b/cmd/koralmapper/static/style.css
index 693ce9b..1ed368e 100644
--- a/cmd/koralmapper/static/style.css
+++ b/cmd/koralmapper/static/style.css
@@ -1 +1,11 @@
-body { font-family: sans-serif; }
+.mapping-section { padding-top: 0.75rem; margin-top: 1rem; }
+.mapping { margin-bottom: 1rem; }
+.mapping-row { display: flex; align-items: center; gap: 0.75rem; margin: 0.35rem 0; }
+.mapping-fields { display: inline-flex; align-items: center; gap: 0.35rem; }
+.mapping-fields input[type="text"] { font-family: monospace; }
+.request-fields, .response-fields { flex-wrap: wrap; }
+.request-dir-arrow, .response-dir-arrow { cursor: pointer; border: 1px solid #bbb; background: #f8f8f8; border-radius: 0.25rem; min-width: 2rem; }
+.cfg-line-label { display: block; margin: 0.35rem 0 0.1rem; }
+.cfg-preview { width: 100%; box-sizing: border-box; font-family: monospace; }
+.desc { color: #555; margin-top: 0; }
+footer { background-color: #aaa; font-size:50%; text-align: right; padding: 0.25rem; }
\ No newline at end of file