blob: 5dab1b0b281f622d16db4da5dde65f286b73fd08 [file] [log] [blame]
Akron49ceeb42025-05-23 17:46:01 +02001package main
2
3import (
Akron49ceeb42025-05-23 17:46:01 +02004 "fmt"
Akron80067202025-06-06 14:16:25 +02005 "net/url"
Akron49ceeb42025-05-23 17:46:01 +02006 "os"
7 "os/signal"
Akron80067202025-06-06 14:16:25 +02008 "path"
Akron14678dc2025-06-05 13:01:38 +02009 "path/filepath"
Akron49ceeb42025-05-23 17:46:01 +020010 "strings"
11 "syscall"
12
Akrona00d4752025-05-26 17:34:36 +020013 "github.com/KorAP/KoralPipe-TermMapper/config"
Akronfa55bb22025-05-26 15:10:42 +020014 "github.com/KorAP/KoralPipe-TermMapper/mapper"
Akron1fc750e2025-05-26 16:54:18 +020015 "github.com/alecthomas/kong"
Akron49ceeb42025-05-23 17:46:01 +020016 "github.com/gofiber/fiber/v2"
17 "github.com/rs/zerolog"
18 "github.com/rs/zerolog/log"
19)
20
Akron74e1c072025-05-26 14:38:25 +020021const (
22 maxInputLength = 1024 * 1024 // 1MB
23 maxParamLength = 1024 // 1KB
24)
25
Akrona00d4752025-05-26 17:34:36 +020026type appConfig struct {
Akrona8a66ce2025-06-05 10:50:17 +020027 Port *int `kong:"short='p',help='Port to listen on'"`
Akrone1cff7c2025-06-04 18:43:32 +020028 Config string `kong:"short='c',help='YAML configuration file containing mapping directives and global settings'"`
Akron14678dc2025-06-05 13:01:38 +020029 Mappings []string `kong:"short='m',help='Individual YAML mapping files to load (supports glob patterns like dir/*.yaml)'"`
Akrona8a66ce2025-06-05 10:50:17 +020030 LogLevel *string `kong:"short='l',help='Log level (debug, info, warn, error)'"`
Akron49ceeb42025-05-23 17:46:01 +020031}
32
Akrondab27112025-06-05 13:52:43 +020033type TemplateMapping struct {
34 ID string
35 Description string
36}
37
Akron40aaa632025-06-03 17:57:52 +020038// TemplateData holds data for the Kalamar plugin template
39type TemplateData struct {
40 Title string
41 Version string
Akronfc77b5e2025-06-04 11:44:43 +020042 Hash string
43 Date string
Akron40aaa632025-06-03 17:57:52 +020044 Description string
Akron06d21f02025-06-04 14:36:07 +020045 Server string
46 SDK string
Akron2ac2ec02025-06-05 15:26:42 +020047 ServiceURL string
Akronc376dcc2025-06-04 17:00:18 +020048 MapID string
Akrondab27112025-06-05 13:52:43 +020049 Mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +020050}
51
Akroncb51f812025-06-30 15:24:20 +020052type QueryParams struct {
53 Dir string
54 FoundryA string
55 FoundryB string
56 LayerA string
57 LayerB string
58}
59
Akrona00d4752025-05-26 17:34:36 +020060func parseConfig() *appConfig {
61 cfg := &appConfig{}
Akronfc77b5e2025-06-04 11:44:43 +020062
63 desc := config.Description
64 desc += " [" + config.Version + "]"
65
Akron1fc750e2025-05-26 16:54:18 +020066 ctx := kong.Parse(cfg,
Akronfc77b5e2025-06-04 11:44:43 +020067 kong.Description(desc),
Akron1fc750e2025-05-26 16:54:18 +020068 kong.UsageOnError(),
69 )
70 if ctx.Error != nil {
71 fmt.Fprintln(os.Stderr, ctx.Error)
Akron49ceeb42025-05-23 17:46:01 +020072 os.Exit(1)
73 }
Akron49ceeb42025-05-23 17:46:01 +020074 return cfg
75}
76
77func setupLogger(level string) {
78 // Parse log level
79 lvl, err := zerolog.ParseLevel(strings.ToLower(level))
80 if err != nil {
81 log.Error().Err(err).Str("level", level).Msg("Invalid log level, defaulting to info")
82 lvl = zerolog.InfoLevel
83 }
84
85 // Configure zerolog
86 zerolog.SetGlobalLevel(lvl)
87 log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
88}
89
90func main() {
91 // Parse command line flags
Akron1fc750e2025-05-26 16:54:18 +020092 cfg := parseConfig()
Akron49ceeb42025-05-23 17:46:01 +020093
Akrone1cff7c2025-06-04 18:43:32 +020094 // Validate command line arguments
95 if cfg.Config == "" && len(cfg.Mappings) == 0 {
96 log.Fatal().Msg("At least one configuration source must be provided: use -c for main config file or -m for mapping files")
97 }
98
Akron14678dc2025-06-05 13:01:38 +020099 // Expand glob patterns in mapping files
100 expandedMappings, err := expandGlobs(cfg.Mappings)
101 if err != nil {
102 log.Fatal().Err(err).Msg("Failed to expand glob patterns in mapping files")
103 }
104
Akrone1cff7c2025-06-04 18:43:32 +0200105 // Load configuration from multiple sources
Akron14678dc2025-06-05 13:01:38 +0200106 yamlConfig, err := config.LoadFromSources(cfg.Config, expandedMappings)
Akrona00d4752025-05-26 17:34:36 +0200107 if err != nil {
108 log.Fatal().Err(err).Msg("Failed to load configuration")
109 }
110
Akrona8a66ce2025-06-05 10:50:17 +0200111 finalPort := yamlConfig.Port
112 finalLogLevel := yamlConfig.LogLevel
113
114 // Use command line values if provided (they override config file)
115 if cfg.Port != nil {
116 finalPort = *cfg.Port
117 }
118 if cfg.LogLevel != nil {
119 finalLogLevel = *cfg.LogLevel
120 }
121
122 // Set up logging with the final log level
123 setupLogger(finalLogLevel)
124
Akron49ceeb42025-05-23 17:46:01 +0200125 // Create a new mapper instance
Akrona00d4752025-05-26 17:34:36 +0200126 m, err := mapper.NewMapper(yamlConfig.Lists)
Akron49ceeb42025-05-23 17:46:01 +0200127 if err != nil {
128 log.Fatal().Err(err).Msg("Failed to create mapper")
129 }
130
131 // Create fiber app
132 app := fiber.New(fiber.Config{
133 DisableStartupMessage: true,
Akron74e1c072025-05-26 14:38:25 +0200134 BodyLimit: maxInputLength,
Akronafbe86d2025-07-01 08:45:13 +0200135 ReadBufferSize: 64 * 1024, // 64KB - increase header size limit
136 WriteBufferSize: 64 * 1024, // 64KB - increase response buffer size
Akron49ceeb42025-05-23 17:46:01 +0200137 })
138
139 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200140 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200141
142 // Start server
143 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200144 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200145
146 for _, list := range yamlConfig.Lists {
147 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
148 }
149
Akrona8a66ce2025-06-05 10:50:17 +0200150 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200151 log.Fatal().Err(err).Msg("Server error")
152 }
153 }()
154
155 // Wait for interrupt signal
156 sigChan := make(chan os.Signal, 1)
157 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
158 <-sigChan
159
160 // Graceful shutdown
161 log.Info().Msg("Shutting down server")
162 if err := app.Shutdown(); err != nil {
163 log.Error().Err(err).Msg("Error during shutdown")
164 }
165}
166
Akron06d21f02025-06-04 14:36:07 +0200167func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200168 // Health check endpoint
169 app.Get("/health", func(c *fiber.Ctx) error {
170 return c.SendString("OK")
171 })
172
173 // Transformation endpoint
174 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200175
Akron4de47a92025-06-27 11:58:11 +0200176 // Response transformation endpoint
177 app.Post("/:map/response", handleResponseTransform(m))
178
Akron40aaa632025-06-03 17:57:52 +0200179 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200180 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200181 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200182}
183
184func handleTransform(m *mapper.Mapper) fiber.Handler {
185 return func(c *fiber.Ctx) error {
186 // Get parameters
187 mapID := c.Params("map")
188 dir := c.Query("dir", "atob")
189 foundryA := c.Query("foundryA", "")
190 foundryB := c.Query("foundryB", "")
191 layerA := c.Query("layerA", "")
192 layerB := c.Query("layerB", "")
193
Akron74e1c072025-05-26 14:38:25 +0200194 // Validate input parameters
195 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
196 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
197 "error": err.Error(),
198 })
199 }
200
Akron49ceeb42025-05-23 17:46:01 +0200201 // Validate direction
202 if dir != "atob" && dir != "btoa" {
203 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
204 "error": "invalid direction, must be 'atob' or 'btoa'",
205 })
206 }
207
208 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200209 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200210 if err := c.BodyParser(&jsonData); err != nil {
211 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
212 "error": "invalid JSON in request body",
213 })
214 }
215
Akrona1a183f2025-05-26 17:47:33 +0200216 // Parse direction
217 direction, err := mapper.ParseDirection(dir)
218 if err != nil {
219 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
220 "error": err.Error(),
221 })
222 }
223
Akron49ceeb42025-05-23 17:46:01 +0200224 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200225 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200226 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200227 FoundryA: foundryA,
228 FoundryB: foundryB,
229 LayerA: layerA,
230 LayerB: layerB,
231 }, jsonData)
232
233 if err != nil {
234 log.Error().Err(err).
235 Str("mapID", mapID).
236 Str("direction", dir).
237 Msg("Failed to apply mappings")
238
239 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
240 "error": err.Error(),
241 })
242 }
243
244 return c.JSON(result)
245 }
246}
Akron74e1c072025-05-26 14:38:25 +0200247
Akron4de47a92025-06-27 11:58:11 +0200248func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
249 return func(c *fiber.Ctx) error {
250 // Get parameters
251 mapID := c.Params("map")
252 dir := c.Query("dir", "atob")
253 foundryA := c.Query("foundryA", "")
254 foundryB := c.Query("foundryB", "")
255 layerA := c.Query("layerA", "")
256 layerB := c.Query("layerB", "")
257
258 // Validate input parameters
259 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
260 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
261 "error": err.Error(),
262 })
263 }
264
265 // Validate direction
266 if dir != "atob" && dir != "btoa" {
267 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
268 "error": "invalid direction, must be 'atob' or 'btoa'",
269 })
270 }
271
272 // Parse request body
273 var jsonData any
274 if err := c.BodyParser(&jsonData); err != nil {
275 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
276 "error": "invalid JSON in request body",
277 })
278 }
279
280 // Parse direction
281 direction, err := mapper.ParseDirection(dir)
282 if err != nil {
283 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
284 "error": err.Error(),
285 })
286 }
287
288 // Apply response mappings
289 result, err := m.ApplyResponseMappings(mapID, mapper.MappingOptions{
290 Direction: direction,
291 FoundryA: foundryA,
292 FoundryB: foundryB,
293 LayerA: layerA,
294 LayerB: layerB,
295 }, jsonData)
296
297 if err != nil {
298 log.Error().Err(err).
299 Str("mapID", mapID).
300 Str("direction", dir).
301 Msg("Failed to apply response mappings")
302
303 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
304 "error": err.Error(),
305 })
306 }
307
308 return c.JSON(result)
309 }
310}
311
Akron74e1c072025-05-26 14:38:25 +0200312// validateInput checks if the input parameters are valid
313func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200314 // Define parameter checks
315 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200316 name string
317 value string
318 }{
319 {"mapID", mapID},
320 {"dir", dir},
321 {"foundryA", foundryA},
322 {"foundryB", foundryB},
323 {"layerA", layerA},
324 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200325 }
326
327 for _, param := range params {
328 // Check input lengths
329 if len(param.value) > maxParamLength {
330 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
331 }
332 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200333 if strings.ContainsAny(param.value, "<>{}[]\\") {
334 return fmt.Errorf("%s contains invalid characters", param.name)
335 }
336 }
337
Akron69d43bf2025-05-26 17:09:00 +0200338 if len(body) > maxInputLength {
339 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
340 }
341
Akron74e1c072025-05-26 14:38:25 +0200342 return nil
343}
Akron40aaa632025-06-03 17:57:52 +0200344
Akron06d21f02025-06-04 14:36:07 +0200345func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200346 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200347 mapID := c.Params("map")
348
Akroncb51f812025-06-30 15:24:20 +0200349 // Get query parameters
350 dir := c.Query("dir", "atob")
351 foundryA := c.Query("foundryA", "")
352 foundryB := c.Query("foundryB", "")
353 layerA := c.Query("layerA", "")
354 layerB := c.Query("layerB", "")
355
356 // Validate input parameters (reuse existing validation)
357 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
358 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
359 "error": err.Error(),
360 })
361 }
362
363 // Validate direction
364 if dir != "atob" && dir != "btoa" {
365 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
366 "error": "invalid direction, must be 'atob' or 'btoa'",
367 })
368 }
369
Akrondab27112025-06-05 13:52:43 +0200370 // Get list of available mappings
371 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200372 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200373 mappings = append(mappings, TemplateMapping{
374 ID: list.ID,
375 Description: list.Description,
376 })
Akron40aaa632025-06-03 17:57:52 +0200377 }
378
Akron06d21f02025-06-04 14:36:07 +0200379 // Use values from config (defaults are already applied during parsing)
380 server := yamlConfig.Server
381 sdk := yamlConfig.SDK
382
Akron40aaa632025-06-03 17:57:52 +0200383 // Prepare template data
384 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200385 Title: config.Title,
386 Version: config.Version,
387 Hash: config.Buildhash,
388 Date: config.Buildtime,
389 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200390 Server: server,
391 SDK: sdk,
Akron2ac2ec02025-06-05 15:26:42 +0200392 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200393 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200394 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200395 }
396
Akroncb51f812025-06-30 15:24:20 +0200397 // Add query parameters to template data
398 queryParams := QueryParams{
399 Dir: dir,
400 FoundryA: foundryA,
401 FoundryB: foundryB,
402 LayerA: layerA,
403 LayerB: layerB,
404 }
405
Akron40aaa632025-06-03 17:57:52 +0200406 // Generate HTML
Akroncb51f812025-06-30 15:24:20 +0200407 html := generateKalamarPluginHTML(data, queryParams)
Akron40aaa632025-06-03 17:57:52 +0200408
409 c.Set("Content-Type", "text/html")
410 return c.SendString(html)
411 }
412}
413
414// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
415// This function can be easily modified to change the appearance and content
Akroncb51f812025-06-30 15:24:20 +0200416func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
Akron40aaa632025-06-03 17:57:52 +0200417 html := `<!DOCTYPE html>
418<html lang="en">
419<head>
420 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200421 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200422 <script src="` + data.SDK + `"
423 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200424</head>
425<body>
426 <div class="container">
427 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200428 <p>` + data.Description + `</p>`
429
430 if data.MapID != "" {
431 html += `<p>Map ID: ` + data.MapID + `</p>`
432 }
433
434 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200435 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
436 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
437 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200438
Akronc471c0a2025-06-04 11:56:22 +0200439 <h2>Available API Endpoints</h2>
440 <dl>
Akron40aaa632025-06-03 17:57:52 +0200441
Akronc376dcc2025-06-04 17:00:18 +0200442 <dt><tt><strong>GET</strong> /:map</tt></dt>
443 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200444
445 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200446 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
Akron4de47a92025-06-27 11:58:11 +0200447
448 <dt><tt><strong>POST</strong> /:map/response</tt></dt>
449 <dd><small>Transform JSON response objects using term mapping rules</small></dd>
Akronc376dcc2025-06-04 17:00:18 +0200450
Akronc471c0a2025-06-04 11:56:22 +0200451 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200452
453 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200454 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200455
Akrondab27112025-06-05 13:52:43 +0200456 for _, m := range data.Mappings {
457 html += `<dt><tt>` + m.ID + `</tt></dt>`
458 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200459 }
460
461 html += `
Akroncb51f812025-06-30 15:24:20 +0200462 </dl></div>`
Akron06d21f02025-06-04 14:36:07 +0200463
Akronc376dcc2025-06-04 17:00:18 +0200464 if data.MapID != "" {
Akron80067202025-06-06 14:16:25 +0200465
Akrond0c88602025-06-27 16:57:21 +0200466 queryServiceURL, err := url.Parse(data.ServiceURL)
Akron80067202025-06-06 14:16:25 +0200467 if err != nil {
468 log.Warn().Err(err).Msg("Failed to join URL path")
469 }
470
471 // Use path.Join to normalize the path part
Akrond0c88602025-06-27 16:57:21 +0200472 queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
Akroncb51f812025-06-30 15:24:20 +0200473
474 // Build query parameters for query URL
475 queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
476 queryServiceURL.RawQuery = queryParamString
Akrond0c88602025-06-27 16:57:21 +0200477
478 responseServiceURL, err := url.Parse(data.ServiceURL)
479 if err != nil {
480 log.Warn().Err(err).Msg("Failed to join URL path")
481 }
482
483 // Use path.Join to normalize the path part
484 responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
Akron80067202025-06-06 14:16:25 +0200485
Akroncb51f812025-06-30 15:24:20 +0200486 reversedDir := "btoa"
487 if queryParams.Dir == "btoa" {
488 reversedDir = "atob"
489 }
490
491 // Build query parameters for response URL (with reversed direction)
492 responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
493 responseServiceURL.RawQuery = responseParamString
494
495 html += `<script>
Akron06d21f02025-06-04 14:36:07 +0200496 <!-- activates/deactivates Mapper. -->
497
Akrond0c88602025-06-27 16:57:21 +0200498 let qdata = {
Akron06d21f02025-06-04 14:36:07 +0200499 'action' : 'pipe',
Akrond0c88602025-06-27 16:57:21 +0200500 'service' : '` + queryServiceURL.String() + `'
Akron06d21f02025-06-04 14:36:07 +0200501 };
502
Akrond0c88602025-06-27 16:57:21 +0200503 let rdata = {
504 'action' : 'pipe',
505 'service' : '` + responseServiceURL.String() + `'
506 };
507
508
Akron06d21f02025-06-04 14:36:07 +0200509 function pluginit (p) {
510 p.onMessage = function(msg) {
511 if (msg.key == 'termmapper') {
512 if (msg.value) {
Akrond0c88602025-06-27 16:57:21 +0200513 qdata['job'] = 'add';
Akron06d21f02025-06-04 14:36:07 +0200514 }
515 else {
Akrond0c88602025-06-27 16:57:21 +0200516 qdata['job'] = 'del';
Akron06d21f02025-06-04 14:36:07 +0200517 };
Akrond0c88602025-06-27 16:57:21 +0200518 KorAPlugin.sendMsg(qdata);
519 if (msg.value) {
520 rdata['job'] = 'add-after';
521 }
522 else {
523 rdata['job'] = 'del-after';
524 };
525 KorAPlugin.sendMsg(rdata);
Akron06d21f02025-06-04 14:36:07 +0200526 };
527 };
528 };
Akronc376dcc2025-06-04 17:00:18 +0200529 </script>`
530 }
531
532 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200533</html>`
534
535 return html
536}
Akron14678dc2025-06-05 13:01:38 +0200537
Akroncb51f812025-06-30 15:24:20 +0200538// buildQueryParams builds a query string from the provided parameters
539func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
540 params := url.Values{}
541 if dir != "" {
542 params.Add("dir", dir)
543 }
544 if foundryA != "" {
545 params.Add("foundryA", foundryA)
546 }
547 if foundryB != "" {
548 params.Add("foundryB", foundryB)
549 }
550 if layerA != "" {
551 params.Add("layerA", layerA)
552 }
553 if layerB != "" {
554 params.Add("layerB", layerB)
555 }
556 return params.Encode()
557}
558
Akron14678dc2025-06-05 13:01:38 +0200559// expandGlobs expands glob patterns in the slice of file paths
560// Returns the expanded list of files or an error if glob expansion fails
561func expandGlobs(patterns []string) ([]string, error) {
562 var expanded []string
563
564 for _, pattern := range patterns {
565 // Use filepath.Glob which works cross-platform
566 matches, err := filepath.Glob(pattern)
567 if err != nil {
568 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
569 }
570
571 // If no matches found, treat as literal filename (consistent with shell behavior)
572 if len(matches) == 0 {
573 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
574 expanded = append(expanded, pattern)
575 } else {
576 expanded = append(expanded, matches...)
577 }
578 }
579
580 return expanded, nil
581}