Turned code templates into embedded static assets and draft config page
Change-Id: Ifcab25a9620b9b8dde3a3b26b2420dbbc5b15fdd
diff --git a/README.md b/README.md
index b4daefd..9b691a4 100644
--- a/README.md
+++ b/README.md
@@ -256,9 +256,9 @@
- [x] JSON script for Kalamar integration
- [x] Integration of multiple mapping files
- [x] Response rewriting
-- [ ] Support for negation
- [x] Support corpus mappings
-- [ ] Support chaining of mappings
+- [x] Support chaining of mappings
+- [ ] Support for negation
## COPYRIGHT AND LICENSE
diff --git a/cmd/koralmapper/main.go b/cmd/koralmapper/main.go
index 0e5044b..1286af0 100644
--- a/cmd/koralmapper/main.go
+++ b/cmd/koralmapper/main.go
@@ -1,14 +1,20 @@
package main
import (
+ "bytes"
+ "embed"
"fmt"
+ "html/template"
+ "io/fs"
"net/url"
"os"
"os/signal"
"path"
"path/filepath"
+ "strconv"
"strings"
"syscall"
+ texttemplate "text/template"
"time"
"github.com/KorAP/Koral-Mapper/config"
@@ -19,6 +25,9 @@
"github.com/rs/zerolog/log"
)
+//go:embed static/*
+var staticFS embed.FS
+
const (
maxInputLength = 1024 * 1024 // 1MB
maxParamLength = 1024 // 1KB
@@ -31,13 +40,7 @@
LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
}
-type TemplateMapping struct {
- ID string
- Description string
-}
-
-// TemplateData holds data for the Kalamar plugin template
-type TemplateData struct {
+type BasePageData struct {
Title string
Version string
Hash string
@@ -46,8 +49,14 @@
Server string
SDK string
ServiceURL string
+}
+
+type SingleMappingPageData struct {
+ BasePageData
MapID string
- Mappings []TemplateMapping
+ Mappings []config.MappingList
+ QueryURL string
+ ResponseURL string
}
type QueryParams struct {
@@ -68,6 +77,13 @@
LayerB string
}
+// ConfigPageData holds all data passed to the configuration page template.
+type ConfigPageData struct {
+ BasePageData
+ AnnotationMappings []config.MappingList
+ CorpusMappings []config.MappingList
+}
+
func parseConfig() *appConfig {
cfg := &appConfig{}
@@ -240,9 +256,14 @@
// Start server
go func() {
log.Info().Int("port", finalPort).Msg("Starting server")
+ fmt.Printf("Starting server port=%d\n", finalPort)
for _, list := range yamlConfig.Lists {
log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
+ fmt.Printf("Loaded mapping desc=%s id=%s\n",
+ formatConsoleField(list.Description),
+ list.ID,
+ )
}
if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
@@ -263,11 +284,17 @@
}
func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
+ configTmpl := template.Must(template.ParseFS(staticFS, "static/config.html"))
+ pluginTmpl := texttemplate.Must(texttemplate.ParseFS(staticFS, "static/plugin.html"))
+
// Health check endpoint
app.Get("/health", func(c *fiber.Ctx) error {
return c.SendString("OK")
})
+ // Static file serving from embedded FS
+ app.Get("/static/*", handleStaticFile())
+
// Composite cascade transformation endpoints
app.Post("/query", handleCompositeQueryTransform(m, yamlConfig.Lists))
app.Post("/response", handleCompositeResponseTransform(m, yamlConfig.Lists))
@@ -279,8 +306,59 @@
app.Post("/:map/response", handleResponseTransform(m))
// Kalamar plugin endpoint
- app.Get("/", handleKalamarPlugin(yamlConfig))
- app.Get("/:map", handleKalamarPlugin(yamlConfig))
+ app.Get("/", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
+ app.Get("/:map", handleKalamarPlugin(yamlConfig, configTmpl, pluginTmpl))
+}
+
+func handleStaticFile() fiber.Handler {
+ return func(c *fiber.Ctx) error {
+ name := c.Params("*")
+ data, err := fs.ReadFile(staticFS, "static/"+name)
+ if err != nil {
+ return c.Status(fiber.StatusNotFound).SendString("not found")
+ }
+ switch {
+ case strings.HasSuffix(name, ".js"):
+ c.Set("Content-Type", "text/javascript; charset=utf-8")
+ case strings.HasSuffix(name, ".css"):
+ c.Set("Content-Type", "text/css; charset=utf-8")
+ case strings.HasSuffix(name, ".html"):
+ c.Set("Content-Type", "text/html; charset=utf-8")
+ }
+ return c.Send(data)
+ }
+}
+
+func buildBasePageData(yamlConfig *config.MappingConfig) BasePageData {
+ return BasePageData{
+ Title: config.Title,
+ Version: config.Version,
+ Hash: config.Buildhash,
+ Date: config.Buildtime,
+ Description: config.Description,
+ Server: yamlConfig.Server,
+ SDK: yamlConfig.SDK,
+ ServiceURL: yamlConfig.ServiceURL,
+ }
+}
+
+func buildConfigPageData(yamlConfig *config.MappingConfig) ConfigPageData {
+ data := ConfigPageData{
+ BasePageData: buildBasePageData(yamlConfig),
+ }
+
+ for _, list := range yamlConfig.Lists {
+ normalized := list
+ if normalized.Type == "" {
+ normalized.Type = "annotation"
+ }
+ if list.IsCorpus() {
+ data.CorpusMappings = append(data.CorpusMappings, normalized)
+ } else {
+ data.AnnotationMappings = append(data.AnnotationMappings, normalized)
+ }
+ }
+ return data
}
func handleCompositeQueryTransform(m *mapper.Mapper, lists []config.MappingList) fiber.Handler {
@@ -513,10 +591,23 @@
return nil
}
-func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
+func handleKalamarPlugin(yamlConfig *config.MappingConfig, configTmpl *template.Template, pluginTmpl *texttemplate.Template) fiber.Handler {
return func(c *fiber.Ctx) error {
mapID := c.Params("map")
+ // Config page (GET /)
+ if mapID == "" {
+ data := buildConfigPageData(yamlConfig)
+ var buf bytes.Buffer
+ if err := configTmpl.Execute(&buf, data); err != nil {
+ log.Error().Err(err).Msg("Failed to execute config template")
+ return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+ }
+ c.Set("Content-Type", "text/html")
+ return c.Send(buf.Bytes())
+ }
+
+ // Single-mapping page (GET /:map) — existing behavior
// Get query parameters
dir := c.Query("dir", "atob")
foundryA := c.Query("foundryA", "")
@@ -537,30 +628,6 @@
})
}
- // Get list of available mappings
- var mappings []TemplateMapping
- for _, list := range yamlConfig.Lists {
- mappings = append(mappings, TemplateMapping{
- ID: list.ID,
- Description: list.Description,
- })
- }
-
- // Prepare template data
- data := TemplateData{
- Title: config.Title,
- Version: config.Version,
- Hash: config.Buildhash,
- Date: config.Buildtime,
- Description: config.Description,
- Server: yamlConfig.Server,
- SDK: yamlConfig.SDK,
- ServiceURL: yamlConfig.ServiceURL,
- MapID: mapID,
- Mappings: mappings,
- }
-
- // Add query parameters to template data
queryParams := QueryParams{
Dir: dir,
FoundryA: foundryA,
@@ -569,136 +636,56 @@
LayerB: layerB,
}
- // Generate HTML
- html := generateKalamarPluginHTML(data, queryParams)
+ queryURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "query", queryParams)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to build query service URL")
+ return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+ }
+ reversed := queryParams
+ if queryParams.Dir == "btoa" {
+ reversed.Dir = "atob"
+ } else {
+ reversed.Dir = "btoa"
+ }
+ responseURL, err := buildMapServiceURL(yamlConfig.ServiceURL, mapID, "response", reversed)
+ if err != nil {
+ log.Warn().Err(err).Msg("Failed to build response service URL")
+ return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+ }
+ data := SingleMappingPageData{
+ BasePageData: buildBasePageData(yamlConfig),
+ MapID: mapID,
+ Mappings: yamlConfig.Lists,
+ QueryURL: queryURL,
+ ResponseURL: responseURL,
+ }
+
+ var buf bytes.Buffer
+ if err := pluginTmpl.Execute(&buf, data); err != nil {
+ log.Error().Err(err).Msg("Failed to execute plugin template")
+ return c.Status(fiber.StatusInternalServerError).SendString("internal error")
+ }
c.Set("Content-Type", "text/html")
- return c.SendString(html)
+ return c.Send(buf.Bytes())
}
}
-// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
-// This function can be easily modified to change the appearance and content
-func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
- html := `<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <title>` + data.Title + `</title>
- <script src="` + data.SDK + `"
- data-server="` + data.Server + `"></script>
-</head>
-<body>
- <div class="container">
- <h1>` + data.Title + `</h1>
- <p>` + data.Description + `</p>`
-
- if data.MapID != "" {
- html += `<p>Map ID: ` + data.MapID + `</p>`
+func buildMapServiceURL(serviceURL, mapID, endpoint string, params QueryParams) (string, error) {
+ service, err := url.Parse(serviceURL)
+ if err != nil {
+ return "", err
}
+ service.Path = path.Join(service.Path, mapID, endpoint)
+ service.RawQuery = buildQueryParams(params.Dir, params.FoundryA, params.FoundryB, params.LayerA, params.LayerB)
+ return service.String(), nil
+}
- html += ` <h2>Plugin Information</h2>
- <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
- <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
- <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
-
- <h2>Available API Endpoints</h2>
- <dl>
-
- <dt><tt><strong>GET</strong> /:map</tt></dt>
- <dd><small>Kalamar integration</small></dd>
-
- <dt><tt><strong>POST</strong> /:map/query</tt></dt>
- <dd><small>Transform JSON query objects using term mapping rules</small></dd>
-
- <dt><tt><strong>POST</strong> /:map/response</tt></dt>
- <dd><small>Transform JSON response objects using term mapping rules</small></dd>
-
- </dl>
-
- <h2>Available Term Mappings</h2>
- <dl>`
-
- for _, m := range data.Mappings {
- html += `<dt><tt>` + m.ID + `</tt></dt>`
- html += `<dd>` + m.Description + `</dd>`
+func formatConsoleField(value string) string {
+ if strings.ContainsAny(value, " \t") {
+ return strconv.Quote(value)
}
-
- html += `
- </dl></div>`
-
- if data.MapID != "" {
-
- queryServiceURL, err := url.Parse(data.ServiceURL)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to join URL path")
- }
-
- // Use path.Join to normalize the path part
- queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
-
- // Build query parameters for query URL
- queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
- queryServiceURL.RawQuery = queryParamString
-
- responseServiceURL, err := url.Parse(data.ServiceURL)
- if err != nil {
- log.Warn().Err(err).Msg("Failed to join URL path")
- }
-
- // Use path.Join to normalize the path part
- responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
-
- reversedDir := "btoa"
- if queryParams.Dir == "btoa" {
- reversedDir = "atob"
- }
-
- // Build query parameters for response URL (with reversed direction)
- responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
- responseServiceURL.RawQuery = responseParamString
-
- html += `<script>
- <!-- activates/deactivates Mapper. -->
-
- let qdata = {
- 'action' : 'pipe',
- 'service' : '` + queryServiceURL.String() + `'
- };
-
- let rdata = {
- 'action' : 'pipe',
- 'service' : '` + responseServiceURL.String() + `'
- };
-
-
- function pluginit (p) {
- p.onMessage = function(msg) {
- if (msg.key == 'koralmapper') {
- if (msg.value) {
- qdata['job'] = 'add';
- }
- else {
- qdata['job'] = 'del';
- };
- KorAPlugin.sendMsg(qdata);
- if (msg.value) {
- rdata['job'] = 'add-after';
- }
- else {
- rdata['job'] = 'del-after';
- };
- KorAPlugin.sendMsg(rdata);
- };
- };
- };
- </script>`
- }
-
- html += ` </body>
-</html>`
-
- return html
+ return value
}
// buildQueryParams builds a query string from the provided parameters
diff --git a/cmd/koralmapper/main_test.go b/cmd/koralmapper/main_test.go
index a3e45a8..30def87 100644
--- a/cmd/koralmapper/main_test.go
+++ b/cmd/koralmapper/main_test.go
@@ -3,7 +3,9 @@
import (
"bytes"
"encoding/json"
+ "html/template"
"io"
+ "io/fs"
"net/http"
"net/http/httptest"
"os"
@@ -19,32 +21,56 @@
"github.com/stretchr/testify/require"
)
-func TestTransformEndpoint(t *testing.T) {
- // Create test mapping list
- mappingList := tmconfig.MappingList{
- ID: "test-mapper",
- FoundryA: "opennlp",
- LayerA: "p",
- FoundryB: "upos",
- LayerB: "p",
- Mappings: []tmconfig.MappingRule{
- "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]",
- "[DET] <> [opennlp/p=DET]",
- },
+func loadConfigFromYAML(t *testing.T, configYAML string, mappingYAMLs ...string) *tmconfig.MappingConfig {
+ t.Helper()
+
+ configPath := ""
+ if configYAML != "" {
+ cfgFile, err := os.CreateTemp("", "koralmapper-config-*.yaml")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Remove(cfgFile.Name()) })
+ _, err = cfgFile.WriteString(configYAML)
+ require.NoError(t, err)
+ require.NoError(t, cfgFile.Close())
+ configPath = cfgFile.Name()
}
+ mappingPaths := make([]string, 0, len(mappingYAMLs))
+ for _, content := range mappingYAMLs {
+ mapFile, err := os.CreateTemp("", "koralmapper-mapping-*.yaml")
+ require.NoError(t, err)
+ t.Cleanup(func() { _ = os.Remove(mapFile.Name()) })
+ _, err = mapFile.WriteString(content)
+ require.NoError(t, err)
+ require.NoError(t, mapFile.Close())
+ mappingPaths = append(mappingPaths, mapFile.Name())
+ }
+
+ cfg, err := tmconfig.LoadFromSources(configPath, mappingPaths)
+ require.NoError(t, err)
+ return cfg
+}
+
+func TestTransformEndpoint(t *testing.T) {
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-mapper
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [opennlp/p=PIDAT & opennlp/p=AdjType:Pdt]"
+ - "[DET] <> [opennlp/p=DET]"
+`)
+
// Create mapper
- m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
- // Create mock config for testing
- mockConfig := &tmconfig.MappingConfig{
- Lists: []tmconfig.MappingList{mappingList},
- }
-
// Create fiber app
app := fiber.New()
- setupRoutes(app, m, mockConfig)
+ setupRoutes(app, m, cfg)
tests := []struct {
name string
@@ -260,30 +286,24 @@
}
func TestResponseTransformEndpoint(t *testing.T) {
- // Create test mapping list
- mappingList := tmconfig.MappingList{
- ID: "test-response-mapper",
- FoundryA: "marmot",
- LayerA: "m",
- FoundryB: "opennlp",
- LayerB: "p",
- Mappings: []tmconfig.MappingRule{
- "[gender:masc] <> [p=M & m=M]",
- },
- }
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: test-response-mapper
+ foundryA: marmot
+ layerA: m
+ foundryB: opennlp
+ layerB: p
+ mappings:
+ - "[gender:masc] <> [p=M & m=M]"
+`)
// Create mapper
- m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
- // Create mock config for testing
- mockConfig := &tmconfig.MappingConfig{
- Lists: []tmconfig.MappingList{mappingList},
- }
-
// Create fiber app
app := fiber.New()
- setupRoutes(app, m, mockConfig)
+ setupRoutes(app, m, cfg)
tests := []struct {
name string
@@ -1292,71 +1312,62 @@
assert.Contains(t, htmlContent, "'service' : '"+expectedJSURL)
}
-func TestGenerateKalamarPluginHTMLWithURLJoining(t *testing.T) {
+func TestBuildMapServiceURLWithURLJoining(t *testing.T) {
tests := []struct {
name string
serviceURL string
mapID string
+ endpoint string
expected string
}{
{
name: "Service URL without trailing slash",
serviceURL: "https://example.com/plugin/koralmapper",
mapID: "test-mapper",
- expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ endpoint: "query",
+ expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Service URL with trailing slash",
serviceURL: "https://example.com/plugin/koralmapper/",
mapID: "test-mapper",
- expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ endpoint: "query",
+ expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Map ID with leading slash",
serviceURL: "https://example.com/plugin/koralmapper",
mapID: "/test-mapper",
- expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ endpoint: "query",
+ expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Both with slashes",
serviceURL: "https://example.com/plugin/koralmapper/",
mapID: "/test-mapper",
- expected: "'service' : 'https://example.com/plugin/koralmapper/test-mapper/query",
+ endpoint: "query",
+ expected: "https://example.com/plugin/koralmapper/test-mapper/query?dir=atob",
},
{
name: "Complex map ID",
serviceURL: "https://example.com/api/v1/",
mapID: "complex-mapper-name_123",
- expected: "'service' : 'https://example.com/api/v1/complex-mapper-name_123/query",
+ endpoint: "query",
+ expected: "https://example.com/api/v1/complex-mapper-name_123/query?dir=atob",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- data := TemplateData{
- Title: "Test Mapper",
- Version: "1.0.0",
- Hash: "abcd1234",
- Date: "2024-01-01",
- Description: "Test description",
- Server: "https://example.com/",
- SDK: "https://example.com/js/sdk.js",
- ServiceURL: tt.serviceURL,
- MapID: tt.mapID,
- Mappings: []TemplateMapping{},
- }
-
- // Use default query parameters for this test
- queryParams := QueryParams{
+ got, err := buildMapServiceURL(tt.serviceURL, tt.mapID, tt.endpoint, QueryParams{
Dir: "atob",
FoundryA: "",
FoundryB: "",
LayerA: "",
LayerB: "",
- }
-
- html := generateKalamarPluginHTML(data, queryParams)
- assert.Contains(t, html, tt.expected)
+ })
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, got)
})
}
}
@@ -1550,33 +1561,28 @@
}
func TestCompositeQueryEndpoint(t *testing.T) {
- lists := []tmconfig.MappingList{
- {
- ID: "step1",
- FoundryA: "opennlp",
- LayerA: "p",
- FoundryB: "opennlp",
- LayerB: "p",
- Mappings: []tmconfig.MappingRule{
- "[PIDAT] <> [DET]",
- },
- },
- {
- ID: "step2",
- FoundryA: "opennlp",
- LayerA: "p",
- FoundryB: "upos",
- LayerB: "p",
- Mappings: []tmconfig.MappingRule{
- "[DET] <> [PRON]",
- },
- },
- }
- m, err := mapper.NewMapper(lists)
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: step1
+ foundryA: opennlp
+ layerA: p
+ foundryB: opennlp
+ layerB: p
+ mappings:
+ - "[PIDAT] <> [DET]"
+ - id: step2
+ foundryA: opennlp
+ layerA: p
+ foundryB: upos
+ layerB: p
+ mappings:
+ - "[DET] <> [PRON]"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
- setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+ setupRoutes(app, m, cfg)
tests := []struct {
name string
@@ -1669,23 +1675,22 @@
}
func TestCompositeResponseEndpoint(t *testing.T) {
- lists := []tmconfig.MappingList{
- {
- ID: "resp-step1",
- Type: "corpus",
- Mappings: []tmconfig.MappingRule{"textClass=novel <> genre=fiction"},
- },
- {
- ID: "resp-step2",
- Type: "corpus",
- Mappings: []tmconfig.MappingRule{"genre=fiction <> category=lit"},
- },
- }
- m, err := mapper.NewMapper(lists)
+ cfg := loadConfigFromYAML(t, `
+lists:
+ - id: resp-step1
+ type: corpus
+ mappings:
+ - "textClass=novel <> genre=fiction"
+ - id: resp-step2
+ type: corpus
+ mappings:
+ - "genre=fiction <> category=lit"
+`)
+ m, err := mapper.NewMapper(cfg.Lists)
require.NoError(t, err)
app := fiber.New()
- setupRoutes(app, m, &tmconfig.MappingConfig{Lists: lists})
+ setupRoutes(app, m, cfg)
tests := []struct {
name string
@@ -1767,3 +1772,410 @@
})
}
}
+
+func TestEmbeddedFilesExist(t *testing.T) {
+ files := []string{"static/config.html", "static/plugin.html", "static/config.js", "static/style.css"}
+ for _, f := range files {
+ t.Run(f, func(t *testing.T) {
+ data, err := fs.ReadFile(staticFS, f)
+ require.NoError(t, err, "embedded file %s should exist", f)
+ assert.NotEmpty(t, data, "embedded file %s should not be empty", f)
+ })
+ }
+}
+
+func TestConfigTemplateParsesSuccessfully(t *testing.T) {
+ tmpl, err := template.ParseFS(staticFS, "static/config.html")
+ require.NoError(t, err, "config template should parse without error")
+ require.NotNil(t, tmpl)
+}
+
+func TestStaticFileServing(t *testing.T) {
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ }
+ m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ require.NoError(t, err)
+ mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ tests := []struct {
+ name string
+ url string
+ expectedCode int
+ expectedCType string
+ }{
+ {
+ name: "config.js is served with correct content type",
+ url: "/static/config.js",
+ expectedCode: http.StatusOK,
+ expectedCType: "text/javascript",
+ },
+ {
+ name: "style.css is served with correct content type",
+ url: "/static/style.css",
+ expectedCode: http.StatusOK,
+ expectedCType: "text/css",
+ },
+ {
+ name: "non-existent static file returns 404",
+ url: "/static/nonexistent.txt",
+ expectedCode: http.StatusNotFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tt.url, nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, tt.expectedCode, resp.StatusCode)
+ if tt.expectedCType != "" {
+ assert.Contains(t, resp.Header.Get("Content-Type"), tt.expectedCType)
+ }
+ })
+ }
+}
+
+func TestStaticFileContent(t *testing.T) {
+ mappingList := tmconfig.MappingList{
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ }
+ m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+ require.NoError(t, err)
+ mockConfig := &tmconfig.MappingConfig{Lists: []tmconfig.MappingList{mappingList}}
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ // Verify served content matches embedded content
+ files := []string{"config.js", "style.css"}
+ for _, f := range files {
+ t.Run(f, func(t *testing.T) {
+ embedded, err := fs.ReadFile(staticFS, "static/"+f)
+ require.NoError(t, err)
+
+ req := httptest.NewRequest(http.MethodGet, "/static/"+f, nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ assert.Equal(t, embedded, body, "served content should match embedded content for %s", f)
+ })
+ }
+}
+
+func TestConfigPageRendering(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "anno-mapper",
+ Description: "Annotation mapping",
+ FoundryA: "opennlp",
+ LayerA: "p",
+ FoundryB: "upos",
+ LayerB: "p",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ {
+ ID: "corpus-mapper",
+ Type: "corpus",
+ Description: "Corpus mapping",
+ Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
+ },
+ }
+ m, err := mapper.NewMapper(lists)
+ require.NoError(t, err)
+
+ mockConfig := &tmconfig.MappingConfig{
+ SDK: "https://example.com/sdk.js",
+ Server: "https://example.com/",
+ ServiceURL: "https://example.com/plugin/koralmapper",
+ Lists: lists,
+ }
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ htmlContent := string(body)
+
+ // HTML structure
+ assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+ assert.Contains(t, htmlContent, `<meta charset="UTF-8">`)
+ assert.Contains(t, htmlContent, "Koral-Mapper")
+
+ // SDK and server
+ assert.Contains(t, htmlContent, `src="https://example.com/sdk.js"`)
+ assert.Contains(t, htmlContent, `data-server="https://example.com/"`)
+
+ // ServiceURL as data attribute
+ assert.Contains(t, htmlContent, `data-service-url="https://example.com/plugin/koralmapper"`)
+
+ // Static file references
+ assert.Contains(t, htmlContent, `/static/style.css`)
+ assert.Contains(t, htmlContent, `/static/config.js`)
+
+ // Annotation mapping section
+ assert.Contains(t, htmlContent, "Query")
+ assert.Contains(t, htmlContent, `data-id="anno-mapper"`)
+ assert.Contains(t, htmlContent, `data-type="annotation"`)
+ assert.Contains(t, htmlContent, `value="opennlp"`)
+ assert.Contains(t, htmlContent, `value="upos"`)
+ assert.Contains(t, htmlContent, "Annotation mapping")
+
+ // Corpus mapping section
+ assert.Contains(t, htmlContent, "Corpus")
+ assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
+ assert.Contains(t, htmlContent, `data-type="corpus"`)
+ assert.Contains(t, htmlContent, "Corpus mapping")
+}
+
+func TestConfigPageAnnotationMappingHasFoundryInputs(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "anno-mapper",
+ FoundryA: "opennlp",
+ LayerA: "p",
+ FoundryB: "upos",
+ LayerB: "pos",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ }
+ m, err := mapper.NewMapper(lists)
+ require.NoError(t, err)
+
+ mockConfig := &tmconfig.MappingConfig{Lists: lists}
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ htmlContent := string(body)
+
+ // Data attributes for default values
+ assert.Contains(t, htmlContent, `data-default-foundry-a="opennlp"`)
+ assert.Contains(t, htmlContent, `data-default-layer-a="p"`)
+ 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"`)
+
+ // Direction arrow
+ assert.Contains(t, htmlContent, `class="dir-arrow"`)
+ assert.Contains(t, htmlContent, `data-dir="atob"`)
+
+ // Request and response checkboxes
+ assert.Contains(t, htmlContent, `class="request-cb"`)
+ assert.Contains(t, htmlContent, `class="response-cb"`)
+}
+
+func TestConfigPageCorpusMappingHasNoFoundryInputs(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "corpus-mapper",
+ Type: "corpus",
+ Mappings: []tmconfig.MappingRule{"textClass=science <> textClass=akademisch"},
+ },
+ }
+
+ m, err := mapper.NewMapper(lists)
+ require.NoError(t, err)
+
+ mockConfig := &tmconfig.MappingConfig{Lists: lists}
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ htmlContent := string(body)
+
+ // Corpus section exists
+ assert.Contains(t, htmlContent, `data-id="corpus-mapper"`)
+ assert.Contains(t, htmlContent, `data-type="corpus"`)
+
+ // Checkboxes present
+ assert.Contains(t, htmlContent, `class="request-cb"`)
+ 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"`)
+}
+
+func TestConfigPageBackwardCompatibility(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "test-mapper",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ }
+
+ m, err := mapper.NewMapper(lists)
+ require.NoError(t, err)
+
+ mockConfig := &tmconfig.MappingConfig{
+ ServiceURL: "https://example.com/plugin/koralmapper",
+ Lists: lists,
+ }
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/test-mapper", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ htmlContent := string(body)
+
+ // Old-style single-mapping page behavior
+ assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+ assert.Contains(t, htmlContent, "Koral-Mapper")
+ assert.Contains(t, htmlContent, "Map ID: test-mapper")
+ assert.Contains(t, htmlContent, "KorAPlugin.sendMsg")
+ assert.Contains(t, htmlContent, "test-mapper/query")
+}
+
+func TestBuildConfigPageData(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "anno1",
+ Description: "First annotation",
+ FoundryA: "f1a",
+ LayerA: "l1a",
+ FoundryB: "f1b",
+ LayerB: "l1b",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ {
+ ID: "corpus1",
+ Type: "corpus",
+ Description: "First corpus",
+ Mappings: []tmconfig.MappingRule{"textClass=a <> textClass=b"},
+ },
+ {
+ ID: "anno2",
+ Type: "annotation",
+ Description: "Second annotation",
+ FoundryA: "f2a",
+ LayerA: "l2a",
+ FoundryB: "f2b",
+ LayerB: "l2b",
+ Mappings: []tmconfig.MappingRule{"[C] <> [D]"},
+ },
+ }
+
+ mockConfig := &tmconfig.MappingConfig{
+ SDK: "https://example.com/sdk.js",
+ Server: "https://example.com/",
+ ServiceURL: "https://example.com/service",
+ Lists: lists,
+ }
+
+ data := buildConfigPageData(mockConfig)
+
+ assert.Equal(t, "https://example.com/sdk.js", data.SDK)
+ assert.Equal(t, "https://example.com/", data.Server)
+ assert.Equal(t, "https://example.com/service", data.ServiceURL)
+
+ require.Len(t, data.AnnotationMappings, 2)
+ assert.Equal(t, "anno1", data.AnnotationMappings[0].ID)
+ assert.Equal(t, "annotation", data.AnnotationMappings[0].Type)
+ assert.Equal(t, "f1a", data.AnnotationMappings[0].FoundryA)
+ assert.Equal(t, "First annotation", data.AnnotationMappings[0].Description)
+ assert.Equal(t, "anno2", data.AnnotationMappings[1].ID)
+ assert.Equal(t, "annotation", data.AnnotationMappings[1].Type)
+
+ require.Len(t, data.CorpusMappings, 1)
+ assert.Equal(t, "corpus1", data.CorpusMappings[0].ID)
+ assert.Equal(t, "corpus", data.CorpusMappings[0].Type)
+ assert.Equal(t, "First corpus", data.CorpusMappings[0].Description)
+}
+
+func TestConfigPagePreservesOrderOfMappings(t *testing.T) {
+ lists := []tmconfig.MappingList{
+ {
+ ID: "mapper-z",
+ FoundryA: "fa",
+ FoundryB: "fb",
+ Mappings: []tmconfig.MappingRule{"[A] <> [B]"},
+ },
+ {
+ ID: "mapper-a",
+ FoundryA: "fa",
+ FoundryB: "fb",
+ Mappings: []tmconfig.MappingRule{"[C] <> [D]"},
+ },
+ {
+ ID: "mapper-m",
+ FoundryA: "fa",
+ FoundryB: "fb",
+ Mappings: []tmconfig.MappingRule{"[E] <> [F]"},
+ },
+ }
+ m, err := mapper.NewMapper(lists)
+ require.NoError(t, err)
+
+ mockConfig := &tmconfig.MappingConfig{Lists: lists}
+ tmconfig.ApplyDefaults(mockConfig)
+
+ app := fiber.New()
+ setupRoutes(app, m, mockConfig)
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ htmlContent := string(body)
+
+ // Verify the order is preserved (z before a before m)
+ idxZ := strings.Index(htmlContent, `data-id="mapper-z"`)
+ idxA := strings.Index(htmlContent, `data-id="mapper-a"`)
+ idxM := strings.Index(htmlContent, `data-id="mapper-m"`)
+ assert.Greater(t, idxA, idxZ, "mapper-a should appear after mapper-z")
+ assert.Greater(t, idxM, idxA, "mapper-m should appear after mapper-a")
+}
diff --git a/cmd/koralmapper/static/config.html b/cmd/koralmapper/static/config.html
new file mode 100644
index 0000000..a946501
--- /dev/null
+++ b/cmd/koralmapper/static/config.html
@@ -0,0 +1,66 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>{{.Title}}</title>
+ <link rel="stylesheet" href="/static/style.css">
+ <script src="{{.SDK}}"
+ data-server="{{.Server}}"></script>
+</head>
+<body>
+ <div class="container" data-service-url="{{.ServiceURL}}">
+ <h1>{{.Title}}</h1>
+ <p>{{.Description}}</p>
+
+ {{if .AnnotationMappings}}
+ <section class="mapping-section">
+ <h2>Query</h2>
+ {{range .AnnotationMappings}}
+ <div class="mapping" data-id="{{.ID}}" data-type="annotation"
+ data-default-foundry-a="{{.FoundryA}}" data-default-layer-a="{{.LayerA}}"
+ data-default-foundry-b="{{.FoundryB}}" data-default-layer-b="{{.LayerB}}">
+ <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">→</button>
+ <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+ </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">→</button>
+ <input type="text" class="foundryB" value="{{.FoundryB}}" size="8">/<input type="text" class="layerB" value="{{.LayerB}}" size="4">
+ </div>
+ </div>
+ {{end}}
+ </section>
+ {{end}}
+
+ {{if .CorpusMappings}}
+ <section class="mapping-section">
+ <h2>Corpus</h2>
+ {{range .CorpusMappings}}
+ <div class="mapping" data-id="{{.ID}}" data-type="corpus">
+ <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>
+ </div>
+ <div class="mapping-row">
+ <label><input type="checkbox" class="response-cb" name="response"> Response</label>
+ </div>
+ </div>
+ {{end}}
+ </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>
+ </div>
+ <script src="/static/config.js"></script>
+</body>
+</html>
diff --git a/cmd/koralmapper/static/config.js b/cmd/koralmapper/static/config.js
new file mode 100644
index 0000000..3918c74
--- /dev/null
+++ b/cmd/koralmapper/static/config.js
@@ -0,0 +1 @@
+"use strict";
diff --git a/cmd/koralmapper/static/plugin.html b/cmd/koralmapper/static/plugin.html
new file mode 100644
index 0000000..2000c06
--- /dev/null
+++ b/cmd/koralmapper/static/plugin.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>{{.Title}}</title>
+ <script src="{{.SDK}}"
+ data-server="{{.Server}}"></script>
+</head>
+<body>
+ <div class="container">
+ <h1>{{.Title}}</h1>
+ <p>{{.Description}}</p>
+
+ <p>Map ID: {{.MapID}}</p>
+
+ <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>
+
+ <h2>Available API Endpoints</h2>
+ <dl>
+ <dt><tt><strong>GET</strong> /:map</tt></dt>
+ <dd><small>Kalamar integration</small></dd>
+
+ <dt><tt><strong>POST</strong> /:map/query</tt></dt>
+ <dd><small>Transform JSON query objects using term mapping rules</small></dd>
+
+ <dt><tt><strong>POST</strong> /:map/response</tt></dt>
+ <dd><small>Transform JSON response objects using term mapping rules</small></dd>
+ </dl>
+
+ <h2>Available Term Mappings</h2>
+ <dl>
+ {{range .Mappings}}
+ <dt><tt>{{.ID}}</tt></dt>
+ <dd>{{.Description}}</dd>
+ {{end}}
+ </dl>
+ </div>
+
+ <script>
+ <!-- activates/deactivates Mapper. -->
+ let qdata = {
+ 'action' : 'pipe',
+ 'service' : '{{.QueryURL}}'
+ };
+
+ let rdata = {
+ 'action' : 'pipe',
+ 'service' : '{{.ResponseURL}}'
+ };
+
+ function pluginit (p) {
+ p.onMessage = function(msg) {
+ if (msg.key == 'koralmapper') {
+ if (msg.value) {
+ qdata['job'] = 'add';
+ }
+ else {
+ qdata['job'] = 'del';
+ };
+ KorAPlugin.sendMsg(qdata);
+ if (msg.value) {
+ rdata['job'] = 'add-after';
+ }
+ else {
+ rdata['job'] = 'del-after';
+ };
+ KorAPlugin.sendMsg(rdata);
+ };
+ };
+ };
+ </script>
+</body>
+</html>
diff --git a/cmd/koralmapper/static/style.css b/cmd/koralmapper/static/style.css
new file mode 100644
index 0000000..693ce9b
--- /dev/null
+++ b/cmd/koralmapper/static/style.css
@@ -0,0 +1 @@
+body { font-family: sans-serif; }