Add support for response pipes

Change-Id: I86905bb22ffa70b86476f0de6fa8343f687dc740
diff --git a/Changes b/Changes
index 3478da4..b94ec89 100644
--- a/Changes
+++ b/Changes
@@ -17,6 +17,7 @@
         - URLs for plugins are mandatory (closes #233; hebasta)
         - Support non-indexed annotations in match tables (diewald)
         - Improve explanation of timed-out match counts (diewald)
+        - Support response pipes (preliminary; diewald)
 
 0.59 2025-03-28
         - Docker only release (diewald)
diff --git a/dev/js/spec/pipeSpec.js b/dev/js/spec/pipeSpec.js
index 93bf172..e1f47a0 100644
--- a/dev/js/spec/pipeSpec.js
+++ b/dev/js/spec/pipeSpec.js
@@ -94,12 +94,19 @@
       let e = p.element();
       expect(e.tagName).toEqual("INPUT");
       expect(e.getAttribute("type")).toEqual("text");
+      expect(e.getAttribute("name")).toEqual("pipe");
       p.append('service1');
       expect(e.getAttribute("value")).toEqual("service1");
       p.append('service2');
       expect(e.getAttribute("value")).toEqual("service1,service2");
       p.remove('service1');
       expect(e.getAttribute("value")).toEqual("service2");
+
+      p = pipeClass.create("response-pipe");
+      e = p.element();
+      expect(e.tagName).toEqual("INPUT");
+      expect(e.getAttribute("type")).toEqual("text");
+      expect(e.getAttribute("name")).toEqual("response-pipe");
     });
   });
 });
diff --git a/dev/js/src/api.js b/dev/js/src/api.js
index 3b3b821..effa9f8 100644
--- a/dev/js/src/api.js
+++ b/dev/js/src/api.js
@@ -41,11 +41,11 @@
     }
 
     docFragment += '/' + match.matchID;
-    url += '/' + docFragment;
+    url += '/' + docFragment + '?';
 
     // { spans: true, layer:x, foundry : y}
     if (param['spans'] == true) {
-      url += '?spans=true';
+      url += 'spans=true';
       docFragment += ' +spans ';
       if (param['foundry'] !== undefined) {
 	      url += '&foundry=' + param['foundry'];
@@ -61,8 +61,11 @@
     else {
       // TODO
       docFragment += ' -spans';
-      url += '?spans=false';
-    }
+      url += 'spans=false';
+    };
+
+    if (KorAP.ResponsePipe != null)
+      url += '&response-pipe=' + KorAP.ResponsePipe.toString();
 
     KorAP.API.getJSON(url, cb, "MatchInfo: " + docFragment);
   };
@@ -81,8 +84,12 @@
     }
     else {
       url += '?fields=@all'; // TODO: Maybe '*'?
-    }
+    };
 
+    if (KorAP.ResponsePipe != null)
+      url += '&response-pipe=' + KorAP.ResponsePipe.toString();
+
+    
     KorAP.API.getJSON(url, cb, "TextInfo: " + doc.textSigle);
   };
 
diff --git a/dev/js/src/init.js b/dev/js/src/init.js
index 706d898..b5c3baf 100644
--- a/dev/js/src/init.js
+++ b/dev/js/src/init.js
@@ -586,6 +586,13 @@
             pipeE.removeAttribute("name");
         };
 
+        if (KorAP.ResponsePipe != null) {
+          const pipeE = KorAP.ResponsePipe.element();
+          if (pipeE.value == "")
+            pipeE.removeAttribute("name");
+        };
+
+	
         // This would preferably set the query to be "disabled",
         // but in that case the query wouldn't be submitted
         // at all.
@@ -696,8 +703,13 @@
             d.head.appendChild(KorAP.Plugin.element());
 
             // Add pipe form
-            KorAP.Pipe = pipeClass.create();
-            d.getElementById("searchform").appendChild(KorAP.Pipe.element());
+            KorAP.Pipe = pipeClass.create("pipe");
+            let searchF = d.getElementById("searchform");
+	    searchF.appendChild(KorAP.Pipe.element());
+
+            // Add pipe form
+            KorAP.ResponsePipe = pipeClass.create("response-pipe");
+            searchF.appendChild(KorAP.ResponsePipe.element());
 
             try {
               
diff --git a/dev/js/src/pipe.js b/dev/js/src/pipe.js
index 7f0f6be..f10853e 100644
--- a/dev/js/src/pipe.js
+++ b/dev/js/src/pipe.js
@@ -1,12 +1,13 @@
 /**
  * Create a pipe object, that holds a list of services
  * meant to transform the KQ passed before it's finally
- * passed to the search engine.
+ * passed to the search engine or to transform the response
+ * afterwards.
  *
  * @author Nils Diewald
  */
 "use strict";
-define(function () {
+define(['util'], function () {
   const notNullRe = new RegExp("[a-zA-Z0-9]");
 
   // Trim and check
@@ -23,8 +24,9 @@
     /**
      * Constructor
      */
-    create : function () {
+    create : function (name) {
       const obj = Object.create(this);
+      obj._name = (name == undefined) ? 'pipe' : name;
       obj._pipe = [];
       return obj;
     },
@@ -81,6 +83,13 @@
       return this._pipe.join(',');
     },
 
+    /**
+     * Return the pipe as a an URI compliant string.
+     */
+    toUriString : function () {
+      return encodeURIComponent(this.toString());
+    },
+
 
     /**
      * Update the pipe value.
@@ -100,7 +109,7 @@
       if (e == null) {
         e = this.e = document.createElement('input');
         e.setAttribute("type","text");
-        e.setAttribute("name","pipe");
+        e.setAttribute("name",this._name);
         e.classList.add("pipe");
       };
       return e;
diff --git a/dev/js/src/plugin/server.js b/dev/js/src/plugin/server.js
index bdc4873..f8c9855 100644
--- a/dev/js/src/plugin/server.js
+++ b/dev/js/src/plugin/server.js
@@ -479,12 +479,24 @@
 
       // Modify pipes
       case 'pipe':
-        if (KorAP.Pipe != undefined) {
-          if (d.job == 'del') {
-            KorAP.Pipe.remove(d.service);
-          } else {
-            KorAP.Pipe.append(d.service);
-          };
+	let j = d.job;
+        if (
+	  ((j == 'del-after' || j == 'add-after') &&
+	   KorAP.ResponsePipe == undefined) ||
+	    KorAP.Pipe == undefined) {
+	  
+	  KorAP.log(0,"No Pipe established");
+	  break;
+	};
+
+	if (j == 'del') {
+          KorAP.Pipe.remove(d.service);
+	} else if (j == 'del-after') {
+	  KorAP.ResponsePipe.remove(d.service);
+        } else if (j == 'add-after') {
+	  KorAP.ResponsePipe.append(d.service);
+	} else {
+          KorAP.Pipe.append(d.service);
         };
         break;
 
diff --git a/lib/Kalamar/Controller/Search.pm b/lib/Kalamar/Controller/Search.pm
index 6938bd6..487b1f5 100644
--- a/lib/Kalamar/Controller/Search.pm
+++ b/lib/Kalamar/Controller/Search.pm
@@ -44,6 +44,7 @@
   $v->optional('o', 'trim')->num(1, undef); # Offset
   $v->optional('context');
   $v->optional('pipe', 'trim');
+  $v->optional('response-pipe', 'trim');
   # $v->optional('action'); # action 'inspect' is no longer valid
   # $v->optional('snippet');
 
@@ -122,13 +123,20 @@
     $query{count} = $items_per_page;
   };
 
-  # Forward pipe
+  # Query pipe
   if ($v->param('pipe')) {
 
     # Temporary, as this is not agreed among the services yet
     $query{pipes} = $v->param('pipe');
   };
 
+  # Response pipe
+  if ($v->param('response-pipe')) {
+
+    # Temporary, as this is not agreed among the services yet
+    $query{'response-pipes'} = $v->param('response-pipe');
+  };
+
   $c->stash(items_per_page => $items_per_page);
 
   # TODO:
@@ -331,6 +339,7 @@
   # Input validation
   my $v = $c->validation;
   $v->optional('cq');
+  $v->optional('response-pipe', 'trim');
 
   my $url = Mojo::URL->new($c->korap->api);
 
@@ -339,8 +348,18 @@
 
   # Add query
   my $cq = $v->param('cq');
-  $url->query({cq => $cq}) if $cq;
 
+  my %query = ();
+  $query{cq} = $cq if $cq;
+
+  # Response pipe
+  if ($v->param('response-pipe')) {
+
+    # Temporary, as this is not agreed among the services yet
+    $query{'response-pipes'} = $v->param('response-pipe');
+  };
+
+  $url->query(\%query);
   $c->app->log->debug("Statistics info: $url");
 
   # Async
@@ -385,10 +404,18 @@
   # Input validation
   my $v = $c->validation;
   $v->optional('fields');
+  $v->optional('response-pipe', 'trim');
 
   my %query = (fields => '@all');
   $query{fields} = $v->param('fields') if $v->param('fields');
 
+  # Response pipe
+  if ($v->param('response-pipe')) {
+
+    # Temporary, as this is not agreed among the services yet
+    $query{'response-pipes'} = $v->param('response-pipe');
+  };
+
   my $url = Mojo::URL->new($c->korap->api);
 
   # Use hash slice to create path
@@ -446,6 +473,7 @@
   $v->optional('foundry');
   $v->optional('layer');
   $v->optional('spans')->in(qw/true false/);
+  $v->optional('response-pipe', 'trim');
 
   # Check validation
   if ($v->has_error) {
@@ -481,6 +509,13 @@
     $query{spans} = $v->param('spans') if $v->param('spans');
   };
 
+  # Response pipe
+  if ($v->param('response-pipe')) {
+
+    # Temporary, as this is not agreed among the services yet
+    $query{'response-pipes'} = $v->param('response-pipe');
+  };
+
   # Create new request API
   my $url = Mojo::URL->new($c->korap->api);
 
diff --git a/t/corpus_info.t b/t/corpus_info.t
index ecbf2dd..e92d8d6 100644
--- a/t/corpus_info.t
+++ b/t/corpus_info.t
@@ -35,6 +35,29 @@
   ->header_isnt('X-Kalamar-Cache', 'true')
   ;
 
+# Query passed
+$t->get_ok('/corpus')
+  ->status_is(200)
+  ->content_like(qr!"tokens":5991667065!)
+  ->json_is('/documents', 20216975)
+  ->json_is('/tokens', 5991667065)
+  ->json_is('/sentences', 403923016)
+  ->json_is('/paragraphs', 129385487)
+  ->json_is('/meta/responsePipes',undef)
+  ->header_is('X-Kalamar-Cache', 'true')
+  ;
+
+$t->get_ok('/corpus?response-pipe=glemm')
+  ->status_is(200)
+  ->content_like(qr!"tokens":5991667065!)
+  ->json_is('/documents', 20216975)
+  ->json_is('/tokens', 5991667065)
+  ->json_is('/sentences', 403923016)
+  ->json_is('/paragraphs', 129385487)
+  ->json_is('/meta/responsePipes','glemm')
+  ->header_isnt('X-Kalamar-Cache', 'true')
+  ;
+
 $t->get_ok('/corpus?cq=docSigle+%3D+\"GOE/AGA\"')
   ->status_is(200)
   ->json_is('/documents', 5)
diff --git a/t/fixtures/response_query_baum_o0_c25_pglemm_rprewind.json b/t/fixtures/response_query_baum_o0_c25_pglemm_rprewind.json
new file mode 100644
index 0000000..a1727e3
--- /dev/null
+++ b/t/fixtures/response_query_baum_o0_c25_pglemm_rprewind.json
@@ -0,0 +1,76 @@
+{
+  "status" : 200,
+  "json" : {
+    "@context" : "http://korap.ids-mannheim.de/ns/KoralQuery/v0.3/context.jsonld",
+    "meta" :  {
+      "count" : 25,
+      "startIndex" : 0,
+      "authorized" : null,
+      "timeout" : 120000,
+      "context" : {
+        "left" : ["token",40],
+        "right" : ["token",40]
+      },
+      "fields" : ["pubDate","subTitle","author","pubPlace","title","textSigle","UID","ID","layerInfos","corpusSigle","docSigle","corpusID","textClass"],
+      "version" : "0.55.7",
+      "benchmark" : "0.120577834 s",
+      "totalResults" : 51,
+      "serialQuery" : "tokens:s:Baum",
+      "itemsPerPage" : 25
+    },
+    "query" : {
+      "@type" : "koral:token",
+      "wrap" : {
+        "@type" : "koral:term",
+        "layer" : "orth",
+        "key" : "Baum",
+        "match" : "match:eq",
+        "foundry" : "opennlp",
+        "rewrites" : [
+          {
+            "@type" : "koral:rewrite",
+            "src" : "Kustvakt",
+            "operation" : "operation:injection",
+            "scope" : "foundry"
+          }
+        ]
+      }
+    },
+    "matches" : [
+      {
+        "field" : "tokens",
+        "pubPlace" : "München",
+        "textSigle" : "GOE/AGI/00000",
+        "docSigle" : "GOE/AGI",
+        "corpusSigle" : "GOE",
+        "title" : "Italienische Reise",
+        "subTitle" : "Auch ich in Arkadien!",
+        "author" : "Goethe, Johann Wolfgang von",
+        "layerInfos" : "base/s=spans corenlp/c=spans corenlp/p=tokens corenlp/s=spans dereko/s=spans malt/d=rels mdp/d=rels opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans",
+        "startMore" : true,
+        "endMore" : true,
+        "snippet" : "<span class=\"context-left\"><span class=\"more\"></span>sie etwas bedeuten zu wollen und machte mit der Oberlippe eine fatale Miene. ich sprach sehr viel mit ihr durch, sie war überall zu Hause und merkte gut auf die Gegenstände. so fragte sie mich einmal, was das für ein </span><span class=\"match\"><mark>Baum</mark></span><span class=\"context-right\"> sei. es war ein schöner großer Ahorn, der erste, der mir auf der ganzen Reise zu Gesichte kam. den hatte sie doch gleich bemerkt und freute sich, da mehrere nach und nach erschienen, daß sie auch diesen Baum unterscheiden könne<span class=\"more\"></span></span>",
+        "matchID" : "match-GOE/AGI/00000-p2030-2031",
+        "UID" : 0,
+        "pubDate" : "1982"
+      },
+      {
+        "field" : "tokens",
+        "pubPlace" : "München",
+        "textSigle" : "GOE/AGI/00001",
+        "docSigle" : "GOE/AGI",
+        "corpusSigle" : "GOE",
+        "title" : "Italienische Reise",
+        "subTitle" : "Auch ich in Arkadien!",
+        "author" : "Goethe, Johann Wolfgang von",
+        "layerInfos" : "base/s=spans corenlp/c=spans corenlp/p=tokens corenlp/s=spans dereko/s=spans malt/d=rels mdp/d=rels opennlp/p=tokens opennlp/s=spans tt/l=tokens tt/p=tokens tt/s=spans",
+        "startMore" : true,
+        "endMore" : true,
+        "snippet" : "<span class=\"context-left\"><span class=\"more\"></span>für ein Baum sei. es war ein schöner großer Ahorn, der erste, der mir auf der ganzen Reise zu Gesichte kam. den hatte sie doch gleich bemerkt und freute sich, da mehrere nach und nach erschienen, daß sie auch diesen </span><span class=\"match\"><mark>Baum</mark></span><span class=\"context-right\"> unterscheiden könne. sie gehe, sagte sie, nach Bozen auf die Messe, wo ich doch wahrscheinlich auch hinzöge. wenn sie mich dort anträfe, müsse ich ihr einen Jahrmarkt kaufen, welches ich ihr denn auch versprach. dort wollte sie auch ihre neue<span class=\"more\"></span></span>",
+        "matchID" : "match-GOE/AGI/00000-p2068-2069",
+        "UID" : 0,
+        "pubDate" : "1982"
+      }
+    ]
+  }
+}
diff --git a/t/match_info.t b/t/match_info.t
index 1dfa55f..edd84b4 100644
--- a/t/match_info.t
+++ b/t/match_info.t
@@ -33,10 +33,11 @@
   ->header_isnt('X-Kalamar-Cache', 'true')
   ;
 
-$t->get_ok('/corpus/GOE/AGF/02286/p75682-75683?_format=json')
+$t->get_ok('/corpus/GOE/AGF/02286/p75682-75683?_format=json&response-pipe=glemm')
   ->status_is(200)
   ->json_is('/textSigle', 'GOE/AGF/02286')
   ->json_is('/title','Materialien zur Geschichte der Farbenlehre')
+  ->json_is('/meta/responsePipes','glemm')
   ;
 
 # TODO:
@@ -115,6 +116,13 @@
   ->text_is('div.notify', 'Parameter "spans" invalid')
   ;
 
+$t->get_ok('/corpus/WPD15/232/39681/p2133-2134?spans=no')
+  ->content_type_is('text/html;charset=UTF-8')
+  ->status_is(400)
+  ->text_is('p.no-results', 'Unable to perform the action.')
+  ->text_is('div.notify', 'Parameter "spans" invalid')
+  ;
+
 
 done_testing;
 __END__
diff --git a/t/query.t b/t/query.t
index 0d909f4..9b51aab 100644
--- a/t/query.t
+++ b/t/query.t
@@ -345,9 +345,10 @@
 is($match->{matchID}, 'p5441-5442');
 
 # Query with pipe
-$err = $t->get_ok('/?q=baum&pipe=glemm')
+$err = $t->get_ok('/?q=baum&pipe=glemm&response-pipe=rewind')
   ->status_is(200)
   ->content_like(qr/${q}pipes${q}:${q}glemm${q}/)
+  ->content_like(qr/${q}responsePipes${q}:${q}rewind${q}/)
   ->tx->res->dom->at('#error')
   ;
 is(defined $err ? $err->text : '', '');
diff --git a/t/server/mock.pl b/t/server/mock.pl
index 32485fb..1af2fb0 100644
--- a/t/server/mock.pl
+++ b/t/server/mock.pl
@@ -142,6 +142,7 @@
   $v->optional('context');
   $v->optional('offset');
   $v->optional('pipes');
+  $v->optional('response-pipes');
   $v->optional('fields');
   $v->optional('cutoff')->in(qw/true false/);
 
@@ -180,6 +181,7 @@
   push @slug_base, 'co' . $v->param('cutoff') if defined $v->param('cutoff');
   push @slug_base, 'cq' if defined $v->param('cq');
   push @slug_base, 'p' . $v->param('pipes') if defined $v->param('pipes');
+  push @slug_base, 'rp' . $v->param('response-pipes') if defined $v->param('response-pipes');
 
   if (defined $v->param('fields') && ($v->param('fields') ne join(',', @default_search_fields))) {
     push @slug_base, 'f' .join('-', split(',', $v->param('fields')));
@@ -227,6 +229,10 @@
     $response->{json}->{meta}->{pipes} = $v->param('pipes');
   };
 
+  if ($v->param('response-pipes')) {
+    $response->{json}->{meta}->{responsePipes} = $v->param('response-pipes');
+  };
+
   # Set page parameter
   if ($v->param('page')) {
     $response->{json}->{meta}->{startIndex} = $v->param("startIndex");
@@ -243,6 +249,8 @@
 # Textinfo fixtures
 get '/v1.0/corpus/#corpusId/#docId/#textId' => sub {
   my $c = shift;
+  my $v = $c->validation;
+  $v->optional('response-pipes');
 
   my $file = join('_', (
     'textinfo',
@@ -255,6 +263,11 @@
 
   # Get response based on query parameter
   my $response = $c->load_response($slug);
+
+  if ($v->param('response-pipes')) {
+    $response->{json}->{meta}->{responsePipes} = $v->param('response-pipes');
+  };
+
   return $c->render(%$response);
 };
 
@@ -262,6 +275,8 @@
 # Matchinfo fixtures
 get '/v1.0/corpus/#corpusId/#docId/#textId/#matchId' => sub {
   my $c = shift;
+  my $v = $c->validation;
+  $v->optional('response-pipes');
 
   my $file = join('_', (
     'matchinfo',
@@ -275,6 +290,11 @@
 
   # Get response based on query parameter
   my $response = $c->load_response($slug);
+
+  if ($v->param('response-pipes')) {
+    $response->{json}->{meta}->{responsePipes} = $v->param('response-pipes');
+  };
+
   return $c->render(%$response);
 };
 
@@ -284,6 +304,7 @@
   my $c = shift;
   my $v = $c->validation;
   $v->optional('cq');
+  $v->optional('response-pipes', 'trim');
 
   my @list = 'corpusinfo';
   if ($v->param('cq')) {
@@ -293,6 +314,13 @@
 
   # Get response based on query parameter
   my $response = $c->load_response($slug);
+
+  if ($v->param('response-pipes')) {
+    my $meta = $response->{json}->{meta} // {};
+    $meta->{responsePipes} = $v->param('response-pipes');
+    $response->{json}->{meta} = $meta;
+  };
+
   return $c->render(%$response);
 };
 
diff --git a/t/text_info.t b/t/text_info.t
index 55b9637..4107931 100644
--- a/t/text_info.t
+++ b/t/text_info.t
@@ -31,6 +31,14 @@
   ->json_is('/document/fields/0/value', 'GOE/AGI/00000')
   ;
 
+$t->get_ok('/corpus/GOE/AGI/00000?response-pipe=glemm')
+  ->status_is(200)
+  ->json_is('/document/fields/0/key', 'textSigle')
+  ->json_is('/document/fields/0/value', 'GOE/AGI/00000')
+  ->json_is('/meta/responsePipes', 'glemm')
+  ;
+
+
 # Not found - should probably be 404
 $t->get_ok('/corpus/GOE/AGY/00000')
   ->status_is(200)