Add configurable Kalamar integration

Change-Id: Ic07423dd7cc605509a364154bf4f37e4c13dc0d1
diff --git a/README.md b/README.md
index 1b0fc1a..a9835a4 100644
--- a/README.md
+++ b/README.md
@@ -25,19 +25,36 @@
 
 ## Configuration File Format
 
-Mapping rules are defined in YAML files with the following structure:
+Mapping rules are defined in a YAML configuration file.
 
 ```yaml
-- id: mapping-list-id
-  foundryA: source-foundry
-  layerA: source-layer
-  foundryB: target-foundry
-  layerB: target-layer
-  mappings:
-    - "[pattern1] <> [replacement1]"
-    - "[pattern2] <> [replacement2]"
+# Optional: Custom SDK endpoint for Kalamar plugin integration
+sdk: "https://custom.example.com/js/korap-plugin.js"
+
+# Optional: Custom server endpoint for Kalamar plugin integration  
+server: "https://custom.example.com/"
+
+# Mapping lists (same format as standard format)
+lists:
+  - id: mapping-list-id
+    foundryA: source-foundry
+    layerA: source-layer
+    foundryB: target-foundry
+    layerB: target-layer
+    mappings:
+      - "[pattern1] <> [replacement1]"
+      - "[pattern2] <> [replacement2]"
 ```
 
+The `sdk` and `server` fields are optional and override the default endpoints used for Kalamar plugin integration:
+
+- **`sdk`**: Custom SDK JavaScript file URL (default: `https://korap.ids-mannheim.de/js/korap-plugin-latest.js`)
+- **`server`**: Custom server endpoint URL (default: `https://korap.ids-mannheim.de/`)
+
+These values are applied during configuration parsing and affect the HTML plugin page served at the root endpoint (`/`).
+
+### Mapping Rules
+
 Each mapping rule consists of two patterns separated by `<>`. The patterns can be:
 - Simple terms: `[key]` or `[foundry/layer=key]` or `[foundry/layer=key:value]`
 - Complex terms with AND/OR relations: `[term1 & term2]` or `[term1 | term2]` or `[term1 | (term2 & term3)]`
@@ -106,13 +123,27 @@
 }
 ```
 
+### GET /
+
+Serves the Kalamar plugin integration page. This HTML page includes:
+
+- Plugin information and available mapping lists
+- JavaScript integration code for Kalamar
+- SDK and server endpoints configured via `sdk` and `server` configuration fields
+
+The SDK script and server data-attribute in the HTML are determined by the configuration file's `sdk` and `server` values, with fallback to default endpoints if not specified.
+
+### GET /health
+
+Health check endpoint that returns "OK" with HTTP 200 status.
+
 ## Progress
 
 - [x] Mapping functionality
 - [x] Support for rewrites
 - [x] Web service
 - [ ] Support for negation
-- [ ] JSON script for Kalamar integration
+- [x] JSON script for Kalamar integration
 - [ ] Response rewriting
 - [ ] Integration of mapping files
 
diff --git a/cmd/termmapper/fuzz_test.go b/cmd/termmapper/fuzz_test.go
index ee5c80d..650a08d 100644
--- a/cmd/termmapper/fuzz_test.go
+++ b/cmd/termmapper/fuzz_test.go
@@ -50,7 +50,7 @@
 	}
 
 	// Create mock config for testing
-	mockConfig := &tmconfig.MappingLists{
+	mockConfig := &tmconfig.MappingConfig{
 		Lists: []tmconfig.MappingList{mappingList},
 	}
 
@@ -164,7 +164,7 @@
 	require.NoError(t, err)
 
 	// Create mock config for testing
-	mockConfig := &tmconfig.MappingLists{
+	mockConfig := &tmconfig.MappingConfig{
 		Lists: []tmconfig.MappingList{mappingList},
 	}
 
diff --git a/cmd/termmapper/main.go b/cmd/termmapper/main.go
index 211dadc..60dc0a0 100644
--- a/cmd/termmapper/main.go
+++ b/cmd/termmapper/main.go
@@ -33,6 +33,8 @@
 	Hash        string
 	Date        string
 	Description string
+	Server      string
+	SDK         string
 	MappingIDs  []string
 }
 
@@ -114,7 +116,7 @@
 	}
 }
 
-func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingLists) {
+func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
 	// Health check endpoint
 	app.Get("/health", func(c *fiber.Ctx) error {
 		return c.SendString("OK")
@@ -224,7 +226,7 @@
 	return nil
 }
 
-func handleKalamarPlugin(yamlConfig *config.MappingLists) fiber.Handler {
+func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
 	return func(c *fiber.Ctx) error {
 		// Get list of available mapping IDs
 		var mappingIDs []string
@@ -232,6 +234,10 @@
 			mappingIDs = append(mappingIDs, list.ID)
 		}
 
+		// Use values from config (defaults are already applied during parsing)
+		server := yamlConfig.Server
+		sdk := yamlConfig.SDK
+
 		// Prepare template data
 		data := TemplateData{
 			Title:       config.Title,
@@ -239,6 +245,8 @@
 			Hash:        config.Buildhash,
 			Date:        config.Buildtime,
 			Description: config.Description,
+			Server:      server,
+			SDK:         sdk,
 			MappingIDs:  mappingIDs,
 		}
 
@@ -258,6 +266,8 @@
 <head>
     <meta charset="UTF-8">
     <title>` + data.Title + `</title>
+    <script src="` + data.SDK + `"
+            data-server="` + data.Server + `"></script>
 </head>
 <body>
     <div class="container">
@@ -291,6 +301,29 @@
 
 	html += `
     </ul>
+
+    <script>
+  		<!-- activates/deactivates Mapper. -->
+  		  
+       let data = {
+         'action'  : 'pipe',
+         'service' : 'https://korap.ids-mannheim.de/plugin/termmapper/query'
+       };
+
+       function pluginit (p) {
+         p.onMessage = function(msg) {
+           if (msg.key == 'termmapper') {
+             if (msg.value) {
+               data['job'] = 'add';
+             }
+             else {
+               data['job'] = 'del';
+             };
+             KorAPlugin.sendMsg(data);
+           };
+         };
+       };
+    </script>
   </body>
 </html>`
 
diff --git a/cmd/termmapper/main_test.go b/cmd/termmapper/main_test.go
index 5a9826b..292d3d9 100644
--- a/cmd/termmapper/main_test.go
+++ b/cmd/termmapper/main_test.go
@@ -34,7 +34,7 @@
 	require.NoError(t, err)
 
 	// Create mock config for testing
-	mockConfig := &tmconfig.MappingLists{
+	mockConfig := &tmconfig.MappingConfig{
 		Lists: []tmconfig.MappingList{mappingList},
 	}
 
@@ -269,7 +269,7 @@
 	require.NoError(t, err)
 
 	// Create mock config for testing
-	mockConfig := &tmconfig.MappingLists{
+	mockConfig := &tmconfig.MappingConfig{
 		Lists: []tmconfig.MappingList{mappingList},
 	}
 
@@ -299,3 +299,89 @@
 	assert.Contains(t, string(body), "KoralPipe-TermMapper")
 
 }
+
+func TestKalamarPluginWithCustomSdkAndServer(t *testing.T) {
+	// Create test mapping list
+	mappingList := tmconfig.MappingList{
+		ID: "test-mapper",
+		Mappings: []tmconfig.MappingRule{
+			"[A] <> [B]",
+		},
+	}
+
+	// Create mapper
+	m, err := mapper.NewMapper([]tmconfig.MappingList{mappingList})
+	require.NoError(t, err)
+
+	tests := []struct {
+		name           string
+		customSDK      string
+		customServer   string
+		expectedSDK    string
+		expectedServer string
+	}{
+		{
+			name:           "Custom SDK and Server values",
+			customSDK:      "https://custom.example.com/custom-sdk.js",
+			customServer:   "https://custom.example.com/",
+			expectedSDK:    "https://custom.example.com/custom-sdk.js",
+			expectedServer: "https://custom.example.com/",
+		},
+		{
+			name:           "Only custom SDK value",
+			customSDK:      "https://custom.example.com/custom-sdk.js",
+			customServer:   "https://korap.ids-mannheim.de/", // defaults applied during parsing
+			expectedSDK:    "https://custom.example.com/custom-sdk.js",
+			expectedServer: "https://korap.ids-mannheim.de/",
+		},
+		{
+			name:           "Only custom Server value",
+			customSDK:      "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
+			customServer:   "https://custom.example.com/",
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
+			expectedServer: "https://custom.example.com/",
+		},
+		{
+			name:           "Defaults applied during parsing",
+			customSDK:      "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // defaults applied during parsing
+			customServer:   "https://korap.ids-mannheim.de/",                          // defaults applied during parsing
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js",
+			expectedServer: "https://korap.ids-mannheim.de/",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create mock config with custom values
+			mockConfig := &tmconfig.MappingConfig{
+				SDK:    tt.customSDK,
+				Server: tt.customServer,
+				Lists:  []tmconfig.MappingList{mappingList},
+			}
+
+			// Create fiber app
+			app := fiber.New()
+			setupRoutes(app, m, mockConfig)
+
+			// Test Kalamar plugin endpoint
+			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)
+
+			// Check that the HTML contains the expected SDK and Server values
+			assert.Contains(t, htmlContent, `src="`+tt.expectedSDK+`"`)
+			assert.Contains(t, htmlContent, `data-server="`+tt.expectedServer+`"`)
+
+			// Ensure it's still a valid HTML page
+			assert.Contains(t, htmlContent, "KoralPipe-TermMapper")
+			assert.Contains(t, htmlContent, "<!DOCTYPE html>")
+		})
+	}
+}
diff --git a/config/config.go b/config/config.go
index 6bafaad..128c063 100644
--- a/config/config.go
+++ b/config/config.go
@@ -9,6 +9,11 @@
 	"gopkg.in/yaml.v3"
 )
 
+const (
+	defaultServer = "https://korap.ids-mannheim.de/"
+	defaultSDK    = "https://korap.ids-mannheim.de/js/korap-plugin-latest.js"
+)
+
 // MappingRule represents a single mapping rule in the configuration
 type MappingRule string
 
@@ -22,13 +27,15 @@
 	Mappings []MappingRule `yaml:"mappings"`
 }
 
-// MappingLists represents the root configuration containing multiple mapping lists
-type MappingLists struct {
-	Lists []MappingList
+// MappingConfig represents the root configuration containing multiple mapping lists
+type MappingConfig struct {
+	SDK    string        `yaml:"sdk,omitempty"`
+	Server string        `yaml:"server,omitempty"`
+	Lists  []MappingList `yaml:"lists,omitempty"`
 }
 
 // LoadConfig loads a YAML configuration file and returns a Config object
-func LoadConfig(filename string) (*MappingLists, error) {
+func LoadConfig(filename string) (*MappingConfig, error) {
 	data, err := os.ReadFile(filename)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read config file: %w", err)
@@ -39,37 +46,71 @@
 		return nil, fmt.Errorf("EOF: config file is empty")
 	}
 
+	// Try to unmarshal as new format first (object with optional sdk/server and lists)
+	var config MappingConfig
+	if err := yaml.Unmarshal(data, &config); err == nil && len(config.Lists) > 0 {
+		// Successfully parsed as new format with lists field
+		if err := validateMappingLists(config.Lists); err != nil {
+			return nil, err
+		}
+		// Apply defaults if not specified
+		applyDefaults(&config)
+		return &config, nil
+	}
+
+	// Fall back to old format (direct list)
 	var lists []MappingList
 	if err := yaml.Unmarshal(data, &lists); err != nil {
 		return nil, fmt.Errorf("failed to parse YAML: %w", err)
 	}
 
+	if err := validateMappingLists(lists); err != nil {
+		return nil, err
+	}
+
+	config = MappingConfig{Lists: lists}
+	// Apply defaults if not specified
+	applyDefaults(&config)
+	return &config, nil
+}
+
+// applyDefaults sets default values for SDK and Server if they are empty
+func applyDefaults(config *MappingConfig) {
+	if config.SDK == "" {
+		config.SDK = defaultSDK
+	}
+	if config.Server == "" {
+		config.Server = defaultServer
+	}
+}
+
+// validateMappingLists validates a slice of mapping lists
+func validateMappingLists(lists []MappingList) error {
 	// Validate the configuration
 	seenIDs := make(map[string]bool)
 	for i, list := range lists {
 		if list.ID == "" {
-			return nil, fmt.Errorf("mapping list at index %d is missing an ID", i)
+			return fmt.Errorf("mapping list at index %d is missing an ID", i)
 		}
 
 		// Check for duplicate IDs
 		if seenIDs[list.ID] {
-			return nil, fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
+			return fmt.Errorf("duplicate mapping list ID found: %s", list.ID)
 		}
 		seenIDs[list.ID] = true
 
 		if len(list.Mappings) == 0 {
-			return nil, fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
+			return fmt.Errorf("mapping list '%s' has no mapping rules", list.ID)
 		}
 
 		// Validate each mapping rule
 		for j, rule := range list.Mappings {
 			if rule == "" {
-				return nil, fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
+				return fmt.Errorf("mapping list '%s' rule at index %d is empty", list.ID, j)
 			}
 		}
 	}
-
-	return &MappingLists{Lists: lists}, nil
+	return nil
 }
 
 // ParseMappings parses all mapping rules in a list and returns a slice of parsed rules
diff --git a/config/config_test.go b/config/config_test.go
index 9dc64ca..6519f82 100644
--- a/config/config_test.go
+++ b/config/config_test.go
@@ -574,3 +574,120 @@
 		assert.Equal(t, expectedValue, pronTypeTerm.Key)
 	}
 }
+
+func TestConfigWithSdkAndServer(t *testing.T) {
+	tests := []struct {
+		name           string
+		content        string
+		expectedSDK    string
+		expectedServer string
+		wantErr        bool
+	}{
+		{
+			name: "Configuration with SDK and Server values",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper
+  foundryA: opennlp
+  layerA: p
+  foundryB: upos
+  layerB: p
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with only SDK value",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+lists:
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://korap.ids-mannheim.de/", // default applied
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with only Server value",
+			content: `
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // default applied
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+		{
+			name: "Configuration without SDK and Server (old format with defaults applied)",
+			content: `
+- id: test-mapper
+  mappings:
+    - "[A] <> [B]"
+`,
+			expectedSDK:    "https://korap.ids-mannheim.de/js/korap-plugin-latest.js", // default applied
+			expectedServer: "https://korap.ids-mannheim.de/",                          // default applied
+			wantErr:        false,
+		},
+		{
+			name: "Configuration with lists field explicitly",
+			content: `
+sdk: "https://custom.example.com/sdk.js"
+server: "https://custom.example.com/"
+lists:
+- id: test-mapper-1
+  mappings:
+    - "[A] <> [B]"
+- id: test-mapper-2
+  mappings:
+    - "[C] <> [D]"
+`,
+			expectedSDK:    "https://custom.example.com/sdk.js",
+			expectedServer: "https://custom.example.com/",
+			wantErr:        false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			tmpfile, err := os.CreateTemp("", "config-*.yaml")
+			require.NoError(t, err)
+			defer os.Remove(tmpfile.Name())
+
+			_, err = tmpfile.WriteString(tt.content)
+			require.NoError(t, err)
+			err = tmpfile.Close()
+			require.NoError(t, err)
+
+			config, err := LoadConfig(tmpfile.Name())
+			if tt.wantErr {
+				require.Error(t, err)
+				return
+			}
+
+			require.NoError(t, err)
+			require.NotNil(t, config)
+
+			// Check SDK and Server values
+			assert.Equal(t, tt.expectedSDK, config.SDK)
+			assert.Equal(t, tt.expectedServer, config.Server)
+
+			// Ensure lists are still loaded correctly
+			require.Greater(t, len(config.Lists), 0)
+
+			// Verify first mapping list
+			firstList := config.Lists[0]
+			assert.NotEmpty(t, firstList.ID)
+			assert.Greater(t, len(firstList.Mappings), 0)
+		})
+	}
+}