Make unexpanded KWIC metadata fields configurable by request URL

Resolves #275

Change-Id: I432ee8b2d6091012c8fa7138a113527644398e26
diff --git a/Changes b/Changes
index ccdc780..a6e8049 100644
--- a/Changes
+++ b/Changes
@@ -15,6 +15,7 @@
         - Add data-testid to glimpse (diewald)
         - Update dependencies (diewald)
         - Support combined toggle+widget button for plugins (diewald)
+        - Make KWIC metadata fields configurable by request URL (kupietz)
 
 0.64 2026-02-14
         - Improve 'Plugins' mounting (diewald)
diff --git a/dev/scss/main/kwic.scss b/dev/scss/main/kwic.scss
index f6313b0..4427a79 100644
--- a/dev/scss/main/kwic.scss
+++ b/dev/scss/main/kwic.scss
@@ -83,6 +83,8 @@
       }
 
       div.meta {
+        display:          flex;
+        align-items:      center;
         flex:             1 0;
         min-width:        12em;
         max-width:        15em;
@@ -98,6 +100,17 @@
         font-size:        75%;
         padding:          0 5pt;
 
+        > span, > ul.meta-list {
+          min-width: 0;
+          width: 100%;
+        }
+
+        ul.meta-list {
+          list-style-type: disc;
+          margin: 0;
+          padding: 0 0 0 1em;
+        }
+
         border: {
           color: colors.$dark-grey;
           style: solid;
@@ -119,6 +132,11 @@
         &.flip {
           background-color: color.adjust(colors.$middle-grey, $lightness: 17%, $space: hsl);
         }
+
+        &.type-text, &.type-keywords {
+          white-space: normal;
+          word-wrap: break-word;
+        }
       }
 
       &:first-of-type div.meta {
diff --git a/lib/Kalamar/Controller/Search.pm b/lib/Kalamar/Controller/Search.pm
index 487b1f5..70fceac 100644
--- a/lib/Kalamar/Controller/Search.pm
+++ b/lib/Kalamar/Controller/Search.pm
@@ -5,6 +5,7 @@
 use Mojo::Util qw/quote/;
 use Mojo::JSON;
 use POSIX 'ceil';
+use List::Util qw/uniq/;
 
 our @search_fields = qw!ID UID textSigle layerInfos title subTitle pubDate author availability snippet!;
 our $query_placeholder = 'NOQUERY';
@@ -45,6 +46,7 @@
   $v->optional('context');
   $v->optional('pipe', 'trim');
   $v->optional('response-pipe', 'trim');
+  $v->optional('vfields', 'comma_separated', 'trim');
   # $v->optional('action'); # action 'inspect' is no longer valid
   # $v->optional('snippet');
 
@@ -139,6 +141,13 @@
 
   $c->stash(items_per_page => $items_per_page);
 
+  # Meta fields to display in KWIC
+  my @vfields_fields = ('textSigle');
+  if (my $m = $v->param('vfields')) {
+    @vfields_fields = @{$v->every_param('vfields')};
+  }
+  $c->stash(vfields_fields => \@vfields_fields);
+
   # TODO:
   #   if ($v->param('action') eq 'inspect') use trace!
 
@@ -148,7 +157,7 @@
 
 
   # Add requested fields
-  $query{fields} = join ',', @search_fields;
+  $query{fields} = join ',', uniq(@search_fields, @vfields_fields);
 
   # Create remote request URL
   my $url = Mojo::URL->new($c->korap->api);
diff --git a/lib/Kalamar/Plugin/KalamarHelpers.pm b/lib/Kalamar/Plugin/KalamarHelpers.pm
index 74816b5..54a284f 100644
--- a/lib/Kalamar/Plugin/KalamarHelpers.pm
+++ b/lib/Kalamar/Plugin/KalamarHelpers.pm
@@ -204,6 +204,32 @@
       )
     }
   );
+  $mojo->helper(
+    match_field => sub {
+      my ($c, $match, $field) = @_;
+      my $v = '';
+      my $type = 'string';
+      if ($match->{fields} && ref $match->{fields}->[0] eq 'HASH') {
+        foreach (@{$match->{fields}}) {
+          if ($_->{key} && $_->{key} eq $field) {
+            $v = $_->{value};
+            $type = $_->{type} || 'string';
+            last;
+          }
+        }
+      } else {
+        $v = $match->{$field};
+      }
+
+      $type =~ s/^type://;
+
+      my $html = ref $v eq 'ARRAY' 
+        ? b('<ul class="meta-list"><li>' . join('</li><li>', map { b($_ // '')->xml_escape } @$v) . '</li></ul>') 
+        : b($v // '')->xml_escape;
+        
+      return { html => $html, type => $type, raw => $v };
+    }
+  );
 };
 
 
diff --git a/t/query.t b/t/query.t
index 2ac2ac6..a46b8d6 100644
--- a/t/query.t
+++ b/t/query.t
@@ -70,7 +70,7 @@
                      '[id="GOE/AGI/00000#p2030-2031"]' .
                      '[data-available-info^="base/s=spans"]' .
                      '[data-info^="{"]')
-  ->text_is('li:nth-of-type(1) div.meta', 'GOE/AGI/00000')
+  ->text_is('li:nth-of-type(1) div.meta > span', 'GOE/AGI/00000')
   ->element_exists('li:nth-of-type(1) div.match-main div.match-wrap div.snippet')
   ->element_exists('li:nth-of-type(1) div.snippet.startMore.endMore')
   ->text_like('li:nth-of-type(1) div.snippet span.context-left',qr!sie etwas bedeuten!)
@@ -385,5 +385,39 @@
 is($f->{corpusSigle}, 'GOE');
 is($f->{corpusEditor}, 'Trunz, Erich');
 
+# Test vfields query parameter
+# vfields=textSigle should behave like default
+$t->get_ok('/?q=baum&vfields=textSigle')
+  ->status_is(200)
+  ->text_is('li:nth-of-type(1) div.meta > span', 'GOE/AGI/00000')
+  ->text_is('li:nth-of-type(2) div.meta > span', 'GOE/AGI/00001') # Value repeats in actual behavior but texts are different
+  ;
+
+# Test vfields query parameter
+# vfields=textSigle should behave like default
+$t->get_ok('/?q=der&p=1&count=2&vfields=textSigle')
+  ->status_is(200)
+  ->text_is('li:nth-of-type(1) div.meta > span', 'GOE/AGI/00000')
+  ->element_exists_not('li:nth-of-type(2) div.meta > span')
+  ->text_is('li:nth-of-type(2) div.meta', '') # Should be completely empty
+  ;
+
+
+# vfields=textSigle,title should show both fields in separate divs
+$t->get_ok('/?q=baum&vfields=textSigle,title')
+  ->status_is(200)
+  ->text_is('li:nth-of-type(1) div.meta:nth-of-type(1) > span', 'GOE/AGI/00000')
+  ->text_is('li:nth-of-type(1) div.meta:nth-of-type(2) > span', 'Italienische Reise')
+  ->text_is('li:nth-of-type(2) div.meta:nth-of-type(1) > span', 'GOE/AGI/00001') # Texts are different
+  ->text_is('li:nth-of-type(2) div.meta:nth-of-type(2) > span', 'Italienische Reise') 
+  ;
+
+# vfields=author should show only author
+$t->get_ok('/?q=baum&vfields=author')
+  ->status_is(200)
+  ->text_is('li:nth-of-type(1) div.meta > span', 'Goethe, Johann Wolfgang von')
+  ->text_is('li:nth-of-type(2) div.meta > span', 'Goethe, Johann Wolfgang von') # Printed again because it's a new document
+  ;
+
 done_testing;
 __END__
diff --git a/templates/match.html.ep b/templates/match.html.ep
index 775008c..4d3cacf 100644
--- a/templates/match.html.ep
+++ b/templates/match.html.ep
@@ -12,14 +12,19 @@
     data-info="<%== b(encode_json(\%match_data))->decode->xml_escape %>"
     id="<%= $id %>"<% if (current_route eq 'match') { %> class="active"<% } =%>>
 %# This should be done using JavaScript
-% my ($show_sigle, $flip) = ('', stash('flip') // 'flip');
-% if ($text_sigle ne (stash('last_sigle') // '')) {
-%   $show_sigle = $text_sigle;
-%   stash(last_sigle => $text_sigle);
+% my @vfields_fields = @{stash('vfields_fields')};
+% my $is_new = ($text_sigle ne (stash('last_text_sigle') // ''));
+% my $flip = stash('flip') // 'flip';
+% if ($is_new) {
+%   stash(last_text_sigle => $text_sigle);
 %   $flip = $flip eq 'flip' ? 'flop' : 'flip'; 
 %   stash(flip => $flip);
 % }  
-  <div class="meta <%= $flip %>"><%= $show_sigle %></div>
+% foreach my $field (@vfields_fields) {
+%   my $info = match_field($match, $field);
+%   my $val = ($is_new && defined $info->{raw} && $info->{raw} ne '') ? $info->{html} : '';
+  <div class="meta <%= $flip %><%= $info->{type} ? " type-" . $info->{type} : '' %>"><% if ($val ne '') { %><span><%== $val %></span><% } %></div>
+% }
   <div class="match-main">
     <div class="match-wrap">
 %# --- Snippet
diff --git a/templates/partial/header.html.ep b/templates/partial/header.html.ep
index 5a2189a..da00042 100644
--- a/templates/partial/header.html.ep
+++ b/templates/partial/header.html.ep
@@ -36,6 +36,9 @@
       %= select_field 'ql', [[loc('QL_poliqarp') => 'poliqarp'], [loc('QL_cosmas2') => 'cosmas2'], [loc('QL_annis') => 'annis'], [loc('QL_cqp') => 'cqp'], [loc('QL_cql') => 'cql'], [loc('QL_fcsql') => 'fcsql']], id => 'ql-field'
     </span>
     <div class="button right">
+      % if (my @vfields = @{$c->req->every_param('vfields') || []}) {
+      %= hidden_field 'vfields' => join(',', @vfields)
+      % }
       % param(cutoff => 1) unless param 'q';
       %= check_box cutoff => 1, id => 'q-cutoff-field', class => 'checkbox'
       <label for="q-cutoff-field" title="<%= loc('glimpse_desc') %>"><span id="glimpse"></span><%= loc('glimpse') %></label>