blob: 90f62423dda18341d06e0d6112fa1642ab99b080 [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,
Akron49ceeb42025-05-23 17:46:01 +0200135 })
136
137 // Set up routes
Akron40aaa632025-06-03 17:57:52 +0200138 setupRoutes(app, m, yamlConfig)
Akron49ceeb42025-05-23 17:46:01 +0200139
140 // Start server
141 go func() {
Akrona8a66ce2025-06-05 10:50:17 +0200142 log.Info().Int("port", finalPort).Msg("Starting server")
Akronae3ffde2025-06-05 14:04:06 +0200143
144 for _, list := range yamlConfig.Lists {
145 log.Info().Str("id", list.ID).Str("desc", list.Description).Msg("Loaded mapping")
146 }
147
Akrona8a66ce2025-06-05 10:50:17 +0200148 if err := app.Listen(fmt.Sprintf(":%d", finalPort)); err != nil {
Akron49ceeb42025-05-23 17:46:01 +0200149 log.Fatal().Err(err).Msg("Server error")
150 }
151 }()
152
153 // Wait for interrupt signal
154 sigChan := make(chan os.Signal, 1)
155 signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
156 <-sigChan
157
158 // Graceful shutdown
159 log.Info().Msg("Shutting down server")
160 if err := app.Shutdown(); err != nil {
161 log.Error().Err(err).Msg("Error during shutdown")
162 }
163}
164
Akron06d21f02025-06-04 14:36:07 +0200165func setupRoutes(app *fiber.App, m *mapper.Mapper, yamlConfig *config.MappingConfig) {
Akron49ceeb42025-05-23 17:46:01 +0200166 // Health check endpoint
167 app.Get("/health", func(c *fiber.Ctx) error {
168 return c.SendString("OK")
169 })
170
171 // Transformation endpoint
172 app.Post("/:map/query", handleTransform(m))
Akron40aaa632025-06-03 17:57:52 +0200173
Akron4de47a92025-06-27 11:58:11 +0200174 // Response transformation endpoint
175 app.Post("/:map/response", handleResponseTransform(m))
176
Akron40aaa632025-06-03 17:57:52 +0200177 // Kalamar plugin endpoint
Akronc471c0a2025-06-04 11:56:22 +0200178 app.Get("/", handleKalamarPlugin(yamlConfig))
Akronc376dcc2025-06-04 17:00:18 +0200179 app.Get("/:map", handleKalamarPlugin(yamlConfig))
Akron49ceeb42025-05-23 17:46:01 +0200180}
181
182func handleTransform(m *mapper.Mapper) fiber.Handler {
183 return func(c *fiber.Ctx) error {
184 // Get parameters
185 mapID := c.Params("map")
186 dir := c.Query("dir", "atob")
187 foundryA := c.Query("foundryA", "")
188 foundryB := c.Query("foundryB", "")
189 layerA := c.Query("layerA", "")
190 layerB := c.Query("layerB", "")
191
Akron74e1c072025-05-26 14:38:25 +0200192 // Validate input parameters
193 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
194 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
195 "error": err.Error(),
196 })
197 }
198
Akron49ceeb42025-05-23 17:46:01 +0200199 // Validate direction
200 if dir != "atob" && dir != "btoa" {
201 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
202 "error": "invalid direction, must be 'atob' or 'btoa'",
203 })
204 }
205
206 // Parse request body
Akron2cbdab52025-05-23 17:57:10 +0200207 var jsonData any
Akron49ceeb42025-05-23 17:46:01 +0200208 if err := c.BodyParser(&jsonData); err != nil {
209 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
210 "error": "invalid JSON in request body",
211 })
212 }
213
Akrona1a183f2025-05-26 17:47:33 +0200214 // Parse direction
215 direction, err := mapper.ParseDirection(dir)
216 if err != nil {
217 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
218 "error": err.Error(),
219 })
220 }
221
Akron49ceeb42025-05-23 17:46:01 +0200222 // Apply mappings
Akron7b4984e2025-05-26 19:12:20 +0200223 result, err := m.ApplyQueryMappings(mapID, mapper.MappingOptions{
Akrona1a183f2025-05-26 17:47:33 +0200224 Direction: direction,
Akron49ceeb42025-05-23 17:46:01 +0200225 FoundryA: foundryA,
226 FoundryB: foundryB,
227 LayerA: layerA,
228 LayerB: layerB,
229 }, jsonData)
230
231 if err != nil {
232 log.Error().Err(err).
233 Str("mapID", mapID).
234 Str("direction", dir).
235 Msg("Failed to apply mappings")
236
237 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
238 "error": err.Error(),
239 })
240 }
241
242 return c.JSON(result)
243 }
244}
Akron74e1c072025-05-26 14:38:25 +0200245
Akron4de47a92025-06-27 11:58:11 +0200246func handleResponseTransform(m *mapper.Mapper) fiber.Handler {
247 return func(c *fiber.Ctx) error {
248 // Get parameters
249 mapID := c.Params("map")
250 dir := c.Query("dir", "atob")
251 foundryA := c.Query("foundryA", "")
252 foundryB := c.Query("foundryB", "")
253 layerA := c.Query("layerA", "")
254 layerB := c.Query("layerB", "")
255
256 // Validate input parameters
257 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, c.Body()); err != nil {
258 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
259 "error": err.Error(),
260 })
261 }
262
263 // Validate direction
264 if dir != "atob" && dir != "btoa" {
265 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
266 "error": "invalid direction, must be 'atob' or 'btoa'",
267 })
268 }
269
270 // Parse request body
271 var jsonData any
272 if err := c.BodyParser(&jsonData); err != nil {
273 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
274 "error": "invalid JSON in request body",
275 })
276 }
277
278 // Parse direction
279 direction, err := mapper.ParseDirection(dir)
280 if err != nil {
281 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
282 "error": err.Error(),
283 })
284 }
285
286 // Apply response mappings
287 result, err := m.ApplyResponseMappings(mapID, mapper.MappingOptions{
288 Direction: direction,
289 FoundryA: foundryA,
290 FoundryB: foundryB,
291 LayerA: layerA,
292 LayerB: layerB,
293 }, jsonData)
294
295 if err != nil {
296 log.Error().Err(err).
297 Str("mapID", mapID).
298 Str("direction", dir).
299 Msg("Failed to apply response mappings")
300
301 return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
302 "error": err.Error(),
303 })
304 }
305
306 return c.JSON(result)
307 }
308}
309
Akron74e1c072025-05-26 14:38:25 +0200310// validateInput checks if the input parameters are valid
311func validateInput(mapID, dir, foundryA, foundryB, layerA, layerB string, body []byte) error {
Akron69d43bf2025-05-26 17:09:00 +0200312 // Define parameter checks
313 params := []struct {
Akron74e1c072025-05-26 14:38:25 +0200314 name string
315 value string
316 }{
317 {"mapID", mapID},
318 {"dir", dir},
319 {"foundryA", foundryA},
320 {"foundryB", foundryB},
321 {"layerA", layerA},
322 {"layerB", layerB},
Akron69d43bf2025-05-26 17:09:00 +0200323 }
324
325 for _, param := range params {
326 // Check input lengths
327 if len(param.value) > maxParamLength {
328 return fmt.Errorf("%s too long (max %d bytes)", param.name, maxParamLength)
329 }
330 // Check for invalid characters in parameters
Akron74e1c072025-05-26 14:38:25 +0200331 if strings.ContainsAny(param.value, "<>{}[]\\") {
332 return fmt.Errorf("%s contains invalid characters", param.name)
333 }
334 }
335
Akron69d43bf2025-05-26 17:09:00 +0200336 if len(body) > maxInputLength {
337 return fmt.Errorf("request body too large (max %d bytes)", maxInputLength)
338 }
339
Akron74e1c072025-05-26 14:38:25 +0200340 return nil
341}
Akron40aaa632025-06-03 17:57:52 +0200342
Akron06d21f02025-06-04 14:36:07 +0200343func handleKalamarPlugin(yamlConfig *config.MappingConfig) fiber.Handler {
Akron40aaa632025-06-03 17:57:52 +0200344 return func(c *fiber.Ctx) error {
Akronc376dcc2025-06-04 17:00:18 +0200345 mapID := c.Params("map")
346
Akroncb51f812025-06-30 15:24:20 +0200347 // Get query parameters
348 dir := c.Query("dir", "atob")
349 foundryA := c.Query("foundryA", "")
350 foundryB := c.Query("foundryB", "")
351 layerA := c.Query("layerA", "")
352 layerB := c.Query("layerB", "")
353
354 // Validate input parameters (reuse existing validation)
355 if err := validateInput(mapID, dir, foundryA, foundryB, layerA, layerB, []byte{}); err != nil {
356 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
357 "error": err.Error(),
358 })
359 }
360
361 // Validate direction
362 if dir != "atob" && dir != "btoa" {
363 return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
364 "error": "invalid direction, must be 'atob' or 'btoa'",
365 })
366 }
367
Akrondab27112025-06-05 13:52:43 +0200368 // Get list of available mappings
369 var mappings []TemplateMapping
Akron40aaa632025-06-03 17:57:52 +0200370 for _, list := range yamlConfig.Lists {
Akrondab27112025-06-05 13:52:43 +0200371 mappings = append(mappings, TemplateMapping{
372 ID: list.ID,
373 Description: list.Description,
374 })
Akron40aaa632025-06-03 17:57:52 +0200375 }
376
Akron06d21f02025-06-04 14:36:07 +0200377 // Use values from config (defaults are already applied during parsing)
378 server := yamlConfig.Server
379 sdk := yamlConfig.SDK
380
Akron40aaa632025-06-03 17:57:52 +0200381 // Prepare template data
382 data := TemplateData{
Akronfc77b5e2025-06-04 11:44:43 +0200383 Title: config.Title,
384 Version: config.Version,
385 Hash: config.Buildhash,
386 Date: config.Buildtime,
387 Description: config.Description,
Akron06d21f02025-06-04 14:36:07 +0200388 Server: server,
389 SDK: sdk,
Akron2ac2ec02025-06-05 15:26:42 +0200390 ServiceURL: yamlConfig.ServiceURL,
Akronc376dcc2025-06-04 17:00:18 +0200391 MapID: mapID,
Akrondab27112025-06-05 13:52:43 +0200392 Mappings: mappings,
Akron40aaa632025-06-03 17:57:52 +0200393 }
394
Akroncb51f812025-06-30 15:24:20 +0200395 // Add query parameters to template data
396 queryParams := QueryParams{
397 Dir: dir,
398 FoundryA: foundryA,
399 FoundryB: foundryB,
400 LayerA: layerA,
401 LayerB: layerB,
402 }
403
Akron40aaa632025-06-03 17:57:52 +0200404 // Generate HTML
Akroncb51f812025-06-30 15:24:20 +0200405 html := generateKalamarPluginHTML(data, queryParams)
Akron40aaa632025-06-03 17:57:52 +0200406
407 c.Set("Content-Type", "text/html")
408 return c.SendString(html)
409 }
410}
411
412// generateKalamarPluginHTML creates the HTML template for the Kalamar plugin page
413// This function can be easily modified to change the appearance and content
Akroncb51f812025-06-30 15:24:20 +0200414func generateKalamarPluginHTML(data TemplateData, queryParams QueryParams) string {
Akron40aaa632025-06-03 17:57:52 +0200415 html := `<!DOCTYPE html>
416<html lang="en">
417<head>
418 <meta charset="UTF-8">
Akron40aaa632025-06-03 17:57:52 +0200419 <title>` + data.Title + `</title>
Akron06d21f02025-06-04 14:36:07 +0200420 <script src="` + data.SDK + `"
421 data-server="` + data.Server + `"></script>
Akron40aaa632025-06-03 17:57:52 +0200422</head>
423<body>
424 <div class="container">
425 <h1>` + data.Title + `</h1>
Akronc376dcc2025-06-04 17:00:18 +0200426 <p>` + data.Description + `</p>`
427
428 if data.MapID != "" {
429 html += `<p>Map ID: ` + data.MapID + `</p>`
430 }
431
432 html += ` <h2>Plugin Information</h2>
Akronc471c0a2025-06-04 11:56:22 +0200433 <p><strong>Version:</strong> <tt>` + data.Version + `</tt></p>
434 <p><strong>Build Date:</strong> <tt>` + data.Date + `</tt></p>
435 <p><strong>Build Hash:</strong> <tt>` + data.Hash + `</tt></p>
Akron40aaa632025-06-03 17:57:52 +0200436
Akronc471c0a2025-06-04 11:56:22 +0200437 <h2>Available API Endpoints</h2>
438 <dl>
Akron40aaa632025-06-03 17:57:52 +0200439
Akronc376dcc2025-06-04 17:00:18 +0200440 <dt><tt><strong>GET</strong> /:map</tt></dt>
441 <dd><small>Kalamar integration</small></dd>
Akrone1cff7c2025-06-04 18:43:32 +0200442
443 <dt><tt><strong>POST</strong> /:map/query</tt></dt>
Akronc376dcc2025-06-04 17:00:18 +0200444 <dd><small>Transform JSON query objects using term mapping rules</small></dd>
Akron4de47a92025-06-27 11:58:11 +0200445
446 <dt><tt><strong>POST</strong> /:map/response</tt></dt>
447 <dd><small>Transform JSON response objects using term mapping rules</small></dd>
Akronc376dcc2025-06-04 17:00:18 +0200448
Akronc471c0a2025-06-04 11:56:22 +0200449 </dl>
Akronc376dcc2025-06-04 17:00:18 +0200450
451 <h2>Available Term Mappings</h2>
Akrondab27112025-06-05 13:52:43 +0200452 <dl>`
Akron40aaa632025-06-03 17:57:52 +0200453
Akrondab27112025-06-05 13:52:43 +0200454 for _, m := range data.Mappings {
455 html += `<dt><tt>` + m.ID + `</tt></dt>`
456 html += `<dd>` + m.Description + `</dd>`
Akron40aaa632025-06-03 17:57:52 +0200457 }
458
459 html += `
Akroncb51f812025-06-30 15:24:20 +0200460 </dl></div>`
Akron06d21f02025-06-04 14:36:07 +0200461
Akronc376dcc2025-06-04 17:00:18 +0200462 if data.MapID != "" {
Akron80067202025-06-06 14:16:25 +0200463
Akrond0c88602025-06-27 16:57:21 +0200464 queryServiceURL, err := url.Parse(data.ServiceURL)
Akron80067202025-06-06 14:16:25 +0200465 if err != nil {
466 log.Warn().Err(err).Msg("Failed to join URL path")
467 }
468
469 // Use path.Join to normalize the path part
Akrond0c88602025-06-27 16:57:21 +0200470 queryServiceURL.Path = path.Join(queryServiceURL.Path, data.MapID+"/query")
Akroncb51f812025-06-30 15:24:20 +0200471
472 // Build query parameters for query URL
473 queryParamString := buildQueryParams(queryParams.Dir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
474 queryServiceURL.RawQuery = queryParamString
Akrond0c88602025-06-27 16:57:21 +0200475
476 responseServiceURL, err := url.Parse(data.ServiceURL)
477 if err != nil {
478 log.Warn().Err(err).Msg("Failed to join URL path")
479 }
480
481 // Use path.Join to normalize the path part
482 responseServiceURL.Path = path.Join(responseServiceURL.Path, data.MapID+"/response")
Akron80067202025-06-06 14:16:25 +0200483
Akroncb51f812025-06-30 15:24:20 +0200484 reversedDir := "btoa"
485 if queryParams.Dir == "btoa" {
486 reversedDir = "atob"
487 }
488
489 // Build query parameters for response URL (with reversed direction)
490 responseParamString := buildQueryParams(reversedDir, queryParams.FoundryA, queryParams.FoundryB, queryParams.LayerA, queryParams.LayerB)
491 responseServiceURL.RawQuery = responseParamString
492
493 html += `<script>
Akron06d21f02025-06-04 14:36:07 +0200494 <!-- activates/deactivates Mapper. -->
495
Akrond0c88602025-06-27 16:57:21 +0200496 let qdata = {
Akron06d21f02025-06-04 14:36:07 +0200497 'action' : 'pipe',
Akrond0c88602025-06-27 16:57:21 +0200498 'service' : '` + queryServiceURL.String() + `'
Akron06d21f02025-06-04 14:36:07 +0200499 };
500
Akrond0c88602025-06-27 16:57:21 +0200501 let rdata = {
502 'action' : 'pipe',
503 'service' : '` + responseServiceURL.String() + `'
504 };
505
506
Akron06d21f02025-06-04 14:36:07 +0200507 function pluginit (p) {
508 p.onMessage = function(msg) {
509 if (msg.key == 'termmapper') {
510 if (msg.value) {
Akrond0c88602025-06-27 16:57:21 +0200511 qdata['job'] = 'add';
Akron06d21f02025-06-04 14:36:07 +0200512 }
513 else {
Akrond0c88602025-06-27 16:57:21 +0200514 qdata['job'] = 'del';
Akron06d21f02025-06-04 14:36:07 +0200515 };
Akrond0c88602025-06-27 16:57:21 +0200516 KorAPlugin.sendMsg(qdata);
517 if (msg.value) {
518 rdata['job'] = 'add-after';
519 }
520 else {
521 rdata['job'] = 'del-after';
522 };
523 KorAPlugin.sendMsg(rdata);
Akron06d21f02025-06-04 14:36:07 +0200524 };
525 };
526 };
Akronc376dcc2025-06-04 17:00:18 +0200527 </script>`
528 }
529
530 html += ` </body>
Akron40aaa632025-06-03 17:57:52 +0200531</html>`
532
533 return html
534}
Akron14678dc2025-06-05 13:01:38 +0200535
Akroncb51f812025-06-30 15:24:20 +0200536// buildQueryParams builds a query string from the provided parameters
537func buildQueryParams(dir, foundryA, foundryB, layerA, layerB string) string {
538 params := url.Values{}
539 if dir != "" {
540 params.Add("dir", dir)
541 }
542 if foundryA != "" {
543 params.Add("foundryA", foundryA)
544 }
545 if foundryB != "" {
546 params.Add("foundryB", foundryB)
547 }
548 if layerA != "" {
549 params.Add("layerA", layerA)
550 }
551 if layerB != "" {
552 params.Add("layerB", layerB)
553 }
554 return params.Encode()
555}
556
Akron14678dc2025-06-05 13:01:38 +0200557// expandGlobs expands glob patterns in the slice of file paths
558// Returns the expanded list of files or an error if glob expansion fails
559func expandGlobs(patterns []string) ([]string, error) {
560 var expanded []string
561
562 for _, pattern := range patterns {
563 // Use filepath.Glob which works cross-platform
564 matches, err := filepath.Glob(pattern)
565 if err != nil {
566 return nil, fmt.Errorf("failed to expand glob pattern '%s': %w", pattern, err)
567 }
568
569 // If no matches found, treat as literal filename (consistent with shell behavior)
570 if len(matches) == 0 {
571 log.Warn().Str("pattern", pattern).Msg("Glob pattern matched no files, treating as literal filename")
572 expanded = append(expanded, pattern)
573 } else {
574 expanded = append(expanded, matches...)
575 }
576 }
577
578 return expanded, nil
579}