Merge branch 'JAL-3878_ws-overhaul-3' into mmw/Release_2_12_ws_merge
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Mon, 6 Feb 2023 13:04:59 +0000 (14:04 +0100)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Mon, 6 Feb 2023 13:10:03 +0000 (14:10 +0100)
68 files changed:
ISSUES.txt [new file with mode: 0644]
src/jalview/datamodel/Sequence.java
src/jalview/gui/AlignFrame.java
src/jalview/gui/Desktop.java
src/jalview/gui/IProgressIndicator.java
src/jalview/gui/PCAPanel.java
src/jalview/gui/ProgressBar.java
src/jalview/gui/SlivkaPreferences.java
src/jalview/gui/StructureChooser.java
src/jalview/gui/WebserviceInfo.java
src/jalview/gui/WsJobParameters.java
src/jalview/util/ArrayUtils.java
src/jalview/util/MathUtils.java
src/jalview/util/Pair.java [new file with mode: 0644]
src/jalview/viewmodel/AlignmentViewport.java
src/jalview/workers/AlignCalcManager2.java
src/jalview/ws/params/ParamDatastoreI.java
src/jalview/ws/params/simple/BooleanOption.java
src/jalview/ws/params/simple/DoubleParameter.java
src/jalview/ws/params/simple/FileParameter.java
src/jalview/ws/params/simple/IntegerParameter.java
src/jalview/ws/params/simple/LogarithmicParameter.java
src/jalview/ws/params/simple/Option.java
src/jalview/ws/params/simple/RadioChoiceParameter.java
src/jalview/ws/params/simple/StringParameter.java
src/jalview/ws2/actions/AbstractPollableTask.java [new file with mode: 0644]
src/jalview/ws2/actions/BaseAction.java [new file with mode: 0644]
src/jalview/ws2/actions/BaseJob.java [new file with mode: 0644]
src/jalview/ws2/actions/ServiceInputInvalidException.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentAction.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentJob.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentProviderI.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentResult.java [new file with mode: 0644]
src/jalview/ws2/actions/alignment/AlignmentTask.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationAction.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationJob.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationProviderI.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationResult.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationTask.java [new file with mode: 0644]
src/jalview/ws2/actions/api/ActionI.java [new file with mode: 0644]
src/jalview/ws2/actions/api/JobI.java [new file with mode: 0644]
src/jalview/ws2/actions/api/TaskEventListener.java [new file with mode: 0644]
src/jalview/ws2/actions/api/TaskI.java [new file with mode: 0644]
src/jalview/ws2/api/CredentialType.java [new file with mode: 0644]
src/jalview/ws2/api/Credentials.java [new file with mode: 0644]
src/jalview/ws2/api/JobStatus.java [new file with mode: 0644]
src/jalview/ws2/api/WebService.java [new file with mode: 0644]
src/jalview/ws2/api/WebServiceJobHandle.java [new file with mode: 0644]
src/jalview/ws2/client/api/AbstractWebServiceDiscoverer.java [new file with mode: 0644]
src/jalview/ws2/client/api/AlignmentWebServiceClientI.java [new file with mode: 0644]
src/jalview/ws2/client/api/AnnotationWebServiceClientI.java [new file with mode: 0644]
src/jalview/ws2/client/api/WebServiceClientI.java [new file with mode: 0644]
src/jalview/ws2/client/api/WebServiceDiscovererI.java [new file with mode: 0644]
src/jalview/ws2/client/api/WebServiceProviderI.java [new file with mode: 0644]
src/jalview/ws2/client/slivka/SlivkaParamStoreFactory.java [new file with mode: 0644]
src/jalview/ws2/client/slivka/SlivkaWSClient.java [new file with mode: 0644]
src/jalview/ws2/client/slivka/SlivkaWSDiscoverer.java [new file with mode: 0644]
src/jalview/ws2/gui/AlignmentServiceGuiHandler.java [new file with mode: 0644]
src/jalview/ws2/gui/AnnotationServiceGuiHandler.java [new file with mode: 0644]
src/jalview/ws2/gui/WebServicesMenuManager.java [new file with mode: 0644]
src/jalview/ws2/helpers/DelegateJobEventListener.java [new file with mode: 0644]
src/jalview/ws2/helpers/TaskEventSupport.java [new file with mode: 0644]
src/jalview/ws2/helpers/WSClientTaskWrapper.java [new file with mode: 0644]
src/jalview/ws2/params/ArgumentBean.java [new file with mode: 0644]
src/jalview/ws2/params/ArgumentBeanList.java [new file with mode: 0644]
src/jalview/ws2/params/SimpleParamDatastore.java [new file with mode: 0644]
src/jalview/ws2/params/SimpleParamSet.java [new file with mode: 0644]
test/jalview/ws2/client/slivka/SlivkaWSDiscovererTest.java [new file with mode: 0644]

diff --git a/ISSUES.txt b/ISSUES.txt
new file mode 100644 (file)
index 0000000..b11d271
--- /dev/null
@@ -0,0 +1,2 @@
+slivka preferences - progress bar disppears immediatelly when running task is cancelled.
+param edit window should aks for preset file
index 509a287..4230366 100755 (executable)
@@ -258,6 +258,21 @@ public class Sequence extends ASequence implements SequenceI
   }
 
   /**
+   * Create a new sequence object from a characters array using default values
+   * of 1 and -1 for start and end. The array used to create the sequence is
+   * copied and is not stored internally.
+   * 
+   * @param name
+   *          sequence name
+   * @param sequence
+   *          list of residues
+   */
+  public Sequence(String name, char[] sequence)
+  {
+    this(name, Arrays.copyOf(sequence, sequence.length), 1, -1);
+  }
+
+  /**
    * Creates a new Sequence object with new AlignmentAnnotations but inherits
    * any existing dataset sequence reference. If non exists, everything is
    * copied.
index c38f336..63bbb72 100644 (file)
@@ -182,7 +182,10 @@ import jalview.ws.params.ArgumentI;
 import jalview.ws.params.ParamDatastoreI;
 import jalview.ws.params.WsParamSetI;
 import jalview.ws.seqfetcher.DbSourceProxy;
-import jalview.ws.slivkaws.SlivkaWSDiscoverer;
+import jalview.ws2.client.api.WebServiceDiscovererI;
+import jalview.ws2.client.slivka.SlivkaWSDiscoverer;
+import jalview.ws2.gui.WebServicesMenuManager;
+
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
@@ -964,13 +967,23 @@ public class AlignFrame extends GAlignFrame
   {
     buildWebServicesMenu();
   }
+
+  private WebServiceDiscovererI.ServicesChangeListener slivkaServiceChangeListener =
+      (discoverer, services) -> {
+        // run when slivka services change
+        var menu = AlignFrame.this.slivkaMenu;
+        menu.setServices(discoverer);
+        menu.setInProgress(discoverer.isRunning());
+        menu.setNoServices(services.isEmpty() && discoverer.isDone());
+      };
+
   /* Set up intrinsic listeners for dynamically generated GUI bits. */
   private void addServiceListeners()
   {
     if (Cache.getDefault("SHOW_SLIVKA_SERVICES", true))
     {
-      WSDiscovererI discoverer = SlivkaWSDiscoverer.getInstance();
-      discoverer.addServiceChangeListener(this);
+      WebServiceDiscovererI discoverer = SlivkaWSDiscoverer.getInstance();
+      discoverer.addServicesChangeListener(slivkaServiceChangeListener);
     }
     if (Cache.getDefault("SHOW_JWS2_SERVICES", true))
     {
@@ -987,7 +1000,7 @@ public class AlignFrame extends GAlignFrame
       @Override
       public void internalFrameClosed(InternalFrameEvent e) {
         System.out.println("deregistering discoverer listener");
-        SlivkaWSDiscoverer.getInstance().removeServiceChangeListener(AlignFrame.this);
+        SlivkaWSDiscoverer.getInstance().removeServicesChangeListener(slivkaServiceChangeListener);
         Jws2Discoverer.getInstance().removeServiceChangeListener(AlignFrame.this);
         Desktop.getInstance().removeJalviewPropertyChangeListener("services", legacyListener);
         closeMenuItem_actionPerformed(true);
@@ -1111,6 +1124,12 @@ public class AlignFrame extends GAlignFrame
   }
   
   @Override
+  public void addProgressBar(long id, String message)
+  {
+    progressBar.addProgressBar(id, message);
+  }
+
+  @Override
   public void removeProgressBar(long id)
   {
     progressBar.removeProgressBar(id);
@@ -4637,6 +4656,7 @@ public class AlignFrame extends GAlignFrame
     return tp;
   }
 
+  private WebServicesMenuManager slivkaMenu = new WebServicesMenuManager("slivka", this);
 
   /**
    * Schedule the web services menu rebuild to the event dispatch thread.
@@ -4650,9 +4670,10 @@ public class AlignFrame extends GAlignFrame
       {
         Console.info("Building web service menu for slivka");
         SlivkaWSDiscoverer discoverer = SlivkaWSDiscoverer.getInstance();
-        JMenu submenu = new JMenu("Slivka");
-        buildWebServicesMenu(discoverer, submenu);
-        webService.add(submenu);
+        slivkaMenu.setServices(discoverer);
+        slivkaMenu.setInProgress(discoverer.isRunning());
+        slivkaMenu.setNoServices(discoverer.isDone() && !discoverer.hasServices());
+        webService.add(slivkaMenu.getMenu());
       }
       if (Cache.getDefault("SHOW_JWS2_SERVICES", true))
       {
@@ -4662,6 +4683,7 @@ public class AlignFrame extends GAlignFrame
         buildWebServicesMenu(jws2servs, submenu);
         webService.add(submenu);
       }
+      build_urlServiceMenu(webService);
       build_fetchdbmenu(webService);
     });
   }
index b125cc2..61bef5e 100644 (file)
@@ -2572,6 +2572,13 @@ public class Desktop extends GDesktop
   }
   
   @Override
+  public void addProgressBar(long id, String message)
+  {
+    // TODO
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  @Override
   public void removeProgressBar(long id)
   {
     //TODO
@@ -2733,7 +2740,8 @@ public class Desktop extends GDesktop
     }
     if (Cache.getDefault("SHOW_SLIVKA_SERVICES", true))
     {
-      tasks.add(jalview.ws.slivkaws.SlivkaWSDiscoverer.getInstance().startDiscoverer());
+      tasks.add(jalview.ws2.client.slivka.SlivkaWSDiscoverer
+          .getInstance().startDiscoverer());
     }
     if (blocking)
     {
index c250aca..d13338e 100644 (file)
@@ -57,10 +57,24 @@ public interface IProgressIndicator
   boolean operationInProgress();
 
   /**
-   * Remove progress bar with a given id from the panel.
+   * Adds a progress bar for the given id if it doesn't exist displaying the
+   * provided message. Subsequent calls do nothing.
    * 
    * @param id
+   *          progress bar identifier
+   * @param message
+   *          displayed message
+   */
+  void addProgressBar(long id, String message);
+
+  /**
+   * Removes a progress bar for the given id if it exists. Subsequent calls do
+   * nothing.
+   * 
+   * @param id
+   *          id of the progress bar to be removed
    */
   void removeProgressBar(long id);
 
+
 }
index 000dd76..6638718 100644 (file)
@@ -615,6 +615,12 @@ public class PCAPanel extends GPCAPanel
   }
   
   @Override
+  public void addProgressBar(long id, String message)
+  {
+    progressBar.addProgressBar(id, message);
+  }
+
+  @Override
   public void removeProgressBar(long id)
   {
     progressBar.removeProgressBar(id);
index abf096f..7c98398 100644 (file)
@@ -83,7 +83,7 @@ public class ProgressBar implements IProgressIndicator
       throw new NullPointerException();
     }
     if (!GridLayout.class
-            .isAssignableFrom(container.getLayout().getClass()))
+        .isAssignableFrom(container.getLayout().getClass()))
     {
       throw new IllegalArgumentException("Container must have GridLayout");
     }
@@ -126,62 +126,81 @@ public class ProgressBar implements IProgressIndicator
       @Override
       public void run()
       {
-        JPanel progressPanel = progressBars.get(id);
-        if (progressPanel != null)
+        if (progressBars.containsKey(id))
         {
           /*
            * Progress bar is displayed for this id - remove it now, and any handler
            */
-          progressBars.remove(id);
+          removeProgressBar(id);
           if (message != null && statusBar != null)
           {
             statusBar.setText(message);
           }
-          if (progressBarHandlers.containsKey(id))
-          {
-            progressBarHandlers.remove(id);
-          }
-          removeRow(progressPanel);
         }
         else
         {
           /*
            * No progress bar for this id - add one now
            */
-          progressPanel = new JPanel(new BorderLayout(10, 5));
-
-          JProgressBar progressBar = new JProgressBar();
-          progressBar.setIndeterminate(true);
-
-          progressPanel.add(new JLabel(message), BorderLayout.WEST);
-          progressPanel.add(progressBar, BorderLayout.CENTER);
-
-          addRow(progressPanel);
-
-          progressBars.put(id, progressPanel);
+          addProgressBar(id, message);
         }
-
-        refreshLayout();
       }
     });
 
   }
-  
+
+  /**
+   * Add a progress bar for the given id if it doesn't exist displaying the
+   * provided message. Subsequent calls do nothing.
+   * 
+   * @param id
+   *          progress bar identifier
+   * @param message
+   *          displayed message
+   */
+  @Override
+  public void addProgressBar(final long id, final String message)
+  {
+    if (progressBars.containsKey(id))
+      return;
+    JPanel progressPanel = new JPanel(new BorderLayout(10, 5));
+    progressBars.put(id, progressPanel);
+    Runnable r = () -> {
+      JProgressBar progressBar = new JProgressBar();
+      progressBar.setIndeterminate(true);
+      progressPanel.add(new JLabel(message), BorderLayout.WEST);
+      progressPanel.add(progressBar, BorderLayout.CENTER);
+      addRow(progressPanel);
+      refreshLayout();
+    };
+    if (SwingUtilities.isEventDispatchThread())
+      r.run();
+    else
+      SwingUtilities.invokeLater(r);
+  }
+
+  /**
+   * Remove a progress bar for the given id if it exists. Subsequent calls do
+   * nothing.
+   * 
+   * @param id
+   *          id of the progress bar to be removed
+   */
   @Override
   public void removeProgressBar(final long id)
   {
-    SwingUtilities.invokeLater(() -> {
-      JPanel progressPanel = progressBars.get(id);
-      if (progressPanel != null)
-      {
-        progressBars.remove(id);
-        if (progressBarHandlers.containsKey(id))
-        {
-          progressBarHandlers.remove(id);
-        }
-        removeRow(progressPanel);
-      }
-    });
+    JPanel progressPanel = progressBars.remove(id);
+    if (progressPanel == null)
+      return;
+    progressBarHandlers.remove(id);
+    Runnable r = () -> {
+      removeRow(progressPanel);
+      refreshLayout();
+    };
+    if (SwingUtilities.isEventDispatchThread())
+      r.run();
+    else
+      SwingUtilities.invokeLater(r);
   }
 
   /**
@@ -236,7 +255,7 @@ public class ProgressBar implements IProgressIndicator
    */
   @Override
   public void registerHandler(final long id,
-          final IProgressIndicatorHandler handler)
+      final IProgressIndicatorHandler handler)
   {
     final IProgressIndicator us = this;
 
@@ -249,7 +268,7 @@ public class ProgressBar implements IProgressIndicator
         if (progressPanel == null)
         {
           System.err.println(
-                  "call setProgressBar before registering the progress bar's handler.");
+              "call setProgressBar before registering the progress bar's handler.");
           return;
         }
 
@@ -263,7 +282,7 @@ public class ProgressBar implements IProgressIndicator
 
         progressBarHandlers.put(id, handler);
         JButton cancel = new JButton(
-                MessageManager.getString("action.cancel"));
+            MessageManager.getString("action.cancel"));
         cancel.addActionListener(new ActionListener()
         {
 
@@ -272,9 +291,9 @@ public class ProgressBar implements IProgressIndicator
           {
             handler.cancelActivity(id);
             us.setProgressBar(MessageManager
-                    .formatMessage("label.cancelled_params", new Object[]
-                    { ((JLabel) progressPanel.getComponent(0)).getText() }),
-                    id);
+                .formatMessage("label.cancelled_params", new Object[]
+                { ((JLabel) progressPanel.getComponent(0)).getText() }),
+                id);
           }
         });
         progressPanel.add(cancel, BorderLayout.EAST);
index 6fc14b0..5f2e106 100644 (file)
@@ -1,11 +1,5 @@
 package jalview.gui;
 
-import jalview.bin.Cache;
-import jalview.bin.Console;
-import jalview.util.MessageManager;
-import jalview.ws.WSDiscovererI;
-import jalview.ws.slivkaws.SlivkaWSDiscoverer;
-
 import java.awt.BorderLayout;
 import java.awt.Color;
 import java.awt.Component;
@@ -22,6 +16,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.NoSuchElementException;
+import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletableFuture;
 
 import javax.swing.BorderFactory;
@@ -38,6 +33,11 @@ import javax.swing.UIManager;
 import javax.swing.table.AbstractTableModel;
 import javax.swing.table.DefaultTableCellRenderer;
 
+import jalview.bin.Console;
+import jalview.util.MessageManager;
+import jalview.ws2.client.api.WebServiceDiscovererI;
+import jalview.ws2.client.slivka.SlivkaWSDiscoverer;
+
 @SuppressWarnings("serial")
 public class SlivkaPreferences extends JPanel
 {
@@ -46,11 +46,11 @@ public class SlivkaPreferences extends JPanel
     setPreferredSize(new Dimension(500, 450));
   }
 
-  WSDiscovererI discoverer;
+  WebServiceDiscovererI discoverer;
 
-  private final ArrayList<String> urls = new ArrayList<>();
+  private final ArrayList<URL> urls = new ArrayList<>();
 
-  private final Map<String, Integer> statuses = new HashMap<>();
+  private final Map<URL, Integer> statuses = new HashMap<>();
 
   private final AbstractTableModel urlTableModel = new AbstractTableModel()
   {
@@ -68,9 +68,9 @@ public class SlivkaPreferences extends JPanel
       switch (columnIndex)
       {
       case 0:
-        return urls.get(rowIndex);
+        return urls.get(rowIndex).toString();
       case 1:
-        return statuses.getOrDefault(urls.get(rowIndex), WSDiscovererI.STATUS_UNKNOWN);
+        return statuses.getOrDefault(urls.get(rowIndex), WebServiceDiscovererI.STATUS_UNKNOWN);
       default:
         throw new NoSuchElementException();
       }
@@ -101,16 +101,16 @@ public class SlivkaPreferences extends JPanel
           hasFocus, row, column);
       switch ((Integer) value)
       {
-      case WSDiscovererI.STATUS_NO_SERVICES:
+      case WebServiceDiscovererI.STATUS_NO_SERVICES:
         setForeground(Color.ORANGE);
         break;
-      case WSDiscovererI.STATUS_OK:
+      case WebServiceDiscovererI.STATUS_OK:
         setForeground(Color.GREEN);
         break;
-      case WSDiscovererI.STATUS_INVALID:
+      case WebServiceDiscovererI.STATUS_INVALID:
         setForeground(Color.RED);
         break;
-      case WSDiscovererI.STATUS_UNKNOWN:
+      case WebServiceDiscovererI.STATUS_UNKNOWN:
       default:
         setForeground(Color.LIGHT_GRAY);
       }
@@ -141,7 +141,7 @@ public class SlivkaPreferences extends JPanel
   JButton moveUrlDown = new JButton(
       MessageManager.getString("action.move_down"));
 
-  private String showEditUrlDialog(String oldUrl)
+  private URL showEditUrlDialog(String oldUrl)
   {
     String input = (String) JvOptionPane
         .showInternalInputDialog(
@@ -158,7 +158,7 @@ public class SlivkaPreferences extends JPanel
     }
     try
     {
-      new URL(input);
+      return new URL(input);
     } catch (MalformedURLException ex)
     {
       JvOptionPane.showInternalMessageDialog(this,
@@ -168,18 +168,17 @@ public class SlivkaPreferences extends JPanel
           JOptionPane.WARNING_MESSAGE);
       return null;
     }
-    return input;
   }
 
   // Button Action Listeners
   private ActionListener newUrlAction = (ActionEvent e) -> {
-    final String input = showEditUrlDialog("");
+    final URL input = showEditUrlDialog("");
     if (input != null)
     {
       urls.add(input);
       reloadStatusForUrl(input);
       urlTableModel.fireTableRowsInserted(urls.size(), urls.size());
-      discoverer.setServiceUrls(urls);
+      discoverer.setUrls(urls);
     }
   };
 
@@ -187,14 +186,14 @@ public class SlivkaPreferences extends JPanel
     final int i = urlListTable.getSelectedRow();
     if (i >= 0)
     {
-      final String input = showEditUrlDialog(urls.get(i));
+      final URL input = showEditUrlDialog(urls.get(i).toString());
       if (input != null)
       {
         urls.set(i, input);
         statuses.remove(input);
         reloadStatusForUrl(input);
         urlTableModel.fireTableRowsUpdated(i, i);
-        discoverer.setServiceUrls(urls);
+        discoverer.setUrls(urls);
       }
     }
   };
@@ -206,7 +205,7 @@ public class SlivkaPreferences extends JPanel
       urls.remove(i);
       statuses.remove(i);
       urlTableModel.fireTableRowsDeleted(i, i);
-      discoverer.setServiceUrls(urls);
+      discoverer.setUrls(urls);
     }
   };
 
@@ -215,7 +214,7 @@ public class SlivkaPreferences extends JPanel
     if (i > 0)
     {
       moveTableRow(i, i - 1);
-      discoverer.setServiceUrls(urls);
+      discoverer.setUrls(urls);
     }
   };
 
@@ -224,7 +223,7 @@ public class SlivkaPreferences extends JPanel
     if (i >= 0 && i < urls.size() - 1)
     {
       moveTableRow(i, i + 1);
-      discoverer.setServiceUrls(urls);
+      discoverer.setUrls(urls);
     }
   };
 
@@ -286,7 +285,7 @@ public class SlivkaPreferences extends JPanel
 
   private void moveTableRow(int fromIndex, int toIndex)
   {
-    String url = urls.get(fromIndex);
+    URL url = urls.get(fromIndex);
     int status = statuses.get(fromIndex);
     urls.set(fromIndex, urls.get(toIndex));
     urls.set(toIndex, url);
@@ -312,13 +311,16 @@ public class SlivkaPreferences extends JPanel
   private ActionListener refreshServicesAction = (ActionEvent e) -> {
     progressBar.setVisible(true);
     Console.info("Requesting service reload");
-    discoverer.startDiscoverer().handle((_discoverer, exception) -> {
+    discoverer.startDiscoverer().handle((services, exception) -> {
       if (exception == null)
       {
         Console.info("Reloading done");
       }
-      else
+      else if (exception instanceof CancellationException)
       {
+        Console.info("Reloading cancelled");
+      }
+      else {
         Console.error("Reloading failed", exception);
       }
       SwingUtilities.invokeLater(() -> progressBar.setVisible(false));
@@ -327,11 +329,11 @@ public class SlivkaPreferences extends JPanel
   };
 
   private ActionListener resetServicesAction = (ActionEvent e) -> {
-    discoverer.setServiceUrls(null);
+    discoverer.setUrls(null);
     urls.clear();
     statuses.clear();
-    urls.addAll(discoverer.getServiceUrls());
-    for (String url : urls)
+    urls.addAll(discoverer.getUrls());
+    for (URL url : urls)
     {
       reloadStatusForUrl(url);
     }
@@ -362,16 +364,16 @@ public class SlivkaPreferences extends JPanel
   {
     // Initial URLs loading
     discoverer = SlivkaWSDiscoverer.getInstance();
-    urls.addAll(discoverer.getServiceUrls());
-    for (String url : urls)
+    urls.addAll(discoverer.getUrls());
+    for (URL url : urls)
     {
       reloadStatusForUrl(url);
     }
   }
 
-  private void reloadStatusForUrl(String url)
+  private void reloadStatusForUrl(URL url)
   {
-    CompletableFuture.supplyAsync(() -> discoverer.getServerStatusFor(url))
+    CompletableFuture.supplyAsync(() -> discoverer.getStatusForUrl(url))
         .thenAccept((status) -> {
           statuses.put(url, status);
           int row = urls.indexOf(url);
index 4fb9e22..5e4226a 100644 (file)
@@ -1428,6 +1428,12 @@ public class StructureChooser extends GStructureChooser
   }
   
   @Override
+  public void addProgressBar(long id, String message)
+  {
+    progressBar.addProgressBar(id, message);
+  }
+
+  @Override
   public void removeProgressBar(long id)
   {
     progressBar.removeProgressBar(id);
index 4c572a2..c0e8592 100644 (file)
@@ -936,6 +936,12 @@ public class WebserviceInfo extends GWebserviceInfo
   }
   
   @Override
+  public void addProgressBar(long id, String message)
+  {
+    progressBar.addProgressBar(id, message);
+  }
+
+  @Override
   public void removeProgressBar(long id)
   {
     progressBar.removeProgressBar(id);
index 80c21b8..42f2ddb 100644 (file)
@@ -173,7 +173,7 @@ public class WsJobParameters extends JPanel implements ItemListener,
     jbInit();
     this.paramStore = store;
     this.service = null;
-    init(preset, args);
+    initForService(preset, args);
     validate();
   }
 
index c05dac5..e141d07 100644 (file)
@@ -20,6 +20,8 @@
  */
 package jalview.util;
 
+import java.util.Objects;
+
 public class ArrayUtils
 {
   /**
@@ -44,4 +46,24 @@ public class ArrayUtils
       }
     }
   }
+
+  /**
+   * Return the index of the first occurrence of the item in the array or -1 if
+   * the array doesn't contain the item.
+   * 
+   * @param arr
+   *          array to be searched
+   * @param item
+   *          item to search for
+   * @return index of the first occurrence of the item or -1 if not found
+   */
+  public static int indexOf(Object[] arr, Object item)
+  {
+    for (int i = 0; i < arr.length; i++)
+    {
+      if (Objects.equals(arr[i], item))
+        return i;
+    }
+    return -1;
+  }
 }
index ecbb6e1..ae0885e 100644 (file)
@@ -39,4 +39,17 @@ public class MathUtils
     return gcd(b, a % b);
   }
 
+
+  private static int uidCounter = (int)(Math.random() * 0xffffffff);
+  /**
+   * Generates a unique 64-bit identifier.
+   */
+  public static long getUID()
+  {
+    long uid = 0L;
+    uid |= ((System.currentTimeMillis() >> 10) & 0xfffffffL) << 36;
+    uid |= (long)(Math.random() * 0xfL) << 32;
+    uid |= ++uidCounter & 0xffffffff;
+    return uid;
+  }
 }
diff --git a/src/jalview/util/Pair.java b/src/jalview/util/Pair.java
new file mode 100644 (file)
index 0000000..63cf7e9
--- /dev/null
@@ -0,0 +1,88 @@
+package jalview.util;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A generic immutable pair of values.
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ *          first value type
+ * @param <U>
+ *          second value type
+ */
+public class Pair<T, U> implements Iterable<Object>
+{
+  final T val0;
+
+  final U val1;
+
+  public Pair(T val0, U val1)
+  {
+    this.val0 = val0;
+    this.val1 = val1;
+  }
+  
+  /**
+   * Return value at the specified index cast to type R
+   * @param <R> return type
+   * @param index item index
+   * @return value at given index
+   * @throws ClassCastException value cannot be cast to R
+   * @throws IndexOutOfBoundsException index outside tuple size
+   */
+  @SuppressWarnings("unchecked")
+  public <R> R get(int index) throws ClassCastException, IndexOutOfBoundsException
+  {
+    if (index == 0)
+      return (R) val0;
+    if (index == 1)
+      return (R) val1;
+    throw new IndexOutOfBoundsException(index);
+  }
+
+  /**
+   * @return 0th value of the pair
+   */
+  public T get0()
+  {
+    return val0;
+  }
+
+  /**
+   * @return 1st value of the pair
+   */
+  public U get1()
+  {
+    return val1;
+  }
+  
+  /**
+   * @return tuple size
+   */
+  public int size()
+  {
+    return 2;
+  }
+
+  @Override
+  public boolean equals(Object obj)
+  {
+    if (obj instanceof Pair)
+    {
+      Pair<?, ?> other = (Pair<?, ?>) obj;
+      return Objects.equals(val0, other.val0) &&
+          Objects.equals(val1, other.val1);
+    }
+    return false;
+  }
+
+  @Override
+  public Iterator<Object> iterator()
+  {
+    return List.of(val0, val1).iterator();
+  }
+}
index 9991421..0e3bb9e 100644 (file)
@@ -74,6 +74,9 @@ import java.util.Hashtable;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
 /**
  * base class holding visualization and analysis attributes and common logic for
@@ -995,6 +998,20 @@ public abstract class AlignmentViewport
     return false;
   }
 
+  private ScheduledExecutorService serviceExecutor = Executors.newSingleThreadScheduledExecutor();
+
+  /**
+   * Get a default scheduled executor service which can be used by
+   * services and calculators to run parallel jobs associated with this
+   * viewport.
+   * 
+   * @return default service executor of that viewport
+   */
+  public ScheduledExecutorService getServiceExecutor()
+  {
+    return serviceExecutor;
+  }
+
   public void setAlignment(AlignmentI align)
   {
     this.alignment = align;
@@ -1024,6 +1041,8 @@ public abstract class AlignmentViewport
     gapcounts = null;
     calculator.shutdown();
     calculator = null;
+    serviceExecutor.shutdown();
+    serviceExecutor = null;
     residueShading = null; // may hold a reference to Consensus
     changeSupport = null;
     ranges = null;
index eba241a..39111ae 100644 (file)
@@ -107,23 +107,19 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
                 "Cannot submit new task if the prevoius one is still running");
       }
       Console.debug(
-              format("Worker %s queued", getWorker().getClass().getName()));
+              format("Worker %s queued", getWorker()));
       task = executor.submit(() -> {
         try
         {
-          Console.debug(format("Worker %s started",
-                  getWorker().getClass().getName()));
+          Console.debug(format("Worker %s started", getWorker()));
           getWorker().run();
-          Console.debug(format("Worker %s finished",
-                  getWorker().getClass().getName()));
+          Console.debug(format("Worker %s finished", getWorker()));
         } catch (InterruptedException e)
         {
-          Console.debug(format("Worker %s interrupted",
-                  getWorker().getClass().getName()));
+          Console.debug(format("Worker %s interrupted", getWorker()));
         } catch (Throwable th)
         {
-          Console.debug(format("Worker %s failed",
-                  getWorker().getClass().getName()), th);
+          Console.debug(format("Worker %s failed", getWorker()), th);
         } finally
         {
           if (!isRegistered())
@@ -142,8 +138,7 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
       {
         return;
       }
-      Console.debug(format("Cancelling worker %s",
-              getWorker().getClass().getName()));
+      Console.debug(format("Cancelling worker %s", getWorker()));
       task.cancel(true);
     }
   }
@@ -174,10 +169,9 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
       if (task != null && !(task.isDone() || task.isCancelled()))
       {
         throw new IllegalStateException(
-                "Cannot submit new task if the prevoius one is still running");
+                "Cannot submit new task if the previous one is still running");
       }
-      Console.debug(
-              format("Worker %s queued", getWorker().getClass().getName()));
+      Console.debug( format("Worker %s queued", getWorker()));
       final var runnable = new Runnable()
       {
         private boolean started = false;
@@ -193,26 +187,22 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
           {
             if (!started)
             {
-              Console.debug(format("Worker %s started",
-                      getWorker().getClass().getName()));
+              Console.debug(format("Worker %s started", getWorker()));
               getWorker().startUp();
               started = true;
             }
             else if (!completed)
             {
-              Console.debug(format("Polling worker %s",
-                      getWorker().getClass().getName()));
+              Console.debug(format("Polling worker %s", getWorker()));
               if (getWorker().poll())
               {
-                Console.debug(format("Worker %s finished",
-                        getWorker().getClass().getName()));
+                Console.debug(format("Worker %s finished", getWorker()));
                 completed = true;
               }
             }
           } catch (Throwable th)
           {
-            Console.debug(format("Worker %s failed",
-                    getWorker().getClass().getName()), th);
+            Console.debug(format("Worker %s failed", getWorker()), th);
             completed = true;
           }
           if (completed)
@@ -220,8 +210,7 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
             final var worker = getWorker();
             if (!isRegistered())
               PollableWorkerManager.super.worker = null;
-            Console.debug(format("Finalizing completed worker %s",
-                    worker.getClass().getName()));
+            Console.debug(format("Finalizing completed worker %s", worker));
             worker.done();
             // almost impossible, but the future may be null at this point
             // let it throw NPE to cancel forcefully
@@ -239,8 +228,7 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
       {
         return;
       }
-      Console.debug(format("Cancelling worker %s",
-              getWorker().getClass().getName()));
+      Console.debug(format("Cancelling worker %s", getWorker()));
       task.cancel(false);
       executor.submit(() -> {
         final var worker = getWorker();
@@ -249,8 +237,7 @@ public class AlignCalcManager2 implements AlignCalcManagerI2
         if (worker != null)
         {
           worker.cancel();
-          Console.debug(format("Finalizing cancelled worker %s",
-                  worker.getClass().getName()));
+          Console.debug(format("Finalizing cancelled worker %s", worker));
           worker.done();
         }
       });
index 8d28ee5..28d16d1 100644 (file)
@@ -30,8 +30,28 @@ public interface ParamDatastoreI
 
   public WsParamSetI getPreset(String name);
 
+  /**
+   * Returns if the service has presets.
+   * @return {@code true} if service has presets
+   */
+  public default boolean hasPresets()
+  {
+    var presets = getPresets();
+    return presets != null && presets.size() > 0;
+  }
+
   public List<ArgumentI> getServiceParameters();
 
+  /**
+   * Returns if the service has parameters.
+   * @return {@code true} if service has parameters
+   */
+  public default boolean hasParameters()
+  {
+    var parameters = getServiceParameters();
+    return parameters != null && parameters.size() > 0;
+  }
+
   public boolean presetExists(String name);
 
   public void deletePreset(String name);
index df17296..cd492cf 100644 (file)
@@ -22,9 +22,62 @@ package jalview.ws.params.simple;
 
 import java.net.URL;
 import java.util.Arrays;
+import java.util.List;
+
+import static java.util.Objects.requireNonNullElse;
 
 public class BooleanOption extends Option
 {
+  public static class Builder extends Option.Builder
+  {
+    private boolean defaultValue = false;
+
+    private boolean value = false;
+
+    private String reprValue = null;
+
+    public void setDefaultValue(Boolean defaultValue)
+    {
+      this.defaultValue = requireNonNullElse(defaultValue, false);
+    }
+
+    public void setValue(Boolean value)
+    {
+      this.value = requireNonNullElse(value, false);
+    }
+
+    public void setReprValue(String reprValue)
+    {
+      this.reprValue = reprValue;
+    }
+
+    @Override
+    public void setPossibleValues(List<String> possibleValues)
+    {
+      throw new UnsupportedOperationException("cannot set possible values for boolean");
+    }
+
+    @Override
+    public BooleanOption build()
+    {
+      return new BooleanOption(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  protected BooleanOption(Builder builder)
+  {
+    super(builder);
+    String reprValue = requireNonNullElse(builder.reprValue, name);
+    defvalue = builder.defaultValue ? reprValue : null;
+    value = builder.value ? reprValue : null;
+    possibleVals = List.of(reprValue);
+    displayVals = List.of(label);
+  }
 
   public BooleanOption(String name, String descr, boolean required,
       Boolean defVal, Boolean val, URL link)
index 6b76170..68029a3 100644 (file)
@@ -2,6 +2,7 @@ package jalview.ws.params.simple;
 
 import jalview.ws.params.ParameterI;
 import jalview.ws.params.ValueConstrainI;
+import static java.util.Objects.requireNonNullElse;
 
 /**
  * 
@@ -10,6 +11,76 @@ import jalview.ws.params.ValueConstrainI;
  */
 public class DoubleParameter extends Option implements ParameterI
 {
+  public static class Builder extends Option.Builder
+  {
+    // setting them the opposite way disables limits until both are set.
+    protected double min = Double.POSITIVE_INFINITY;
+
+    protected double max = Double.NEGATIVE_INFINITY;
+
+    /**
+     * Setting string on double parameter is not allowed, use
+     * {@link #setValue(Double)} instead.
+     */
+    @Override
+    public void setValue(String value)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setValue(Double value)
+    {
+      if (value != null)
+        super.setValue(value.toString());
+      else
+        super.setValue(null);
+    }
+
+    /**
+     * Setting string on double parameter is not allowed, use
+     * {@link #setDefaultValue(Double)} instead.
+     */
+    @Override
+    public void setDefaultValue(String defaultValue)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setDefaultValue(Double defaultValue)
+    {
+      if (defaultValue != null)
+        super.setDefaultValue(defaultValue.toString());
+      else
+        super.setDefaultValue(null);
+    }
+
+    public void setMin(Double min)
+    {
+      this.min = requireNonNullElse(min, Double.POSITIVE_INFINITY);
+    }
+
+    public void setMax(Double max)
+    {
+      this.max = requireNonNullElse(max, Double.NEGATIVE_INFINITY);
+    }
+
+    public void setBounds(Double min, Double max)
+    {
+      setMin(min);
+      setMax(max);
+    }
+
+    public DoubleParameter build()
+    {
+      return new DoubleParameter(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
   double defval;
 
   double min;
@@ -41,6 +112,15 @@ public class DoubleParameter extends Option implements ParameterI
     };
   }
 
+  protected DoubleParameter(Builder builder)
+  {
+    super(builder);
+    this.min = builder.min;
+    this.max = builder.max;
+    if (defvalue != null)
+      defval = Double.parseDouble(defvalue);
+  }
+
   public DoubleParameter(DoubleParameter parm)
   {
     super(parm);
@@ -49,20 +129,20 @@ public class DoubleParameter extends Option implements ParameterI
   }
 
   public DoubleParameter(String name, String description, boolean required,
-          Double defValue, double min, double max)
+      Double defValue, double min, double max)
   {
     super(name, description, required, String.valueOf(defValue), null, null,
-            null);
+        null);
     defval = defValue;
     this.min = min;
     this.max = max;
   }
 
   public DoubleParameter(String name, String description, boolean required,
-          Double defValue, Double value, double min, double max)
+      Double defValue, Double value, double min, double max)
   {
     super(name, description, required, String.valueOf(defValue),
-            String.valueOf(value), null, null);
+        String.valueOf(value), null, null);
     defval = defValue;
     this.min = min;
     this.max = max;
index aa8e7ad..3e17e68 100644 (file)
@@ -11,9 +11,27 @@ import jalview.ws.params.ValueConstrainI;
  */
 public class FileParameter extends StringParameter
 {
+  public static class Builder extends StringParameter.Builder
+  {
+    @Override
+    public FileParameter build()
+    {
+      return new FileParameter(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  public FileParameter(Builder builder)
+  {
+    super(builder);
+  }
 
   public FileParameter(String name, String description, boolean required,
-          String defValue, String value)
+      String defValue, String value)
   {
     super(name, description, required, defValue, value);
   }
index b3a01b2..af6c881 100644 (file)
@@ -22,6 +22,7 @@ package jalview.ws.params.simple;
 
 import jalview.ws.params.ParameterI;
 import jalview.ws.params.ValueConstrainI;
+import static java.util.Objects.requireNonNullElse;
 
 /**
  * @author jimp
@@ -29,6 +30,68 @@ import jalview.ws.params.ValueConstrainI;
  */
 public class IntegerParameter extends Option implements ParameterI
 {
+  public static class Builder extends Option.Builder
+  {
+    // assigning them the opposite way results in no limits unless both are set
+    protected int min = Integer.MAX_VALUE;
+
+    protected int max = Integer.MIN_VALUE;
+
+    @Override
+    public void setDefaultValue(String defaultValue)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setDefaultValue(Integer defaultValue)
+    {
+      if (defaultValue != null)
+        super.setDefaultValue(defaultValue.toString());
+      else
+        super.setDefaultValue(null);
+    }
+
+    @Override
+    public void setValue(String value)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setValue(Integer value)
+    {
+      if (value != null)
+        super.setValue(value.toString());
+      else
+        super.setValue(null);
+    }
+
+    public void setMin(Integer min)
+    {
+      this.min = requireNonNullElse(min, Integer.MAX_VALUE);
+    }
+
+    public void setMax(Integer max)
+    {
+      this.max = requireNonNullElse(max, Integer.MIN_VALUE);
+    }
+
+    public void setBounds(Integer min, Integer max)
+    {
+      setMin(min);
+      setMax(max);
+    }
+
+    public IntegerParameter build()
+    {
+      return new IntegerParameter(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
   int defval;
 
   int min;
@@ -61,6 +124,15 @@ public class IntegerParameter extends Option implements ParameterI
     };
   }
 
+  protected IntegerParameter(Builder builder)
+  {
+    super(builder);
+    min = builder.min;
+    max = builder.max;
+    if (defvalue != null)
+      defval = Integer.parseInt(defvalue);
+  }
+
   public IntegerParameter(IntegerParameter parm)
   {
     super(parm);
@@ -69,20 +141,20 @@ public class IntegerParameter extends Option implements ParameterI
   }
 
   public IntegerParameter(String name, String description, boolean required,
-          int defValue, int min, int max)
+      int defValue, int min, int max)
   {
     super(name, description, required, String.valueOf(defValue), null, null,
-            null);
+        null);
     defval = defValue;
     this.min = min;
     this.max = max;
   }
 
   public IntegerParameter(String name, String description, boolean required,
-          int defValue, int value, int min, int max)
+      int defValue, int value, int min, int max)
   {
     super(name, description, required, String.valueOf(defValue),
-            String.valueOf(value), null, null);
+        String.valueOf(value), null, null);
     defval = defValue;
     this.min = min;
     this.max = max;
index af80181..12a7be1 100644 (file)
@@ -1,5 +1,7 @@
 package jalview.ws.params.simple;
 
+import static java.util.Objects.requireNonNullElse;
+
 import jalview.ws.params.ParameterI;
 import jalview.ws.params.ValueConstrainI;
 
@@ -11,6 +13,72 @@ import jalview.ws.params.ValueConstrainI;
  */
 public class LogarithmicParameter extends Option implements ParameterI
 {
+  public static class Builder extends Option.Builder
+  {
+    // setting them the opposite way disables limits until both are set.
+    protected double min = Double.POSITIVE_INFINITY;
+
+    protected double max = Double.NEGATIVE_INFINITY;
+
+    /**
+     * Setting string on double parameter is not allowed, use
+     * {@link #setValue(Double)} instead.
+     */
+    @Override
+    public void setValue(String value)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setValue(Double value)
+    {
+      if (value != null)
+        super.setValue(value.toString());
+      else
+        super.setValue(null);
+    }
+
+    /**
+     * Setting string on double parameter is not allowed, use
+     * {@link #setDefaultValue(Double)} instead.
+     */
+    @Override
+    public void setDefaultValue(String defaultValue)
+    {
+      throw new UnsupportedOperationException();
+    }
+
+    public void setDefaultValue(Double defaultValue)
+    {
+      if (defaultValue != null)
+        super.setDefaultValue(defaultValue.toString());
+      else
+        super.setDefaultValue(null);
+    }
+
+    public void setMin(Double min)
+    {
+      this.min = requireNonNullElse(min, Double.POSITIVE_INFINITY);
+    }
+
+    public void setMax(Double max)
+    {
+      this.max = requireNonNullElse(max, Double.NEGATIVE_INFINITY);
+    }
+
+    public void setBounds(Double min, Double max)
+    {
+      setMin(min);
+      setMax(max);
+    }
+
+    @Override
+    public LogarithmicParameter build()
+    {
+      return new LogarithmicParameter(this);
+    }
+  }
+
   final double defval;
 
   final double min;
@@ -43,6 +111,22 @@ public class LogarithmicParameter extends Option implements ParameterI
     };
   }
 
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  public LogarithmicParameter(Builder builder)
+  {
+    super(builder);
+    this.min = builder.min;
+    this.max = builder.max;
+    if (defvalue != null)
+      defval = Double.parseDouble(defvalue);
+    else
+      defval = 0.0;
+  }
+
   public LogarithmicParameter(LogarithmicParameter parm)
   {
     super(parm);
@@ -52,21 +136,21 @@ public class LogarithmicParameter extends Option implements ParameterI
   }
 
   public LogarithmicParameter(String name, String description,
-          boolean required, Double defValue, double min, double max)
+      boolean required, Double defValue, double min, double max)
   {
     super(name, description, required, String.valueOf(defValue), null, null,
-            null);
+        null);
     defval = defValue;
     this.min = min;
     this.max = max;
   }
 
   public LogarithmicParameter(String name, String description,
-          boolean required, Double defValue, double value, double min,
-          double max)
+      boolean required, Double defValue, double value, double min,
+      double max)
   {
     super(name, description, required, String.valueOf(defValue),
-            String.valueOf(value), null, null);
+        String.valueOf(value), null, null);
     defval = defValue;
     this.min = min;
     this.max = max;
index 40e3804..44f9f5d 100644 (file)
@@ -25,9 +25,93 @@ import jalview.ws.params.OptionI;
 import java.net.URL;
 import java.util.ArrayList;
 import java.util.List;
+import static java.util.Objects.requireNonNull;
+import static java.util.Objects.requireNonNullElse;
 
 public class Option implements OptionI
 {
+  /**
+   * A builder class which avoids multiple telescoping parameters nightmare.
+   * 
+   * @author mmwarowny
+   *
+   */
+  public static class Builder
+  {
+    protected String name = null;
+
+    protected String label = null;
+
+    protected String description = "";
+
+    protected boolean required = false;
+
+    protected String defaultValue = null;
+
+    protected String value = null;
+
+    protected List<String> possibleValues = null;
+
+    protected List<String> displayValues = null;
+
+    protected URL detailsUrl = null;
+
+    public void setName(String name)
+    {
+      this.name = name;
+    }
+
+    public void setLabel(String label)
+    {
+      this.label = label;
+    }
+
+    public void setDescription(String description)
+    {
+      this.description = description;
+    }
+
+    public void setRequired(boolean required)
+    {
+      this.required = required;
+    }
+
+    public void setDefaultValue(String defaultValue)
+    {
+      this.defaultValue = defaultValue;
+    }
+
+    public void setValue(String value)
+    {
+      this.value = value;
+    }
+
+    public void setPossibleValues(List<String> possibleValues)
+    {
+      this.possibleValues = possibleValues;
+    }
+
+    public void setDisplayValues(List<String> displayValues)
+    {
+      this.displayValues = displayValues;
+    }
+
+    public void setDetailsUrl(URL detailsUrl)
+    {
+      this.detailsUrl = detailsUrl;
+    }
+
+    public Option build()
+    {
+      return new Option(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
   String name;
 
   String label;
@@ -55,6 +139,30 @@ public class Option implements OptionI
 
   URL fdetails;
 
+  protected Option(Builder builder)
+  {
+    requireNonNull(builder.name);
+    name = builder.name;
+    label = requireNonNullElse(builder.label, name);
+    description = builder.description;
+    required = builder.required;
+    defvalue = builder.defaultValue;
+    value = builder.value;
+    if (builder.possibleValues != null)
+      possibleVals = new ArrayList<>(builder.possibleValues);
+    if (builder.displayValues != null)
+      displayVals = new ArrayList<>(builder.displayValues);
+    else
+      displayVals = possibleVals;
+    if (possibleVals == null && displayVals != null)
+      throw new IllegalArgumentException(
+          "cannot use displayValues if possibleValues is null");
+    if (possibleVals != null && possibleVals.size() != displayVals.size())
+      throw new IllegalArgumentException(
+          "displayValues size does not match possibleValues");
+    fdetails = builder.detailsUrl;
+  }
+
   /**
    * Copy constructor
    * 
@@ -99,8 +207,8 @@ public class Option implements OptionI
    * @param fdetails
    */
   public Option(String name2, String description2, boolean isrequired,
-          String defValue, String val, List<String> possibleVals,
-          List<String> displayNames, URL fdetails)
+      String defValue, String val, List<String> possibleVals,
+      List<String> displayNames, URL fdetails)
   {
     name = name2;
     description = description2;
@@ -130,11 +238,11 @@ public class Option implements OptionI
    * @param fdetails
    */
   public Option(String name2, String description2, boolean isrequired,
-          String defValue, String val, List<String> possibleVals,
-          URL fdetails)
+      String defValue, String val, List<String> possibleVals,
+      URL fdetails)
   {
     this(name2, description2, isrequired, defValue, val, possibleVals, null,
-            fdetails);
+        fdetails);
   }
 
   @Override
index 4fdb05e..9567858 100644 (file)
@@ -28,6 +28,24 @@ import java.util.List;
  */
 public class RadioChoiceParameter extends StringParameter
 {
+  public static class Builder extends StringParameter.Builder
+  {
+    @Override
+    public RadioChoiceParameter build()
+    {
+      return new RadioChoiceParameter(this);
+    }
+  }
+
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  public RadioChoiceParameter(Builder builder)
+  {
+    super(builder);
+  }
 
   /**
    * Constructor
@@ -38,7 +56,7 @@ public class RadioChoiceParameter extends StringParameter
    * @param def
    */
   public RadioChoiceParameter(String name, String description,
-          List<String> options, String def)
+      List<String> options, String def)
   {
     super(name, description, true, def, def, options, null);
   }
index d3d899c..ad3834a 100644 (file)
@@ -7,6 +7,15 @@ import java.util.List;
 
 public class StringParameter extends Option implements ParameterI
 {
+  public static class Builder extends Option.Builder
+  {
+    @Override
+    public StringParameter build()
+    {
+      return new StringParameter(this);
+    }
+  }
+
   @Override
   public ValueConstrainI getValidValue()
   {
@@ -42,6 +51,16 @@ public class StringParameter extends Option implements ParameterI
 
   }
 
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  protected StringParameter(Builder builder)
+  {
+    super(builder);
+  }
+
   public StringParameter(StringParameter parm)
   {
     this.name = parm.name;
@@ -51,18 +70,18 @@ public class StringParameter extends Option implements ParameterI
   }
 
   public StringParameter(String name, String description, boolean required,
-          String defValue)
+      String defValue)
   {
     super(name, description, required, String.valueOf(defValue), null, null,
-            null);
+        null);
     this.defvalue = defValue;
   }
 
   public StringParameter(String name, String description, boolean required,
-          String defValue, String value)
+      String defValue, String value)
   {
     super(name, description, required, String.valueOf(defValue),
-            String.valueOf(value), null, null);
+        String.valueOf(value), null, null);
     this.defvalue = defValue;
   }
 
@@ -79,10 +98,10 @@ public class StringParameter extends Option implements ParameterI
    * @param displayNames
    */
   public StringParameter(String name2, String description2,
-          boolean isrequired, String defValue, String value,
-          List<String> possibleVals, List<String> displayNames)
+      boolean isrequired, String defValue, String value,
+      List<String> possibleVals, List<String> displayNames)
   {
     super(name2, description2, isrequired, defValue, value, possibleVals,
-            displayNames, null);
+        displayNames, null);
   }
 }
diff --git a/src/jalview/ws2/actions/AbstractPollableTask.java b/src/jalview/ws2/actions/AbstractPollableTask.java
new file mode 100644 (file)
index 0000000..fc3c554
--- /dev/null
@@ -0,0 +1,351 @@
+package jalview.ws2.actions;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import jalview.bin.Cache;
+import jalview.util.ArrayUtils;
+import jalview.util.MathUtils;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.WebServiceClientI;
+import jalview.ws2.helpers.DelegateJobEventListener;
+import jalview.ws2.helpers.TaskEventSupport;
+import static java.lang.String.format;
+
+/**
+ * An abstract base class for non-interactive tasks which implements common
+ * tasks methods. Additionally, it manages task execution in a polling loop.
+ * Subclasses are only required to implement {@link #prepare()} and
+ * {@link #done()} methods.
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ *          the type of jobs managed by the task
+ * @param <R>
+ *          the type of result provided by the task
+ */
+public abstract class AbstractPollableTask<T extends BaseJob, R> implements TaskI<R>
+{
+  private final long uid = MathUtils.getUID();
+
+  protected final WebServiceClientI client;
+
+  protected final List<ArgumentI> args;
+
+  protected final Credentials credentials;
+
+  private final TaskEventSupport<R> eventHandler;
+
+  protected JobStatus taskStatus = null;
+
+  private Future<?> future = null;
+
+  protected List<T> jobs = Collections.emptyList();
+
+  protected R result;
+
+  protected AbstractPollableTask(WebServiceClientI client, List<ArgumentI> args,
+      Credentials credentials, TaskEventListener<R> eventListener)
+  {
+    this.client = client;
+    this.args = args;
+    this.credentials = credentials;
+    this.eventHandler = new TaskEventSupport<R>(this, eventListener);
+  }
+
+  public long getUid()
+  {
+    return uid;
+  }
+
+  /**
+   * Start the task using provided scheduled executor service. It creates a
+   * polling loop running at set intervals.
+   * 
+   * @param executor
+   *          executor to run the polling loop with
+   */
+  public void start(ScheduledExecutorService executor)
+  {
+    if (future != null)
+      throw new IllegalStateException("task already started");
+    var runnable = new Runnable()
+    {
+      private int stage = STAGE_PREPARE;
+
+      private static final int STAGE_PREPARE = 0;
+
+      private static final int STAGE_START = 1;
+
+      private static final int STAGE_POLL = 2;
+
+      private static final int STAGE_FINALIZE = 3;
+
+      private static final int STAGE_DONE = 4;
+
+      private int retryCount = 0;
+
+      private static final int MAX_RETRY = 5;
+
+      /**
+       * A polling loop run periodically which carries the task through its
+       * consecutive execution stages.
+       */
+      @Override
+      public void run()
+      {
+        if (stage == STAGE_PREPARE)
+        {
+          // first stage - the input data is collected and the jobs are created
+          try
+          {
+            jobs = prepare();
+          } catch (ServiceInputInvalidException e)
+          {
+            stage = STAGE_DONE;
+            setStatus(JobStatus.INVALID);
+            eventHandler.fireTaskException(e);
+            throw new CompletionException(e);
+          }
+          stage = STAGE_START;
+          setStatus(JobStatus.READY);
+          eventHandler.fireTaskStarted(jobs);
+          var jobListener = new DelegateJobEventListener<>(eventHandler);
+          for (var job : jobs)
+          {
+            job.addPropertyChagneListener(jobListener);
+          }
+        }
+        try
+        {
+          if (stage == STAGE_START)
+          {
+            // second stage - jobs are submitted to the server
+            startJobs();
+            stage = STAGE_POLL;
+            setStatus(JobStatus.SUBMITTED);
+          }
+          if (stage == STAGE_POLL)
+          {
+            // third stage - jobs are poolled until all of them are completed
+            if (pollJobs())
+            {
+              stage = STAGE_FINALIZE;
+            }
+            updateGlobalStatus();
+          }
+          if (stage == STAGE_FINALIZE)
+          {
+            // final stage - results are collected and stored
+            result = done();
+            eventHandler.fireTaskCompleted(result);
+            stage = STAGE_DONE;
+          }
+          retryCount = 0;
+        } catch (IOException e)
+        {
+          eventHandler.fireTaskException(e);
+          if (++retryCount > MAX_RETRY)
+          {
+            stage = STAGE_DONE;
+            cancelJobs();
+            setStatus(JobStatus.SERVER_ERROR);
+            throw new CompletionException(e);
+          }
+        }
+        if (stage == STAGE_DONE)
+        {
+          // finalization - terminating the future task
+          throw new CancellationException("task terminated");
+        }
+      }
+    };
+    if (taskStatus != JobStatus.CANCELLED)
+      future = executor.scheduleWithFixedDelay(runnable, 0, 2, TimeUnit.SECONDS);
+  }
+
+  @Override
+  public JobStatus getStatus()
+  {
+    return taskStatus;
+  }
+
+  /**
+   * Set the status of the task and notify the event handler.
+   * 
+   * @param status
+   *          new task status
+   */
+  protected void setStatus(JobStatus status)
+  {
+    if (this.taskStatus != status)
+    {
+      this.taskStatus = status;
+      eventHandler.fireTaskStatusChanged(status);
+    }
+  }
+
+  /**
+   * Update task status according to the overall status of its jobs. The rules
+   * of setting the status are following:
+   * <ul>
+   * <li>task is invalid if all jobs are invalid</li>
+   * <li>task is completed if all but invalid jobs are completed</li>
+   * <li>task is ready, submitted or queued if at least one job is ready,
+   * submitted or queued an none proceeded to the next stage excluding
+   * completed.</li>
+   * <li>task is running if at least one job is running and none are failed or
+   * cancelled</li>
+   * <li>task is cancelled if at least one job is cancelled and none failed</li>
+   * <li>task is failed or server error if at least one job is failed or server
+   * error</li>
+   * </ul>
+   */
+  private void updateGlobalStatus()
+  {
+    int precedence = -1;
+    for (BaseJob job : jobs)
+    {
+      JobStatus status = job.getStatus();
+      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
+      if (precedence < jobPrecedence)
+        precedence = jobPrecedence;
+    }
+    if (precedence >= 0)
+    {
+      setStatus(JobStatus.statusPrecedence[precedence]);
+    }
+  }
+
+  @Override
+  public void cancel()
+  {
+    setStatus(JobStatus.CANCELLED);
+    if (future != null)
+      future.cancel(false);
+    cancelJobs();
+  }
+
+  @Override
+  public List<? extends BaseJob> getSubJobs()
+  {
+    return jobs;
+  }
+
+  /**
+   * Collect and process input sequences for submission and return the list of
+   * jobs to be submitted.
+   * 
+   * @return list of jobs to be submitted
+   * @throws ServiceInputInvalidException
+   *           input is invalid and the task should not be started
+   */
+  protected abstract List<T> prepare() throws ServiceInputInvalidException;
+
+  /**
+   * Submit all valid jobs to the server and store their job handles.
+   * 
+   * @throws IOException
+   *           if server error occurred
+   */
+  protected void startJobs() throws IOException
+  {
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
+      {
+        WebServiceJobHandle serverJob = client.submit(job.getInputSequences(),
+            args, credentials);
+        job.setServerJob(serverJob);
+        job.setStatus(JobStatus.SUBMITTED);
+      }
+    }
+  }
+
+  /**
+   * Poll all running jobs and update their status and logs. Polling is repeated
+   * periodically until this method return true when all jobs are done.
+   * 
+   * @return {@code true] if all jobs are done @throws IOException if server
+   *         error occurred
+   */
+  protected boolean pollJobs() throws IOException
+  {
+    boolean allDone = true;
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && !job.getStatus().isDone())
+      {
+        WebServiceJobHandle serverJob = job.getServerJob();
+        job.setStatus(client.getStatus(serverJob));
+        job.setLog(client.getLog(serverJob));
+        job.setErrorLog(client.getErrorLog(serverJob));
+      }
+      allDone &= job.isCompleted();
+    }
+    return allDone;
+  }
+
+  /**
+   * Fetch and process the outputs produced by jobs and return the final result
+   * of the task. The method is called once all jobs have finished execution. If
+   * this method raises {@link IOException} it will be called again after a
+   * delay. All IO operations should happen before data processing, so
+   * potentially expensive computation is avoided in case of an error.
+   * 
+   * @return final result of the computation
+   * @throws IOException
+   *           if server error occurred
+   */
+  protected abstract R done() throws IOException;
+
+  /**
+   * Cancel all running jobs. Used in case of task failure to cleanup the
+   * resources or when the task has been cancelled.
+   */
+  protected void cancelJobs()
+  {
+    for (BaseJob job : jobs)
+    {
+      if (!job.isCompleted())
+      {
+        try
+        {
+          if (job.getServerJob() != null)
+          {
+            client.cancel(job.getServerJob());
+          }
+          job.setStatus(JobStatus.CANCELLED);
+        } catch (IOException e)
+        {
+          Cache.log.error(format("failed to cancel job %s", job.getServerJob()), e);
+        }
+      }
+    }
+  }
+
+  @Override
+  public R getResult()
+  {
+    return result;
+  }
+
+  @Override
+  public String toString()
+  {
+    var status = taskStatus != null ? taskStatus.name() : "UNSET";
+    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
+  }
+}
diff --git a/src/jalview/ws2/actions/BaseAction.java b/src/jalview/ws2/actions/BaseAction.java
new file mode 100644 (file)
index 0000000..6e92f4c
--- /dev/null
@@ -0,0 +1,196 @@
+package jalview.ws2.actions;
+
+import java.util.EnumSet;
+import java.util.Objects;
+
+import jalview.ws2.actions.api.ActionI;
+import jalview.ws2.api.CredentialType;
+import jalview.ws2.api.WebService;
+
+/**
+ * An abstract base class storing common data and implementing their getters
+ * defined in {@link ActionI} interface. The concrete action implementations are
+ * encouraged to extend this class and provide their own {@code perform} and
+ * {@code isActive} implementations.
+ * 
+ * @author mmwarowny
+ * @param <R>
+ *          task result type
+ */
+public abstract class BaseAction<R> implements ActionI<R>
+{
+  public static abstract class Builder<A extends BaseAction<?>>
+  {
+    protected WebService<A> webService;
+
+    protected String name = null;
+
+    protected String tooltip = "";
+
+    protected String subcategory = null;
+
+    protected int minSequences = -1;
+
+    protected int maxSequences = -1;
+
+    protected boolean allowProtein = true;
+
+    protected boolean allowNucleotide = true;
+
+    protected EnumSet<CredentialType> requiredCredentials = EnumSet.noneOf(CredentialType.class);
+
+    public Builder()
+    {
+    }
+
+    public void name(String val)
+    {
+      this.name = val;
+    }
+
+    public void webService(WebService<A> val)
+    {
+      this.webService = val;
+    }
+
+    public void tooltip(String val)
+    {
+      tooltip = val;
+    }
+
+    public void subcategory(String val)
+    {
+      subcategory = val;
+    }
+
+    public void minSequences(int val)
+    {
+      minSequences = val;
+    }
+
+    public void maxSequecnes(int val)
+    {
+      maxSequences = val;
+    }
+
+    public void allowProtein(boolean val)
+    {
+      allowProtein = val;
+    }
+
+    public void allowNucleotide(boolean val)
+    {
+      allowNucleotide = val;
+    }
+
+    public void addRequiredCredential(CredentialType val)
+    {
+      requiredCredentials.add(val);
+    }
+
+    public void requiredCredentials(EnumSet<CredentialType> val)
+    {
+      requiredCredentials = val;
+    }
+  }
+
+  protected final WebService<? extends ActionI<R>> webService;
+
+  protected final String name;
+
+  protected final String tooltip;
+
+  protected final String subcategory;
+
+  protected final int minSequences;
+
+  protected final int maxSequences;
+
+  protected final boolean allowProtein;
+
+  protected final boolean allowNucleotide;
+
+  protected final EnumSet<CredentialType> requiredCredentials;
+
+  protected BaseAction(Builder<? extends BaseAction<R>> builder)
+  {
+    Objects.requireNonNull(builder.webService);
+    this.webService = builder.webService;
+    this.name = builder.name;
+    this.tooltip = builder.tooltip;
+    this.subcategory = builder.subcategory;
+    this.minSequences = builder.minSequences;
+    this.maxSequences = builder.maxSequences;
+    this.allowProtein = builder.allowProtein;
+    this.allowNucleotide = builder.allowNucleotide;
+    this.requiredCredentials = builder.requiredCredentials;
+  }
+
+  @Override
+  public WebService<? extends ActionI<R>> getWebService()
+  {
+    return webService;
+  }
+
+  @Override
+  public String getName()
+  {
+    return name;
+  }
+
+  /**
+   * Returns a full name of the action which comprises of the service name and
+   * the action name if present.
+   * 
+   * @return full name of this action
+   */
+  public String getFullName()
+  {
+    if (name == null || name.isEmpty())
+      return webService.getName();
+    else
+      return webService.getName() + " " + name;
+  }
+
+  @Override
+  public String getTooltip()
+  {
+    return tooltip;
+  }
+
+  @Override
+  public String getSubcategory()
+  {
+    return subcategory;
+  }
+
+  @Override
+  public int getMinSequences()
+  {
+    return minSequences;
+  }
+
+  @Override
+  public int getMaxSequences()
+  {
+    return maxSequences;
+  }
+
+  @Override
+  public boolean doAllowProtein()
+  {
+    return allowProtein;
+  }
+
+  @Override
+  public boolean doAllowNucleotide()
+  {
+    return allowNucleotide;
+  }
+
+  @Override
+  public EnumSet<CredentialType> getRequiredCredentials()
+  {
+    return requiredCredentials;
+  }
+}
diff --git a/src/jalview/ws2/actions/BaseJob.java b/src/jalview/ws2/actions/BaseJob.java
new file mode 100644 (file)
index 0000000..945c7b0
--- /dev/null
@@ -0,0 +1,193 @@
+package jalview.ws2.actions;
+
+import java.beans.PropertyChangeListener;
+import java.beans.PropertyChangeSupport;
+import java.util.List;
+
+import jalview.datamodel.SequenceI;
+import jalview.util.MathUtils;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+
+/**
+ * Basic implementation of the {@link JobI} interface which stores internal job
+ * id, status, log and error log and provides getters to those fields.
+ * Additionally, it stores sequences that will be submitted as job input and the
+ * handle to the job on the server. Extending classes can add extra fields in
+ * order to associate additional data with the job.
+ * 
+ * Observers can be registered with this bean-like object to listen to changes
+ * to {@code status}, {@code log}, and {@code errorLog} properties. Typically,
+ * the events are delegated to the {@link TaskEventListener} objects observing
+ * the task that created this job.
+ * 
+ * @author mmwarowny
+ */
+public abstract class BaseJob implements JobI
+{
+  protected final long internalId = MathUtils.getUID();
+
+  protected final List<SequenceI> inputSeqs;
+
+  protected JobStatus status = null;
+
+  protected String log = "";
+
+  protected String errorLog = "";
+
+  /* FIXME: server job is not specific to the BaseJob and should preferably
+   * be managed by classes using clients (tasks). */
+  protected WebServiceJobHandle serverJob;
+
+  public BaseJob(List<SequenceI> inputSeqs)
+  {
+    this.inputSeqs = inputSeqs;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public final long getInternalId()
+  {
+    return internalId;
+  }
+
+  /**
+   * Return the list of input sequences associated with this job.
+   * 
+   * @return input sequences
+   */
+  public List<SequenceI> getInputSequences()
+  {
+    return inputSeqs;
+  }
+
+  /**
+   * Check if inputs make a valid job.
+   * 
+   * @return {@code true} if the input is valid.
+   */
+  public abstract boolean isInputValid();
+
+  /**
+   * Check if the job is completed, This includes jobs with invalid input,
+   * successful and unsuccessful termination.
+   * 
+   * @return {@code true} if job is completed successfully or not
+   */
+  public boolean isCompleted()
+  {
+    return !isInputValid() || getStatus().isDone();
+  }
+
+  @Override
+  public final JobStatus getStatus()
+  {
+    return status;
+  }
+
+  /**
+   * Set new status of the job and notify listeners of the change. Job status is
+   * managed internally by tasks and should not be modified outside the task
+   * which created this job.
+   * 
+   * @param status
+   *          new job status
+   */
+  public final void setStatus(JobStatus status)
+  {
+    JobStatus oldStatus = this.status;
+    this.status = status;
+    pcs.firePropertyChange("status", oldStatus, status);
+  }
+
+  @Override
+  public final String getLog()
+  {
+    return log;
+  }
+
+  /**
+   * Set log text and notify listeners of the change. Log is managed by tasks
+   * which created the job and should not be modified by other classes.
+   * 
+   * @param log
+   *          new log
+   */
+  public final void setLog(String log)
+  {
+    String oldLog = this.log;
+    this.log = log;
+    pcs.firePropertyChange("log", oldLog, log);
+  }
+
+  @Override
+  public final String getErrorLog()
+  {
+    return errorLog;
+  }
+
+  /**
+   * Set error log text and notify listeners of the change. Error log is managed
+   * by tasks which created the job and should not be modified by other classes.
+   * 
+   * @param errorLog
+   */
+  public final void setErrorLog(String errorLog)
+  {
+    String oldLog = this.errorLog;
+    this.errorLog = errorLog;
+    pcs.firePropertyChange("errorLog", oldLog, errorLog);
+  }
+
+  /**
+   * Return the job handle that identifies this job running on the server or
+   * {@code null} if the job was not submitted.
+   * 
+   * @return server job handle
+   */
+  public final WebServiceJobHandle getServerJob()
+  {
+    return serverJob;
+  }
+
+  /**
+   * Set the server job handle once the job was submitted to the server. The
+   * handler is managed by the task which created this job and should not be
+   * modified by other classes.
+   * 
+   * @param job
+   */
+  public final void setServerJob(WebServiceJobHandle job)
+  {
+    this.serverJob = job;
+  }
+
+  private final PropertyChangeSupport pcs = new PropertyChangeSupport(this);
+
+  /**
+   * Register an observer that will be notified of changes to status, log and
+   * error log.
+   * 
+   * @param listener
+   *          property change listener
+   */
+  public final void addPropertyChagneListener(PropertyChangeListener listener)
+  {
+    pcs.addPropertyChangeListener(listener);
+  }
+
+  /**
+   * Remove the property listener from this object.
+   * 
+   * @param listener
+   *          listener to remove
+   */
+  public final void removePropertyChangeListener(PropertyChangeListener listener)
+  {
+    pcs.removePropertyChangeListener(listener);
+  }
+}
diff --git a/src/jalview/ws2/actions/ServiceInputInvalidException.java b/src/jalview/ws2/actions/ServiceInputInvalidException.java
new file mode 100644 (file)
index 0000000..c816d61
--- /dev/null
@@ -0,0 +1,31 @@
+package jalview.ws2.actions;
+
+/**
+ * An exception thrown to indicate that the input is invalid and the service
+ * cannot be started.
+ *  
+ * @author mmwarowny
+ *
+ */
+public class ServiceInputInvalidException extends Exception
+{
+  /**
+   * 
+   */
+  private static final long serialVersionUID = 174066679057181584L;
+
+  public ServiceInputInvalidException(String message)
+  {
+    super(message);
+  }
+  
+  public ServiceInputInvalidException(Throwable cause)
+  {
+    super(cause);
+  }
+  
+  public ServiceInputInvalidException(String message, Throwable cause)
+  {
+    super(message, cause);
+  }
+}
diff --git a/src/jalview/ws2/actions/alignment/AlignmentAction.java b/src/jalview/ws2/actions/alignment/AlignmentAction.java
new file mode 100644 (file)
index 0000000..7f935bf
--- /dev/null
@@ -0,0 +1,92 @@
+package jalview.ws2.actions.alignment;
+
+import java.util.List;
+import java.util.Objects;
+
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+
+/**
+ * Implementation of the {@link BaseAction} that runs alignment services. This
+ * type of action requires {@link AlignmentWebServiceClientI} to retrieve
+ * alignment result from the server.
+ * 
+ * @author mmwarowny
+ *
+ */
+public class AlignmentAction extends BaseAction<AlignmentResult>
+{
+  /**
+   * A builder for AlignemntActions. Adds {@code client} and {@code submitGaps}
+   * parameters to the base builder.
+   * 
+   * @author mmwarowny
+   */
+  public static class Builder extends BaseAction.Builder<AlignmentAction>
+  {
+    protected AlignmentWebServiceClientI client;
+
+    protected boolean submitGaps = false;
+
+    public Builder(AlignmentWebServiceClientI client)
+    {
+      super();
+      Objects.requireNonNull(client);
+      this.client = client;
+    }
+
+    public void submitGaps(boolean val)
+    {
+      submitGaps = val;
+    }
+
+    public AlignmentAction build()
+    {
+      return new AlignmentAction(this);
+    }
+  }
+
+  public static Builder newBuilder(AlignmentWebServiceClientI client)
+  {
+    return new Builder(client);
+  }
+
+  protected final boolean submitGaps;
+
+  protected final AlignmentWebServiceClientI client;
+
+  public AlignmentAction(Builder builder)
+  {
+    super(builder);
+    submitGaps = builder.submitGaps;
+    client = builder.client;
+  }
+
+  @Override
+  public TaskI<AlignmentResult> perform(AlignmentViewport viewport,
+      List<ArgumentI> args, Credentials credentials,
+      TaskEventListener<AlignmentResult> handler)
+  {
+    var msa = viewport.getAlignmentView(true);
+    var task = new AlignmentTask(
+        client, this, args, credentials, msa, viewport, submitGaps, handler);
+    task.start(viewport.getServiceExecutor());
+    return task;
+  }
+
+  /**
+   * Returns if the action is active for the given viewport. Alignment services
+   * are non-interactive, so the action is never active.
+   */
+  @Override
+  public boolean isActive(AlignmentViewport viewport)
+  {
+    return false;
+  }
+
+}
diff --git a/src/jalview/ws2/actions/alignment/AlignmentJob.java b/src/jalview/ws2/actions/alignment/AlignmentJob.java
new file mode 100644 (file)
index 0000000..5ddb64a
--- /dev/null
@@ -0,0 +1,114 @@
+package jalview.ws2.actions.alignment;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignSeq;
+import jalview.analysis.SeqsetUtils;
+import jalview.analysis.SeqsetUtils.SequenceInfo;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceI;
+import jalview.util.Comparison;
+import jalview.ws2.actions.BaseJob;
+import jalview.ws2.api.WebServiceJobHandle;
+
+/**
+ * A wrapper class that extends basic job container with data specific to
+ * alignment services. It stores input and empty sequences (with uniquified
+ * names) along the original sequence information. {@link AlignmentJob} objects
+ * are created by {@link AlignmentTask} during a preparation stage.
+ * 
+ * @author mmwarowny
+ *
+ */
+class AlignmentJob extends BaseJob
+{
+  private final List<SequenceI> emptySeqs;
+
+  private final Map<String, SequenceInfo> names;
+
+  private AlignmentI alignmentResult;
+
+  AlignmentJob(List<SequenceI> inputSeqs, List<SequenceI> emptySeqs,
+      Map<String, SequenceInfo> names)
+  {
+    super(Collections.unmodifiableList(inputSeqs));
+    this.emptySeqs = Collections.unmodifiableList(emptySeqs);
+    this.names = Collections.unmodifiableMap(names);
+  }
+
+  public static AlignmentJob create(SequenceI[] seqs, int minlen, boolean keepGaps)
+  {
+    int nseqs = 0;
+    for (int i = 0; i < seqs.length; i++)
+    {
+      if (seqs[i].getEnd() - seqs[i].getStart() >= minlen)
+        nseqs++;
+    }
+    boolean valid = nseqs > 1; // need at least two sequences
+    Map<String, SequenceInfo> names = new LinkedHashMap<>();
+    List<SequenceI> inputSeqs = new ArrayList<>();
+    List<SequenceI> emptySeqs = new ArrayList<>();
+    for (int i = 0; i < seqs.length; i++)
+    {
+      SequenceI seq = seqs[i];
+      String newName = SeqsetUtils.unique_name(i);
+      names.put(newName, SeqsetUtils.SeqCharacterHash(seq));
+      if (valid && seq.getEnd() - seq.getStart() >= minlen)
+      {
+        // make new input sequence
+        String seqString = seq.getSequenceAsString();
+        if (!keepGaps)
+          seqString = AlignSeq.extractGaps(Comparison.GapChars, seqString);
+        inputSeqs.add(new Sequence(newName, seqString));
+      }
+      else
+      {
+        String seqString = "";
+        if (seq.getEnd() >= seq.getStart()) // true if gaps only
+        {
+          seqString = seq.getSequenceAsString();
+          if (!keepGaps)
+            seqString = AlignSeq.extractGaps(Comparison.GapChars, seqString);
+        }
+        emptySeqs.add(new Sequence(newName, seqString));
+      }
+    }
+    return new AlignmentJob(inputSeqs, emptySeqs, names);
+  }
+
+  @Override
+  public boolean isInputValid()
+  {
+    return inputSeqs.size() >= 2;
+  }
+
+  List<SequenceI> getEmptySequences()
+  {
+    return emptySeqs;
+  }
+
+  Map<String, SequenceInfo> getNames()
+  {
+    return names;
+  }
+
+  boolean hasResult()
+  {
+    return alignmentResult != null;
+  }
+
+  AlignmentI getAlignmentResult()
+  {
+    return alignmentResult;
+  }
+
+  void setAlignmentResult(AlignmentI alignment)
+  {
+    this.alignmentResult = alignment;
+  }
+}
diff --git a/src/jalview/ws2/actions/alignment/AlignmentProviderI.java b/src/jalview/ws2/actions/alignment/AlignmentProviderI.java
new file mode 100644 (file)
index 0000000..58bf8fc
--- /dev/null
@@ -0,0 +1,32 @@
+package jalview.ws2.actions.alignment;
+
+import java.io.IOException;
+
+import jalview.datamodel.AlignmentI;
+
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+import jalview.ws2.client.api.WebServiceClientI;
+
+/**
+ * An interface for providing alignment results to the alignment services. Web
+ * service clients that want to support alignment actions must implement this
+ * interface in addition to {@link WebServiceClientI}.
+ * 
+ * @author mmwarowny
+ *
+ * @see AlignmentWebServiceClientI
+ */
+public interface AlignmentProviderI
+{
+  /**
+   * Get the alignment result for the job from the server.
+   * 
+   * @param job
+   *          web service job
+   * @return alignment result
+   * @throws IOException
+   *           server communication error
+   */
+  public AlignmentI getAlignment(WebServiceJobHandle job) throws IOException;
+}
diff --git a/src/jalview/ws2/actions/alignment/AlignmentResult.java b/src/jalview/ws2/actions/alignment/AlignmentResult.java
new file mode 100644 (file)
index 0000000..9090dd2
--- /dev/null
@@ -0,0 +1,48 @@
+package jalview.ws2.actions.alignment;
+
+import java.util.List;
+
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AlignmentOrder;
+import jalview.datamodel.HiddenColumns;
+import jalview.ws2.actions.api.TaskEventListener;
+
+/**
+ * A data container storing the output of multiple sequence alignment services.
+ * The object is constructed by an {@link AlignmentTask} on completion and
+ * passed to the handler {@link TaskEventListener#taskCompleted(TaskI, Object)}
+ * method as a result.
+ * 
+ * @author mmwarowny
+ */
+public class AlignmentResult
+{
+  final AlignmentI aln;
+
+  final List<AlignmentOrder> alorders;
+
+  final HiddenColumns hidden;
+
+  AlignmentResult(AlignmentI aln, List<AlignmentOrder> alorders,
+      HiddenColumns hidden)
+  {
+    this.aln = aln;
+    this.alorders = alorders;
+    this.hidden = hidden;
+  }
+
+  public AlignmentI getAlignment()
+  {
+    return aln;
+  }
+
+  public List<AlignmentOrder> getAlignmentOrders()
+  {
+    return alorders;
+  }
+
+  public HiddenColumns getHiddenColumns()
+  {
+    return hidden;
+  }
+}
diff --git a/src/jalview/ws2/actions/alignment/AlignmentTask.java b/src/jalview/ws2/actions/alignment/AlignmentTask.java
new file mode 100644 (file)
index 0000000..96e9a12
--- /dev/null
@@ -0,0 +1,223 @@
+package jalview.ws2.actions.alignment;
+
+import static java.lang.String.format;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignmentSorter;
+import jalview.analysis.SeqsetUtils;
+import jalview.analysis.SeqsetUtils.SequenceInfo;
+import jalview.api.AlignViewportI;
+import jalview.bin.Cache;
+import jalview.datamodel.AlignedCodonFrame;
+import jalview.datamodel.Alignment;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AlignmentOrder;
+import jalview.datamodel.AlignmentView;
+import jalview.datamodel.HiddenColumns;
+import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceI;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.AbstractPollableTask;
+import jalview.ws2.actions.ServiceInputInvalidException;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+
+/**
+ * Implementation of an abstract pollable task used by alignment service
+ * actions.
+ * 
+ * @author mmwarowny
+ *
+ */
+class AlignmentTask extends AbstractPollableTask<AlignmentJob, AlignmentResult>
+{
+  /* task parameters set in the constructor */
+  private final AlignmentWebServiceClientI client;
+
+  private final AlignmentAction action;
+
+  private final AlignmentView msa; // a.k.a. input
+
+  private final AlignViewportI viewport;
+
+  private final boolean submitGaps;
+
+  private final AlignmentI currentView;
+
+  private final AlignmentI dataset;
+
+  private final char gapChar;
+
+  private final List<AlignedCodonFrame> codonFrame = new ArrayList<>();
+
+  AlignmentTask(AlignmentWebServiceClientI client, AlignmentAction action,
+      List<ArgumentI> args, Credentials credentials,
+      AlignmentView msa, AlignViewportI viewport, boolean submitGaps,
+      TaskEventListener<AlignmentResult> eventListener)
+  {
+    super(client, args, credentials, eventListener);
+    this.client = client;
+    this.action = action;
+    this.msa = msa;
+    this.viewport = viewport;
+    this.submitGaps = submitGaps;
+    this.currentView = viewport.getAlignment();
+    this.dataset = viewport.getAlignment().getDataset();
+    this.gapChar = viewport.getGapCharacter();
+    List<AlignedCodonFrame> cf = viewport.getAlignment().getCodonFrames();
+    if (cf != null)
+      this.codonFrame.addAll(cf);
+  }
+  
+  @Override
+  protected List<AlignmentJob> prepare() throws ServiceInputInvalidException
+  { 
+    Cache.log.info(format("starting alignment service %s:%s",
+        client.getClientName(), action.getName()));
+    SequenceI[][] conmsa = msa.getVisibleContigs(gapChar);
+    if (conmsa == null)
+    {
+      throw new ServiceInputInvalidException("no visible contigs for alignment");
+    }
+    List<AlignmentJob> jobs = new ArrayList<>(conmsa.length);
+    boolean validInput = false;
+    for (int i = 0; i < conmsa.length; i++)
+    {
+      AlignmentJob job = AlignmentJob.create(conmsa[i], 2, submitGaps);
+      validInput |= job.isInputValid();  // at least one input is valid
+      job.setStatus(job.isInputValid() ? JobStatus.READY : JobStatus.INVALID);
+      jobs.add(job);
+    }
+    this.jobs = jobs;
+    if (!validInput)
+    {
+      throw new ServiceInputInvalidException("no valid sequences for alignment");
+    }
+    return jobs;
+  }
+
+  @Override
+  protected AlignmentResult done() throws IOException
+  {
+    IOException lastIOE = null;
+    for (AlignmentJob job : jobs)
+    {
+      if (job.isInputValid() && job.getStatus() == JobStatus.COMPLETED &&
+          !job.hasResult())
+      {
+        try
+        {
+          job.setAlignmentResult(client.getAlignment(job.getServerJob()));
+        } catch (IOException e)
+        {
+          lastIOE = e;
+        }
+      }
+    }
+    if (lastIOE != null)
+      throw lastIOE;  // do not proceed unless all results has been retrieved
+    
+    List<AlignmentOrder> alorders = new ArrayList<>();
+    SequenceI[][] results = new SequenceI[jobs.size()][];
+    AlignmentOrder[] orders = new AlignmentOrder[jobs.size()];
+    for (int i = 0; i < jobs.size(); i++)
+    {
+      /* alternative implementation of MsaWSJob#getAlignment */
+      AlignmentJob job = jobs.get(i);
+      if (!job.hasResult())
+        continue;
+      AlignmentI alignment = job.getAlignmentResult();
+      int alnSize = alignment.getSequences().size();
+      char gapChar = alnSize > 0 ? alignment.getGapCharacter() : '-';
+      List<SequenceI> emptySeqs = job.getEmptySequences();
+      List<SequenceI> alnSeqs = new ArrayList<>(alnSize);
+      // create copies of all sequences involved
+      for (SequenceI seq : alignment.getSequences())
+      {
+        alnSeqs.add(new Sequence(seq));
+      }
+      for (SequenceI seq : emptySeqs)
+      {
+        alnSeqs.add(new Sequence(seq));
+      }
+      // find the width of the longest sequence
+      int width = 0;
+      for (var seq: alnSeqs)
+        width = Integer.max(width, seq.getLength());
+      // make a sequence of gaps only to cut/paste
+      String gapSeq;
+      {
+        char[] gaps = new char[width];
+        Arrays.fill(gaps, gapChar);
+        gapSeq = new String(gaps);
+      }
+      for (var seq: alnSeqs)
+      {
+        if (seq.getLength() < width)
+        {
+          // pad sequences shorter than the target width with gaps
+          seq.setSequence(seq.getSequenceAsString()
+              + gapSeq.substring(seq.getLength()));
+        }
+      }
+      SequenceI[] result = alnSeqs.toArray(new SequenceI[0]);
+      AlignmentOrder msaOrder = new AlignmentOrder(result);
+      AlignmentSorter.recoverOrder(result);
+      Map<String, SequenceInfo> names = new HashMap<>(job.getNames());
+      SeqsetUtils.deuniquify(names, result);
+      
+      alorders.add(msaOrder);
+      results[i] = result;
+      orders[i] = msaOrder;
+    }
+    Object[] newView = msa.getUpdatedView(results, orders, gapChar);
+    // free references to original data
+    for (int i = 0; i < jobs.size(); i++)
+    {
+      results[i] = null;
+      orders[i] = null;
+    }
+    SequenceI[] alignment = (SequenceI[]) newView[0];
+    HiddenColumns hidden = (HiddenColumns) newView[1];
+    Alignment aln = new Alignment(alignment);
+    aln.setProperty("Alignment Program", action.getName());
+    if (dataset != null)
+      aln.setDataset(dataset);
+    
+    propagateDatasetMappings(aln);
+    return new AlignmentResult(aln, alorders, hidden);
+  }
+  
+  /**
+   * Conserve dataset references to sequence objects returned from web services.
+   * Propagate AlignedCodonFrame data from {@code codonFrame} to {@code aln}.
+   * TODO: Refactor to datamodel
+   */
+  private void propagateDatasetMappings(AlignmentI aln)
+  {
+    if (codonFrame != null)
+    {
+      SequenceI[] alignment = aln.getSequencesArray();
+      for (final SequenceI seq : alignment)
+      {
+        for (AlignedCodonFrame acf : codonFrame)
+        {
+          if (acf != null && acf.involvesSequence(seq))
+          {
+            aln.addCodonFrame(acf);
+            break;
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationAction.java b/src/jalview/ws2/actions/annotation/AnnotationAction.java
new file mode 100644 (file)
index 0000000..02829fd
--- /dev/null
@@ -0,0 +1,128 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.List;
+import java.util.Objects;
+
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+
+public class AnnotationAction extends BaseAction<AnnotationResult>
+{
+  /**
+   * A builder of {@link AnnotationAction} instances.
+   */
+  public static class Builder extends BaseAction.Builder<AnnotationAction>
+  {
+    protected AnnotationWebServiceClientI client;
+    
+    protected boolean alignmentAnalysis = false;
+    
+    protected boolean requireAlignedSequences = false;
+    
+    protected boolean filterSymbols = true;
+
+    public Builder(AnnotationWebServiceClientI client)
+    {
+      super();
+      Objects.requireNonNull(client);
+      this.client = client;
+    }
+    
+    /**
+     * Set if action is an alignment analysis action.
+     */
+    public void alignmentAnalysis(boolean val)
+    {
+      alignmentAnalysis = val;
+    }
+    
+    /**
+     * Set if action require aligned sequences.
+     */
+    public void requireAlignedSequences(boolean val)
+    {
+      requireAlignedSequences = val;
+    }
+
+    /**
+     * Set if action requires non-standard residues to be filtered out 
+     */
+    public void filterSymbols(boolean val)
+    {
+      filterSymbols = val;
+    }
+
+    public AnnotationAction build()
+    {
+      return new AnnotationAction(this);
+    }
+  }
+
+  public static Builder newBuilder(AnnotationWebServiceClientI client)
+  {
+    return new Builder(client);
+  }
+
+  protected final AnnotationWebServiceClientI client;
+  
+  protected final boolean alignmentAnalysis;
+  
+  protected final boolean requireAlignedSequences;
+  
+  protected final boolean filterSymbols;
+
+  protected AnnotationAction(Builder builder)
+  {
+    super(builder);
+    client = builder.client;
+    alignmentAnalysis = builder.alignmentAnalysis;
+    requireAlignedSequences = builder.requireAlignedSequences;
+    filterSymbols = builder.filterSymbols;
+  }
+
+  @Override
+  public TaskI<AnnotationResult> perform(AlignmentViewport viewport,
+      List<ArgumentI> args, Credentials credentials,
+      TaskEventListener<AnnotationResult> handler)
+  {
+    var task = new AnnotationTask(client, this, args, credentials, viewport,
+        handler);
+    task.start(viewport.getCalcManager());
+    return task;
+  }
+
+  /**
+   * Return if this action is an alignment analysis service.
+   */
+  public boolean isAlignmentAnalysis()
+  {
+    return alignmentAnalysis;
+  }
+
+  /**
+   * Return if this action require sequences to be aligned.
+   */
+  public boolean getRequireAlignedSequences()
+  {
+    return requireAlignedSequences;
+  }
+  
+  /**
+   * Return if this action require non-standard symbols to be filtered out.
+   */
+  public boolean getFilterSymbols()
+  {
+    return filterSymbols;
+  }
+  
+  @Override
+  public boolean isActive(AlignmentViewport viewport)
+  {
+    return false;
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationJob.java b/src/jalview/ws2/actions/annotation/AnnotationJob.java
new file mode 100644 (file)
index 0000000..23e462b
--- /dev/null
@@ -0,0 +1,144 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignSeq;
+import jalview.analysis.SeqsetUtils;
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AnnotatedCollectionI;
+import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.schemes.ResidueProperties;
+import jalview.util.Comparison;
+import jalview.ws2.actions.BaseJob;
+
+public class AnnotationJob extends BaseJob
+{
+  final boolean[] gapMap;
+
+  final Map<String, SequenceI> seqNames;
+
+  final int start, end;
+  
+  final int minSize;
+
+  List<AlignmentAnnotation> returnedAnnotations = Collections.emptyList();
+  
+  Map<String, FeatureColourI> featureColours = Collections.emptyMap();
+  
+  Map<String, FeatureMatcherSetI> featureFilters = Collections.emptyMap();
+  
+
+  public AnnotationJob(List<SequenceI> inputSeqs, boolean[] gapMap,
+      Map<String, SequenceI> seqNames, int start, int end, int minSize)
+  {
+    super(inputSeqs);
+    this.gapMap = gapMap;
+    this.seqNames = seqNames;
+    this.start = start;
+    this.end = end;
+    this.minSize = minSize;
+  }
+
+  @Override
+  public boolean isInputValid()
+  {
+    int nvalid = 0;
+    for (SequenceI sq : getInputSequences())
+      if (sq.getStart() <= sq.getEnd())
+        nvalid++;
+    return nvalid >= minSize;
+  }
+
+  public static AnnotationJob create(AnnotatedCollectionI inputSeqs, 
+      boolean bySequence, boolean submitGaps, boolean requireAligned, 
+      boolean filterNonStandardResidues, int minSize)
+  {
+    List<SequenceI> seqs = new ArrayList<>();
+    int minlen = 10;
+    int ln = -1;
+    Map<String, SequenceI> seqNames = bySequence ? new HashMap<>() : null;
+    BitSet gapMap = new BitSet();
+    int gapMapSize = 0;
+    int start = inputSeqs.getStartRes();
+    int end = inputSeqs.getEndRes();
+    // TODO: URGENT! unify with JPred / MSA code to handle hidden regions
+    // correctly
+    // TODO: push attributes into WsJob instance (so they can be safely
+    // persisted/restored
+    for (SequenceI sq : inputSeqs.getSequences())
+    {
+      int sqlen;
+      if (bySequence)
+        sqlen = sq.findPosition(end + 1) - sq.findPosition(start + 1);
+      else
+        sqlen = sq.getEnd() - sq.getStart();
+      if (sqlen >= minlen)
+      {
+        String newName = SeqsetUtils.unique_name(seqs.size() + 1);
+        if (seqNames != null)
+          seqNames.put(newName, sq);
+        Sequence seq;
+        if (submitGaps)
+        {
+          seq = new Sequence(newName, sq.getSequenceAsString());
+          gapMapSize = Math.max(gapMapSize, seq.getLength());
+          for (int pos : sq.gapMap())
+          {
+            char sqchr = sq.getCharAt(pos);
+            boolean include = !filterNonStandardResidues;
+            include |= sq.isProtein() ? ResidueProperties.aaIndex[sqchr] < 20
+                : ResidueProperties.nucleotideIndex[sqchr] < 5;
+            if (include)
+              gapMap.set(pos);
+          }
+        }
+        else
+        {
+          // TODO: add ability to exclude hidden regions
+          seq = new Sequence(newName, AlignSeq.extractGaps(Comparison.GapChars,
+              sq.getSequenceAsString(start, end + 1)));
+          // for annotation need to also record map to sequence start/end
+          // position in range
+          // then transfer back to original sequence on return.
+        }
+        seqs.add(seq);
+        ln = Math.max(ln, seq.getLength());
+      }
+    }
+
+    if (requireAligned && submitGaps)
+    {
+      int realWidth = gapMap.cardinality();
+      for (int i = 0; i < seqs.size(); i++)
+      {
+        SequenceI sq = seqs.get(i);
+        char[] padded = new char[realWidth];
+        char[] original = sq.getSequence();
+        for (int op = 0, pp = 0; pp < realWidth; op++)
+        {
+          if (gapMap.get(op))
+          {
+            if (original.length > op)
+              padded[pp++] = original[op];
+            else
+              padded[pp++] = '-';
+          }
+        }
+        seqs.set(i, new Sequence(sq.getName(), padded));
+      }
+    }
+    boolean[] gapMapArray = new boolean[gapMapSize];
+    for (int i = 0; i < gapMapSize; i++)
+      gapMapArray[i] = gapMap.get(i);
+    return new AnnotationJob(seqs, gapMapArray, seqNames, start, end, minSize);
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationProviderI.java b/src/jalview/ws2/actions/annotation/AnnotationProviderI.java
new file mode 100644 (file)
index 0000000..3a836a0
--- /dev/null
@@ -0,0 +1,48 @@
+package jalview.ws2.actions.annotation;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+import jalview.ws2.client.api.WebServiceClientI;
+
+/**
+ * An interface for providing annotation results to the annotation services. It
+ * declares a method to attach annotations fetched from the server to sequences.
+ * Web service clients wanting to support annotation acitons must implement this
+ * interface in addition to {@link WebServiceClientI}
+ * 
+ * @author mmwarowny
+ *
+ * @see AnnotationWebServiceClientI
+ */
+public interface AnnotationProviderI
+{
+  /**
+   * Retrieves annotations from the job result on the server and attaches them
+   * to provided sequences. Additionally, adds feature colours and filters to
+   * provided containers.
+   * 
+   * @param job
+   *          web service job
+   * @param sequences
+   *          sequences the annotations will be added to
+   * @param colours
+   *          container for feature colours
+   * @param filters
+   *          container for feature filters
+   * @return sequence and alignment annotation rows that should be made
+   *         visible/updated on alignment
+   * @throws IOException
+   *           annotation retrieval failed
+   */
+  public List<AlignmentAnnotation> attachAnnotations(WebServiceJobHandle job,
+      List<SequenceI> sequences, Map<String, FeatureColourI> colours,
+      Map<String, FeatureMatcherSetI> filters) throws IOException;
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationResult.java b/src/jalview/ws2/actions/annotation/AnnotationResult.java
new file mode 100644 (file)
index 0000000..373ecbb
--- /dev/null
@@ -0,0 +1,56 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.List;
+import java.util.Map;
+
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.features.FeatureMatcherSetI;
+
+/**
+ * A simple data container storing the output of annotation tasks. The object is
+ * constructed on {@link AnnotationTask} completion and passed to an appropriate
+ * handler.
+ * 
+ * @author mmwarowny
+ *
+ */
+public class AnnotationResult
+{
+  final List<AlignmentAnnotation> annotations;
+
+  final boolean transferFeatures;
+
+  final Map<String, FeatureColourI> featureColours;
+
+  final Map<String, FeatureMatcherSetI> featureFilters;
+
+  public AnnotationResult(List<AlignmentAnnotation> annotations, boolean transferFeatures,
+      Map<String, FeatureColourI> featureColours, Map<String, FeatureMatcherSetI> featureFilters)
+  {
+    this.annotations = annotations;
+    this.transferFeatures = transferFeatures;
+    this.featureColours = featureColours;
+    this.featureFilters = featureFilters;
+  }
+
+  public List<AlignmentAnnotation> getAnnotations()
+  {
+    return annotations;
+  }
+  
+  public boolean getTransferFeatures()
+  {
+    return transferFeatures;
+  }
+
+  public Map<String, FeatureColourI> getFeatureColours()
+  {
+    return featureColours;
+  }
+
+  public Map<String, FeatureMatcherSetI> getFeatureFilters()
+  {
+    return featureFilters;
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationTask.java b/src/jalview/ws2/actions/annotation/AnnotationTask.java
new file mode 100644 (file)
index 0000000..9d16400
--- /dev/null
@@ -0,0 +1,589 @@
+package jalview.ws2.actions.annotation;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignmentAnnotationUtils;
+import jalview.api.AlignCalcManagerI2;
+import jalview.api.AlignCalcWorkerI;
+import jalview.api.AlignViewportI;
+import jalview.api.FeatureColourI;
+import jalview.api.PollableAlignCalcWorkerI;
+import jalview.bin.Cache;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AnnotatedCollectionI;
+import jalview.datamodel.Annotation;
+import jalview.datamodel.ContiguousI;
+import jalview.datamodel.Mapping;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.schemes.FeatureSettingsAdapter;
+import jalview.util.ArrayUtils;
+import jalview.util.MapList;
+import jalview.util.MathUtils;
+import jalview.util.Pair;
+import jalview.workers.AlignCalcWorker;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.AbstractPollableTask;
+import jalview.ws2.actions.BaseJob;
+import jalview.ws2.actions.ServiceInputInvalidException;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+import jalview.ws2.helpers.DelegateJobEventListener;
+import jalview.ws2.helpers.TaskEventSupport;
+
+import static java.util.Objects.requireNonNullElse;
+
+public class AnnotationTask implements TaskI<AnnotationResult>
+{
+  private final long uid = MathUtils.getUID();
+
+  private AnnotationWebServiceClientI client;
+
+  private final AnnotationAction action;
+
+  private final List<ArgumentI> args;
+
+  private final Credentials credentials;
+
+  private final AlignViewportI viewport;
+
+  private final TaskEventSupport<AnnotationResult> eventHandler;
+
+  private JobStatus taskStatus = null;
+
+  private AlignCalcWorkerAdapter worker = null;
+
+  private List<AnnotationJob> jobs = Collections.emptyList();
+
+  private AnnotationResult result = null;
+
+  private DelegateJobEventListener<AnnotationResult> jobEventHandler;
+
+  private class AlignCalcWorkerAdapter extends AlignCalcWorker
+      implements PollableAlignCalcWorkerI
+  {
+    private boolean restarting = false;
+
+    AlignCalcWorkerAdapter(AlignCalcManagerI2 calcMan)
+    {
+      super(viewport, null);
+      this.calcMan = calcMan;
+    }
+
+    String getServiceName()
+    {
+      return action.getWebService().getName();
+    }
+
+    @Override
+    public void startUp() throws Throwable
+    {
+      if (alignViewport.isClosed())
+      {
+        stop();
+        throw new IllegalStateException("Starting annotation for closed viewport");
+      }
+      if (restarting)
+        eventHandler.fireTaskRestarted();
+      else
+        restarting = true;
+      jobs = Collections.emptyList();
+      try
+      {
+        jobs = prepare();
+      } catch (ServiceInputInvalidException e)
+      {
+        setStatus(JobStatus.INVALID);
+        eventHandler.fireTaskException(e);
+        throw e;
+      }
+      setStatus(JobStatus.READY);
+      eventHandler.fireTaskStarted(jobs);
+      for (var job : jobs)
+      {
+        job.addPropertyChagneListener(jobEventHandler);
+      }
+      try
+      {
+        startJobs();
+      } catch (IOException e)
+      {
+        eventHandler.fireTaskException(e);
+        cancelJobs();
+        setStatus(JobStatus.SERVER_ERROR);
+        throw e;
+      }
+      setStatus(JobStatus.SUBMITTED);
+    }
+
+    @Override
+    public boolean poll() throws Throwable
+    {
+      boolean done = AnnotationTask.this.poll();
+      updateGlobalStatus();
+      if (done)
+      {
+        retrieveAndProcessResult();
+        eventHandler.fireTaskCompleted(result);
+      }
+      return done;
+    }
+
+    private void retrieveAndProcessResult() throws IOException
+    {
+      result = retrieveResult();
+      updateOurAnnots(result.annotations);
+      if (result.transferFeatures)
+      {
+        final var featureColours = result.featureColours;
+        final var featureFilters = result.featureFilters;
+        viewport.applyFeaturesStyle(new FeatureSettingsAdapter()
+        {
+          @Override
+          public FeatureColourI getFeatureColour(String type)
+          {
+            return featureColours.get(type);
+          }
+
+          @Override
+          public FeatureMatcherSetI getFeatureFilters(String type)
+          {
+            return featureFilters.get(type);
+          }
+
+          @Override
+          public boolean isFeatureDisplayed(String type)
+          {
+            return featureColours.containsKey(type);
+          }
+        });
+      }
+    }
+
+    @Override
+    public void updateAnnotation()
+    {
+      var job = jobs.size() > 0 ? jobs.get(0) : null;
+      if (!calcMan.isWorking(this) && job != null)
+      {
+        var ret = updateResultAnnotation(job, job.returnedAnnotations);
+        updateOurAnnots(ret.get0());
+      }
+    }
+
+    private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
+    {
+      List<AlignmentAnnotation> oldAnnots = requireNonNullElse(ourAnnots, Collections.emptyList());
+      ourAnnots = newAnnots;
+      AlignmentI alignment = viewport.getAlignment();
+      for (AlignmentAnnotation an : oldAnnots)
+      {
+        if (!newAnnots.contains(an))
+        {
+          alignment.deleteAnnotation(an);
+        }
+      }
+      oldAnnots.clear();
+      for (AlignmentAnnotation an : ourAnnots)
+      {
+        viewport.getAlignment().validateAnnotation(an);
+      }
+    }
+
+    @Override
+    public void cancel()
+    {
+      cancelJobs();
+    }
+
+    void stop()
+    {
+      calcMan.disableWorker(this);
+      super.abortAndDestroy();
+    }
+
+    @Override
+    public void done()
+    {
+      for (var job : jobs)
+      {
+        if (job.isInputValid() && !job.isCompleted())
+        {
+          /* if done was called but job is not completed then it
+           * must have been stopped by an exception */
+          job.setStatus(JobStatus.SERVER_ERROR);
+        }
+      }
+      updateGlobalStatus();
+      // dispose of unfinished jobs just in case
+      cancelJobs();
+    }
+
+    @Override
+    public String toString()
+    {
+      return AnnotationTask.this.toString() + "$AlignCalcWorker@"
+          + Integer.toHexString(hashCode());
+    }
+  }
+
+  public AnnotationTask(AnnotationWebServiceClientI client,
+      AnnotationAction action, List<ArgumentI> args, Credentials credentials,
+      AlignViewportI viewport,
+      TaskEventListener<AnnotationResult> eventListener)
+  {
+    this.client = client;
+    this.action = action;
+    this.args = args;
+    this.credentials = credentials;
+    this.viewport = viewport;
+    this.eventHandler = new TaskEventSupport<>(this, eventListener);
+    this.jobEventHandler = new DelegateJobEventListener<>(this.eventHandler);
+  }
+
+  @Override
+  public long getUid()
+  {
+    return uid;
+  }
+
+  public void start(AlignCalcManagerI2 calcManager)
+  {
+    if (this.worker != null)
+      throw new IllegalStateException("task already started");
+    this.worker = new AlignCalcWorkerAdapter(calcManager);
+    if (taskStatus != JobStatus.CANCELLED)
+    {
+      List<AlignCalcWorkerI> oldWorkers = calcManager.getWorkersOfClass(
+          AlignCalcWorkerAdapter.class);
+      for (var worker : oldWorkers)
+      {
+        if (action.getWebService().getName().equalsIgnoreCase(
+            ((AlignCalcWorkerAdapter) worker).getServiceName()))
+        {
+          // remove interactive workers for the same service.
+          calcManager.removeWorker(worker);
+          calcManager.cancelWorker(worker);
+        }
+      }
+      if (action.getWebService().isInteractive())
+        calcManager.registerWorker(worker);
+      else
+        calcManager.startWorker(worker);
+    }
+  }
+
+  /*
+   * The following methods are mostly copied from the {@link AbstractPollableTask}
+   * TODO: move common functionality to a base class
+   */
+  @Override
+  public JobStatus getStatus()
+  {
+    return taskStatus;
+  }
+
+  private void setStatus(JobStatus status)
+  {
+    if (this.taskStatus != status)
+    {
+      Cache.log.debug(String.format("%s status change to %s", this, status.name()));
+      this.taskStatus = status;
+      eventHandler.fireTaskStatusChanged(status);
+    }
+  }
+
+  private void updateGlobalStatus()
+  {
+    int precedence = -1;
+    for (BaseJob job : jobs)
+    {
+      JobStatus status = job.getStatus();
+      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
+      if (precedence < jobPrecedence)
+        precedence = jobPrecedence;
+    }
+    if (precedence >= 0)
+    {
+      setStatus(JobStatus.statusPrecedence[precedence]);
+    }
+  }
+
+  @Override
+  public List<? extends JobI> getSubJobs()
+  {
+    return jobs;
+  }
+
+  /**
+   * Create and return a list of annotation jobs from the current state of the
+   * viewport. Returned job are not started by this method and should be stored
+   * in a field and started separately.
+   * 
+   * @return list of annotation jobs
+   * @throws ServiceInputInvalidException
+   *           input data is not valid
+   */
+  private List<AnnotationJob> prepare() throws ServiceInputInvalidException
+  {
+    AlignmentI alignment = viewport.getAlignment();
+    if (alignment == null || alignment.getWidth() <= 0 ||
+        alignment.getSequences() == null)
+      throw new ServiceInputInvalidException("Alignment does not contain sequences");
+    if (alignment.isNucleotide() && !action.doAllowNucleotide())
+      throw new ServiceInputInvalidException(
+          action.getFullName() + " does not allow nucleotide sequences");
+    if (!alignment.isNucleotide() && !action.doAllowProtein())
+      throw new ServiceInputInvalidException(
+          action.getFullName() + " does not allow protein sequences");
+    boolean bySequence = !action.isAlignmentAnalysis();
+    AnnotatedCollectionI inputSeqs = bySequence ? viewport.getSelectionGroup() : null;
+    if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
+        inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
+      inputSeqs = alignment;
+    boolean submitGaps = action.isAlignmentAnalysis();
+    boolean requireAligned = action.getRequireAlignedSequences();
+    boolean filterSymbols = action.getFilterSymbols();
+    int minSize = action.getMinSequences();
+    AnnotationJob job = AnnotationJob.create(inputSeqs, bySequence,
+        submitGaps, requireAligned, filterSymbols, minSize);
+    if (!job.isInputValid())
+    {
+      job.setStatus(JobStatus.INVALID);
+      throw new ServiceInputInvalidException("Annotation job has invalid input");
+    }
+    job.setStatus(JobStatus.READY);
+    return List.of(job);
+  }
+
+  private void startJobs() throws IOException
+  {
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
+      {
+        var serverJob = client.submit(job.getInputSequences(),
+            args, credentials);
+        job.setServerJob(serverJob);
+        job.setStatus(JobStatus.SUBMITTED);
+      }
+    }
+  }
+
+  private boolean poll() throws IOException
+  {
+    boolean allDone = true;
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && !job.getStatus().isDone())
+      {
+        WebServiceJobHandle serverJob = job.getServerJob();
+        job.setStatus(client.getStatus(serverJob));
+        job.setLog(client.getLog(serverJob));
+        job.setErrorLog(client.getErrorLog(serverJob));
+      }
+      allDone &= job.isCompleted();
+    }
+    return allDone;
+  }
+
+  private AnnotationResult retrieveResult() throws IOException
+  {
+    final Map<String, FeatureColourI> featureColours = new HashMap<>();
+    final Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
+    var job = jobs.get(0);
+    List<AlignmentAnnotation> returnedAnnot = client.attachAnnotations(
+        job.getServerJob(), job.getInputSequences(), featureColours,
+        featureFilters);
+    /* TODO
+     * copy over each annotation row returned and also defined on each
+     * sequence, excluding regions not annotated due to gapMap/column
+     * visibility */
+
+    // update calcId if it is not already set on returned annotation
+    for (AlignmentAnnotation annot : returnedAnnot)
+    {
+      if (annot.getCalcId() == null || annot.getCalcId().isEmpty())
+      {
+        annot.setCalcId(action.getFullName());
+      }
+      annot.autoCalculated = action.isAlignmentAnalysis() &&
+          action.getWebService().isInteractive();
+    }
+    job.returnedAnnotations = returnedAnnot;
+    job.featureColours = featureColours;
+    job.featureFilters = featureFilters;
+    var ret = updateResultAnnotation(job, returnedAnnot);
+    var annotations = ret.get0();
+    var transferFeatures = ret.get1();
+    return new AnnotationResult(annotations, transferFeatures, featureColours,
+        featureFilters);
+  }
+
+  private Pair<List<AlignmentAnnotation>, Boolean> updateResultAnnotation(
+      AnnotationJob job, List<AlignmentAnnotation> annotations)
+  {
+    List<AlignmentAnnotation> newAnnots = new ArrayList<>();
+    // update graphGroup for all annotation
+    /* find a graphGroup greater than any existing one, could be moved
+     * to Alignment#getNewGraphGroup() - returns next unused graph group */
+    int graphGroup = 1;
+    if (viewport.getAlignment().getAlignmentAnnotation() != null)
+    {
+      for (var ala : viewport.getAlignment().getAlignmentAnnotation())
+      {
+        graphGroup = Math.max(graphGroup, ala.graphGroup);
+      }
+    }
+    // update graphGroup in the annotation rows returned form service'
+    /* TODO: look at sequence annotation rows and update graph groups in the
+     * case of reference annotation */
+    for (AlignmentAnnotation ala : annotations)
+    {
+      if (ala.graphGroup > 0)
+        ala.graphGroup += graphGroup;
+      SequenceI aseq = null;
+      // transfer sequence refs and adjust gapMap
+      if (ala.sequenceRef != null)
+      {
+        aseq = job.seqNames.get(ala.sequenceRef.getName());
+      }
+      ala.sequenceRef = aseq;
+
+      Annotation[] resAnnot = ala.annotations;
+      boolean[] gapMap = job.gapMap;
+      Annotation[] gappedAnnot = new Annotation[Math.max(
+          viewport.getAlignment().getWidth(), gapMap.length)];
+      for (int p = 0, ap = job.start; ap < gappedAnnot.length; ap++)
+      {
+        if (gapMap.length > ap && !gapMap[ap])
+          gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
+        else if (p < resAnnot.length)
+          gappedAnnot[ap] = resAnnot[p++];
+        // is this loop exhaustive of resAnnot?
+      }
+      ala.annotations = gappedAnnot;
+
+      AlignmentAnnotation newAnnot = viewport.getAlignment()
+          .updateFromOrCopyAnnotation(ala);
+      if (aseq != null)
+      {
+        aseq.addAlignmentAnnotation(newAnnot);
+        newAnnot.adjustForAlignment();
+        AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
+            newAnnot, newAnnot.label, newAnnot.getCalcId());
+      }
+      newAnnots.add(newAnnot);
+    }
+
+    boolean transferFeatures = false;
+    for (SequenceI sq : job.getInputSequences())
+    {
+      if (!sq.getFeatures().hasFeatures() &&
+          (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
+        continue;
+      transferFeatures = true;
+      SequenceI seq = job.seqNames.get(sq.getName());
+      SequenceI dseq;
+      int start = job.start, end = job.end;
+      boolean[] gapMap = job.gapMap;
+      ContiguousI seqRange = seq.findPositions(start, end);
+      while ((dseq = seq).getDatasetSequence() != null)
+      {
+        seq = seq.getDatasetSequence();
+      }
+      List<ContiguousI> sourceRange = new ArrayList<>();
+      if (gapMap.length >= end)
+      {
+        int lastcol = start, col = start;
+        do
+        {
+          if (col == end || !gapMap[col])
+          {
+            if (lastcol <= (col - 1))
+            {
+              seqRange = seq.findPositions(lastcol, col);
+              sourceRange.add(seqRange);
+            }
+            lastcol = col + 1;
+          }
+        } while (col++ < end);
+      }
+      else
+      {
+        sourceRange.add(seq.findPositions(start, end));
+      }
+
+      int i = 0;
+      int sourceStartEnd[] = new int[sourceRange.size() * 2];
+      for (ContiguousI range : sourceRange)
+      {
+        sourceStartEnd[i++] = range.getBegin();
+        sourceStartEnd[i++] = range.getEnd();
+      }
+      Mapping mp = new Mapping(new MapList(
+          sourceStartEnd, new int[]
+          { seq.getStart(), seq.getEnd() }, 1, 1));
+      dseq.transferAnnotation(sq, mp);
+    }
+
+    return new Pair<>(newAnnots, transferFeatures);
+  }
+
+  @Override
+  public AnnotationResult getResult()
+  {
+    return result;
+  }
+
+  @Override
+  public void cancel()
+  {
+    setStatus(JobStatus.CANCELLED);
+    if (worker != null)
+    {
+      worker.stop();
+    }
+    cancelJobs();
+  }
+
+  public void cancelJobs()
+  {
+    for (BaseJob job : jobs)
+    {
+      if (!job.isCompleted())
+      {
+        try
+        {
+          if (job.getServerJob() != null)
+          {
+            client.cancel(job.getServerJob());
+          }
+          job.setStatus(JobStatus.CANCELLED);
+        } catch (IOException e)
+        {
+          Cache.log.error(String.format(
+              "failed to cancel job %s", job.getServerJob()), e);
+        }
+      }
+    }
+  }
+
+  @Override
+  public String toString()
+  {
+    var status = taskStatus != null ? taskStatus.name() : "UNSET";
+    return String.format("%s(%x, %s)", getClass().getSimpleName(), uid, status);
+  }
+}
diff --git a/src/jalview/ws2/actions/api/ActionI.java b/src/jalview/ws2/actions/api/ActionI.java
new file mode 100644 (file)
index 0000000..52d70df
--- /dev/null
@@ -0,0 +1,147 @@
+package jalview.ws2.actions.api;
+
+import java.util.EnumSet;
+import java.util.List;
+
+import javax.swing.Icon;
+
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.api.CredentialType;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.WebService;
+
+/**
+ * {@code Action} object represents an executable action that the web service
+ * can perform. Actions are factories for {@link TaskI} objects which are
+ * created by {@link #perform} method. Actions are instantiated by
+ * {@link WebServiceDiscovererI} from the service definition obtained from the
+ * server and are added to and provided by the {@link WebService}.
+ * 
+ * Majority of web services will have a single action only, however multiple
+ * actions providing variation to job execution are possible e.g. align and
+ * realign actions of ClustalO service.
+ * 
+ * @author mmwarowny
+ *
+ * @param <R>
+ *          task result type
+ */
+public interface ActionI<R>
+{
+  /**
+   * Get the web service containing this action.
+   * 
+   * @return containing web service
+   */
+  WebService<? extends ActionI<R>> getWebService();
+
+  /**
+   * Get the name of the action. Typically, it should be the same as the name of
+   * the service.
+   * 
+   * @return action name
+   */
+  String getName();
+
+  /**
+   * Get the full name of the action consisting of the service name and the
+   * action name if present.
+   * 
+   * @return full name of this action
+   */
+  default String getFullName()
+  {
+    var name = getName();
+    if (name == null || name.isEmpty())
+      return getWebService().getName();
+    else
+      return getWebService().getName() + " " + name;
+  }
+
+  /**
+   * Get the tooltip for the action which contains extra details about the
+   * action.
+   * 
+   * @return action tooltip
+   */
+  String getTooltip();
+
+  /**
+   * Get the subcategory this action belongs to. Can be used to group or
+   * separate multiple actions.
+   * 
+   * @return action subcategory
+   */
+  String getSubcategory();
+
+  /**
+   * Get the minimum number of sequences this action requires to run or -1 for
+   * no minimum. Actions may still run if the requirement is not met, but may
+   * produce meaningless results.
+   * 
+   * @return minimum required number of sequences
+   */
+  int getMinSequences();
+
+  /**
+   * Get the maximum number of sequences this action requires to run or -1 for
+   * no maximum. Actions may still run if the requirement is not met, but may
+   * produce meaningless or incomplete results.
+   * 
+   * @return maximum required number of sequences
+   */
+  int getMaxSequences();
+
+  /**
+   * Return if this action allows protein sequences.
+   * 
+   * @return {@code true} if protein sequences are allowed
+   */
+  boolean doAllowProtein();
+
+  /**
+   * Return if this action allows nucleotide sequences.
+   * 
+   * @return {@code true} if nucleotide sequences are allowed
+   */
+  boolean doAllowNucleotide();
+
+  /**
+   * Get the set of credentials required to run the action.
+   * 
+   * @return required credentials
+   */
+  EnumSet<CredentialType> getRequiredCredentials();
+
+  /**
+   * Run the action, create and start a new task with provided viewport,
+   * arguments and credentials and attach the handler to the task. The
+   * implementations of this method are responsible for starting the task using
+   * execution method appropriate for the action class.
+   * 
+   * @param viewport
+   *          current alignment viewport
+   * @param args
+   *          job parameters appropriate for the service
+   * @param credentials
+   *          optional user credentials
+   * @param handler
+   *          event handler attached to the new task
+   * @return new running task
+   */
+  TaskI<R> perform(AlignmentViewport viewport, List<ArgumentI> args,
+      Credentials credentials, TaskEventListener<R> handler);
+
+  /**
+   * Return if the action is currently active for the given viewport. Active
+   * actions refer to interactive services which are registered to run
+   * repeatedly on viewport changes. This method has no use for one-shot
+   * services and should always return {@code false} in that case.
+   * 
+   * @param viewport
+   *          viewport being checked for interactive services
+   * @return if there are interactive services registered for viewport
+   */
+  boolean isActive(AlignmentViewport viewport);
+}
diff --git a/src/jalview/ws2/actions/api/JobI.java b/src/jalview/ws2/actions/api/JobI.java
new file mode 100644 (file)
index 0000000..a2643f0
--- /dev/null
@@ -0,0 +1,35 @@
+package jalview.ws2.actions.api;
+
+import jalview.util.MathUtils;
+import jalview.ws2.api.JobStatus;
+
+public interface JobI
+{
+  /**
+   * Get a unique job id used internally by jalview.
+   * 
+   * @return unique job id
+   */
+  long getInternalId();
+  
+  /**
+   * Get the status of this job
+   * 
+   * @return job status
+   */
+  JobStatus getStatus();
+  
+  /**
+   * Get the log for this job
+   * 
+   * @return sub job log
+   */
+  String getLog();
+  
+  /**
+   * Get the error log for this job.
+   * 
+   * @return sub job error log
+   */
+  String getErrorLog();
+}
diff --git a/src/jalview/ws2/actions/api/TaskEventListener.java b/src/jalview/ws2/actions/api/TaskEventListener.java
new file mode 100644 (file)
index 0000000..94de9d0
--- /dev/null
@@ -0,0 +1,111 @@
+package jalview.ws2.actions.api;
+
+import java.util.List;
+
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+
+/**
+ * The listener interface for receiving relevant job progress and state change
+ * events on the task. The listener object is registered with the task on its
+ * creation in {@link ActionI#perform} method. An event is generated when the
+ * task is started, restarted, completed, fails with an exception or its global
+ * status changes. Additional, sub-job related, events are emitted when the
+ * sub-job status, log or error log changes.
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ */
+public interface TaskEventListener<T>
+{
+  /**
+   * Invoked when the task has been started. The {@code subJobs} parameter
+   * contains a complete list of sub-jobs for that run. Note that restartable
+   * tasks may invoke this method multiple times with different set of sub-jobs.
+   * 
+   * @param source
+   *          task this event originates from
+   * @param subJobs
+   *          list of sub-jobs for this run
+   */
+  void taskStarted(TaskI<T> source, List<? extends JobI> subJobs);
+
+  /**
+   * Invoked when the global task status has changed.
+   * 
+   * @param source
+   *          task this event originates from
+   * @param status
+   *          new task status
+   */
+  void taskStatusChanged(TaskI<T> source, JobStatus status);
+
+  /**
+   * Invoked when the task has completed. If the task completed with a result,
+   * that result is passed in the call argument, otherwise, a {@code null} value
+   * is given.
+   * 
+   * @param source
+   *          task this event originates from
+   * @param result
+   *          computation result or null if result not present
+   */
+  void taskCompleted(TaskI<T> source, T result);
+
+  /**
+   * Invoked when an unhandled exception has occurred during task execution.
+   * 
+   * @param source
+   *          task this event originates from
+   * @param e
+   *          exception
+   */
+  void taskException(TaskI<T> source, Exception e);
+
+  /**
+   * Invoked when the task had been restarted. This event is only applicable to
+   * restartable tasks and will precede each {@link #taskStarted} after the
+   * first one.
+   * 
+   * @param source
+   *          task this event originates from
+   */
+  void taskRestarted(TaskI<T> source);
+
+  /**
+   * Invoked when the status of a sub-job has changed.
+   * 
+   * @param source
+   *          task this event originates form
+   * @param job
+   *          sub-job that has been updated
+   * @param status
+   *          new job status
+   */
+  void subJobStatusChanged(TaskI<T> source, JobI job, JobStatus status);
+
+  /**
+   * Invoked when a log string of the sub-job has changed.
+   * 
+   * @param source
+   *          task this event originates form
+   * @param job
+   *          sub-job that has been updated
+   * @param log
+   *          new log string
+   */
+  void subJobLogChanged(TaskI<T> source, JobI job, String log);
+
+  /**
+   * Invoked when an error log string of the sub-job has changed.
+   * 
+   * @param source
+   *          task this event originates form
+   * @param job
+   *          sub-job that has been updated
+   * @param log
+   *          new log string
+   */
+  void subJobErrorLogChanged(TaskI<T> source, JobI job, String log);
+}
diff --git a/src/jalview/ws2/actions/api/TaskI.java b/src/jalview/ws2/actions/api/TaskI.java
new file mode 100644 (file)
index 0000000..cb84944
--- /dev/null
@@ -0,0 +1,54 @@
+package jalview.ws2.actions.api;
+
+import java.util.List;
+
+import jalview.ws2.api.JobStatus;
+
+/**
+ * {@code TaskI} objects represent running services. Tasks are created by
+ * concrete implementations of {@link ActionI} and provide a view of the state
+ * of the underlying job(s).
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ *          task result type
+ */
+public interface TaskI<T>
+{
+  /**
+   * Get the universal identifier of this task.
+   * 
+   * @return identifier
+   */
+  long getUid();
+
+  /**
+   * Get the current status of the task. The resultant status should be a
+   * combination of individual sub-job statuses.
+   * 
+   * @return global status of
+   */
+  JobStatus getStatus();
+
+  /**
+   * Get the current list of sub-jobs of that task.
+   * 
+   * @return sub-jobs
+   */
+  List<? extends JobI> getSubJobs();
+
+  /**
+   * Get the last result of the task or {@code null} if not present. Note that
+   * the result is subject to change for restartable tasks.
+   * 
+   * @return last task result
+   */
+  T getResult();
+
+  /**
+   * Cancel the task, stop all sub-jobs running on a server and stop all threads
+   * managing this task.
+   */
+  void cancel();
+}
diff --git a/src/jalview/ws2/api/CredentialType.java b/src/jalview/ws2/api/CredentialType.java
new file mode 100644 (file)
index 0000000..3101c49
--- /dev/null
@@ -0,0 +1,6 @@
+package jalview.ws2.api;
+
+public enum CredentialType
+{
+  EMAIL, USERNAME, PASSWORD;
+}
diff --git a/src/jalview/ws2/api/Credentials.java b/src/jalview/ws2/api/Credentials.java
new file mode 100644 (file)
index 0000000..cc7c714
--- /dev/null
@@ -0,0 +1,50 @@
+package jalview.ws2.api;
+
+import java.util.Objects;
+
+public final class Credentials
+{
+  String username = null;
+  String email = null;
+  String password = null;
+  private static final Credentials EMPTY = new Credentials();
+
+  private Credentials() {
+  }
+  
+  public static final Credentials empty()
+  {
+    return EMPTY;
+  }
+
+  public static final Credentials usingEmail(String email) {
+    Objects.requireNonNull(email);
+    if (email.isEmpty())
+      throw new IllegalArgumentException("empty email");
+    Credentials credentials = new Credentials();
+    credentials.email = email;
+    return credentials;
+  }
+  
+  public static final Credentials usingEmail(String email, String password) {
+    Objects.requireNonNull(email);
+    Objects.requireNonNull(password);
+    if (email.isEmpty())
+      throw new IllegalArgumentException("empty email");
+    Credentials credentials = new Credentials();
+    credentials.email = email;
+    credentials.password = password;
+    return credentials;
+  }
+  
+  public static final Credentials usingUsername(String username, String password) {
+    Objects.requireNonNull(username);
+    Objects.requireNonNull(password);
+    if (username.isEmpty())
+      throw new IllegalArgumentException("empty username");
+    Credentials credentials = new Credentials();
+    credentials.username = username;
+    credentials.password = password;
+    return credentials;
+  }
+}
diff --git a/src/jalview/ws2/api/JobStatus.java b/src/jalview/ws2/api/JobStatus.java
new file mode 100644 (file)
index 0000000..3341a69
--- /dev/null
@@ -0,0 +1,71 @@
+package jalview.ws2.api;
+
+public enum JobStatus
+{
+  /** Job has invalid inputs and cannot be started. */
+  INVALID,
+  /** Job is created and ready for submission. */
+  READY,
+  /** Job has been submitted and awaits processing. */
+  SUBMITTED,
+  /** Job has been queued for execution */
+  QUEUED,
+  /** Job is running */
+  RUNNING,
+  /** Job has completed successfully. */
+  COMPLETED,
+  /** Job has finished with errors. */
+  FAILED,
+  /** Job has been cancelled by the user. */
+  CANCELLED,
+  /** Job cannot be processed due to server error. */
+  SERVER_ERROR,
+  /** Job status cannot be determined. */
+  UNKNOWN;
+
+  /**
+   * Returns true if the status corresponds to the job completed due to normal
+   * termination, error or cancellation.
+   * 
+   * @return {@value true} if status corresponds to a finished job.
+   */
+  public boolean isDone()
+  {
+    switch (this)
+    {
+    case INVALID:
+    case COMPLETED:
+    case FAILED:
+    case CANCELLED:
+    case SERVER_ERROR:
+      return true;
+    case READY:
+    case SUBMITTED:
+    case QUEUED:
+    case RUNNING:
+    case UNKNOWN:
+      return false;
+    default:
+      throw new AssertionError("non-exhaustive switch statement");
+    }
+  }
+
+  /**
+   * A precedence order of job statuses used to compute the overall task status.
+   */
+  public static final JobStatus[] statusPrecedence = {
+      JobStatus.INVALID, // all must be invalid for task to be invalid
+      JobStatus.COMPLETED, // all but invalid must be completed for task to be
+                           // completed
+      JobStatus.UNKNOWN, // unknown prevents successful completion but not
+                         // running or failure
+      JobStatus.READY,
+      JobStatus.SUBMITTED,
+      JobStatus.QUEUED,
+      JobStatus.RUNNING,
+      JobStatus.CANCELLED, // if any is terminated unsuccessfully, the task is
+                           // failed
+      JobStatus.FAILED,
+      JobStatus.SERVER_ERROR
+  };
+}
diff --git a/src/jalview/ws2/api/WebService.java b/src/jalview/ws2/api/WebService.java
new file mode 100644 (file)
index 0000000..4a101fd
--- /dev/null
@@ -0,0 +1,182 @@
+package jalview.ws2.api;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws2.actions.api.ActionI;
+
+import static java.util.Objects.requireNonNull;
+
+public class WebService<A extends ActionI<?>>
+{
+  public static class Builder<A extends ActionI<?>>
+  {
+    private URL url;
+
+    private String clientName;
+
+    private String category;
+
+    private String name;
+
+    private String description = "";
+
+    private boolean interactive = false;
+
+    private ParamDatastoreI paramDatastore;
+
+    private Class<A> actionClass;
+
+    public Builder<A> url(URL val)
+    {
+      url = val;
+      return this;
+    }
+
+    public Builder<A> clientName(String val)
+    {
+      clientName = val;
+      return this;
+    }
+
+    public Builder<A> category(String val)
+    {
+      category = val;
+      return this;
+    }
+
+    public Builder<A> name(String val)
+    {
+      name = val;
+      return this;
+    }
+
+    public Builder<A> description(String val)
+    {
+      description = val;
+      return this;
+    }
+
+    public Builder<A> interactive(boolean val)
+    {
+      interactive = val;
+      return this;
+    }
+
+    public Builder<A> paramDatastore(ParamDatastoreI val)
+    {
+      paramDatastore = val;
+      return this;
+    }
+
+    public Builder<A> actionClass(Class<A> val)
+    {
+      actionClass = val;
+      return this;
+    }
+
+    public WebService<A> build()
+    {
+      return new WebService<A>(this);
+    }
+  }
+
+  private final URL url;
+
+  private final String clientName;
+
+  private final String category;
+
+  private final String name;
+
+  private final String description;
+
+  private final boolean interactive;
+
+  private final ParamDatastoreI paramDatastore;
+
+  private final List<A> actions;
+
+  private final Class<A> actionClass;
+
+  protected WebService(Builder<A> builder)
+  {
+    requireNonNull(builder.url);
+    requireNonNull(builder.clientName);
+    requireNonNull(builder.category);
+    requireNonNull(builder.name);
+    requireNonNull(builder.paramDatastore);
+    requireNonNull(builder.actionClass);
+    this.url = builder.url;
+    this.clientName = builder.clientName;
+    this.category = builder.category;
+    this.name = builder.name;
+    this.description = builder.description;
+    this.interactive = builder.interactive;
+    this.paramDatastore = builder.paramDatastore;
+    this.actions = new ArrayList<>();
+    this.actionClass = builder.actionClass;
+  }
+
+  public static <A extends ActionI<?>> Builder<A> newBuilder()
+  {
+    return new Builder<A>();
+  }
+
+  public void addAction(A action)
+  {
+    this.actions.add(action);
+  }
+
+  public void addActions(Collection<? extends A> actions)
+  {
+    this.actions.addAll(actions);
+  }
+
+  public URL getUrl()
+  {
+    return url;
+  }
+
+  public String getClientName()
+  {
+    return clientName;
+  }
+
+  public String getCategory()
+  {
+    return category;
+  }
+
+  public String getName()
+  {
+    return name;
+  }
+
+  public String getDescription()
+  {
+    return description;
+  }
+
+  public boolean isInteractive()
+  {
+    return interactive;
+  }
+
+  public ParamDatastoreI getParamDatastore()
+  {
+    return paramDatastore;
+  }
+
+  public List<A> getActions()
+  {
+    return actions;
+  }
+
+  public Class<A> getActionClass()
+  {
+    return actionClass;
+  }
+}
diff --git a/src/jalview/ws2/api/WebServiceJobHandle.java b/src/jalview/ws2/api/WebServiceJobHandle.java
new file mode 100644 (file)
index 0000000..58e2d63
--- /dev/null
@@ -0,0 +1,74 @@
+package jalview.ws2.api;
+
+import java.util.Date;
+
+/**
+ * {@code WebServiceJob} represents a job running on a remote server. The object
+ * contains all the information needed to associate the job with an originating
+ * client and url, service being run and to poll the job and retrieve the
+ * results from the server. The {@code WebServiceJob} object is provided by the
+ * {@link WebServiceClientI#submit} method when the job is created.
+ * 
+ * @see WebServiceClientI
+ * 
+ * @author mmwarowny
+ */
+public class WebServiceJobHandle
+{
+  /** Name of the related client */
+  private final String serviceClient;
+
+  /** Name of the related service */
+  private final String serviceName;
+
+  /** URL the job is valid for */
+  private final String url;
+
+  /** External job id as given by the server */
+  private final String jobId;
+
+  private Date creationTime = new Date();
+
+  public WebServiceJobHandle(String serviceClient, String serviceName,
+      String url, String jobId)
+  {
+    this.serviceClient = serviceClient;
+    this.serviceName = serviceName;
+    this.url = url;
+    this.jobId = jobId;
+  }
+
+  /**
+   * Get a URL this job originates from.
+   * 
+   * @return job URL
+   */
+  public String getUrl()
+  {
+    return url;
+  }
+
+  /**
+   * Get an id assigned to the job by the server.
+   * 
+   * @return job id handle
+   */
+  public String getJobId()
+  {
+    return jobId;
+  }
+
+  /**
+   * @return Job creation time
+   */
+  public Date getCreationTime()
+  {
+    return creationTime;
+  }
+
+  public String toString()
+  {
+    return String.format("%s:%s [%s] Created %s", serviceClient, serviceName,
+        jobId, creationTime);
+  }
+}
diff --git a/src/jalview/ws2/client/api/AbstractWebServiceDiscoverer.java b/src/jalview/ws2/client/api/AbstractWebServiceDiscoverer.java
new file mode 100644 (file)
index 0000000..6a81410
--- /dev/null
@@ -0,0 +1,224 @@
+package jalview.ws2.client.api;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jalview.bin.Cache;
+import jalview.ws2.actions.api.ActionI;
+import jalview.ws2.api.WebService;
+
+public abstract class AbstractWebServiceDiscoverer implements WebServiceDiscovererI
+{
+  // TODO: we can use linked hash map to group and retrieve services by type.
+  protected List<WebService<?>> services = List.of();
+
+  @Override
+  public List<WebService<?>> getServices()
+  {
+    return services;
+  }
+
+  @Override
+  public <A extends ActionI<?>> List<WebService<A>> getServices(Class<A> type)
+  {
+    List<WebService<A>> list = new ArrayList<>();
+    for (WebService<?> service : services)
+    {
+      if (service.getActionClass().equals(type))
+      {
+        @SuppressWarnings("unchecked")
+        WebService<A> _service = (WebService<A>) service;
+        list.add(_service);
+      }
+    }
+    return list;
+  }
+
+  @Override
+  public List<URL> getUrls()
+  {
+    String key = getUrlsPropertyKey();
+    if (key == null)
+      // unmodifiable urls list, return default
+      return List.of(getDefaultUrl());
+    String surls = Cache.getProperty(key);
+    if (surls == null)
+      return List.of(getDefaultUrl());
+    String[] urls = surls.split(",");
+    ArrayList<URL> valid = new ArrayList<>(urls.length);
+    for (String url : urls)
+    {
+      try
+      {
+        valid.add(new URL(url));
+      } catch (MalformedURLException e)
+      {
+        Cache.log.warn(String.format(
+            "Problem whilst trying to make a URL from '%s'. " +
+                "This was probably due to malformed comma-separated-list " +
+                "in the %s entry of ${HOME}/.jalview-properties",
+            Objects.toString(url, "<null>"), key));
+        Cache.log.debug("Exception occurred while reading url list", e);
+      }
+    }
+    return valid;
+  }
+
+  @Override
+  public void setUrls(List<URL> wsUrls)
+  {
+    String key = getUrlsPropertyKey();
+    if (key == null)
+      throw new UnsupportedOperationException("setting urls not supported");
+    if (wsUrls != null && !wsUrls.isEmpty())
+    {
+      String[] surls = new String[wsUrls.size()];
+      var iter = wsUrls.iterator(); 
+      for (int i = 0; iter.hasNext(); i++)
+        surls[i] = iter.next().toString();
+      Cache.setProperty(key, String.join(",", surls));
+    }
+    else
+    {
+      Cache.removeProperty(key);
+    }
+  }
+
+  /**
+   * Get the key in jalview property file where the urls for this discoverer are
+   * stored. Return null if modifying urls is not supported.
+   * 
+   * @return urls entry key
+   */
+  protected abstract String getUrlsPropertyKey();
+
+  /**
+   * Get the default url for web service discovery for this discoverer.
+   * 
+   * @return default discovery url
+   */
+  protected abstract URL getDefaultUrl();
+
+  @Override
+  public boolean hasServices()
+  {
+    return !isRunning() && services.size() > 0;
+  }
+
+  private static final int END = 0x01;
+  private static final int BEGIN = 0x02;
+  private static final int AGAIN = 0x04;
+  private final AtomicInteger state = new AtomicInteger(END);
+  private CompletableFuture<List<WebService<?>>> discoveryTask = new CompletableFuture<>();
+  
+  @Override
+  public boolean isRunning()
+  {
+    return (state.get() & (BEGIN | AGAIN)) != 0;
+  }
+
+  @Override
+  public boolean isDone()
+  {
+    return state.get() == END && discoveryTask.isDone();
+  }
+
+  @Override
+  public synchronized final CompletableFuture<List<WebService<?>>> startDiscoverer()
+  {
+    Cache.log.debug("Requesting service discovery");
+    while (true)
+    {
+      if (state.get() == AGAIN)
+      {
+        return discoveryTask;
+      }
+      if (state.compareAndSet(END, BEGIN) || state.compareAndSet(BEGIN, AGAIN))
+      {
+        Cache.log.debug("State changed to " + state.get());
+        final var oldTask = discoveryTask;
+        CompletableFuture<List<WebService<?>>> task = oldTask
+            .handleAsync((_r, _e) -> {
+              Cache.log.info("Reloading services for " + this);
+              fireServicesChanged(services = Collections.emptyList());
+              var allServices = new ArrayList<WebService<?>>();
+              for (var url : getUrls())
+              {
+                Cache.log.info("Fetching list of services from " + url);
+                try
+                {
+                  allServices.addAll(fetchServices(url));
+                }
+                catch (IOException e)
+                {
+                  Cache.log.error("Failed to get services from " + url, e);
+                }
+              }
+              return services = allServices;
+            });
+        task.<Void>handle((services, exception) -> {
+          while (true)
+          {
+            if (state.get() == END)
+              // should never happen, throw exception to break the loop just in case
+              throw new AssertionError();
+            if (state.compareAndSet(BEGIN, END) || state.compareAndSet(AGAIN, BEGIN))
+              Cache.log.debug("Discovery ended, state is " + state.get());
+              break;
+          }
+          if (services != null)
+            fireServicesChanged(services);
+          return null;
+        });
+        Cache.log.debug("Spawned task " + task);
+        Cache.log.debug("Killing task " + oldTask);
+        oldTask.cancel(false);
+        return discoveryTask = task;
+      }
+    }
+  }
+  
+  protected abstract List<WebService<?>> fetchServices(URL url) throws IOException;
+  
+  private List<ServicesChangeListener> listeners = new ArrayList<>();
+  
+  private void fireServicesChanged(List<WebService<?>> services)
+  {
+    for (var listener : listeners)
+    {
+      try
+      {
+        listener.servicesChanged(this, services);
+      }
+      catch (Exception e)
+      {
+        Cache.log.warn(e);
+      }
+    }
+  }
+
+  @Override
+  public final void addServicesChangeListener(ServicesChangeListener listener)
+  {
+    listeners.add(listener);
+  }
+
+  @Override
+  public final void removeServicesChangeListener(ServicesChangeListener listener)
+  {
+    listeners.remove(listener);
+  }
+
+  @Override
+  public String toString()
+  {
+    return getClass().getName();
+  }
+}
diff --git a/src/jalview/ws2/client/api/AlignmentWebServiceClientI.java b/src/jalview/ws2/client/api/AlignmentWebServiceClientI.java
new file mode 100644 (file)
index 0000000..b0cb06c
--- /dev/null
@@ -0,0 +1,15 @@
+package jalview.ws2.client.api;
+
+import jalview.ws2.actions.alignment.AlignmentProviderI;
+
+/**
+ * A client interface for alignment services combining {@link WebServiceClientI}
+ * and {@link AlignmentProviderI} functionality into one interface.
+ * Alignment services use this interface to issue queries to the server.  
+ *  
+ * @author mmwarowny
+ */
+public interface AlignmentWebServiceClientI extends WebServiceClientI, AlignmentProviderI
+{
+
+}
diff --git a/src/jalview/ws2/client/api/AnnotationWebServiceClientI.java b/src/jalview/ws2/client/api/AnnotationWebServiceClientI.java
new file mode 100644 (file)
index 0000000..a6370ea
--- /dev/null
@@ -0,0 +1,16 @@
+package jalview.ws2.client.api;
+
+import jalview.ws2.actions.annotation.AnnotationProviderI;
+
+/**
+ * A mixin interface used by annotation services combining
+ * {@link WebServiceClientI} and {@link AnnotationProviderI} functionality into
+ * one interface. Annotation services use this interface to issue queries to the
+ * server.
+ * 
+ * @author mmwarowny
+ */
+public interface AnnotationWebServiceClientI extends WebServiceClientI, AnnotationProviderI
+{
+
+}
diff --git a/src/jalview/ws2/client/api/WebServiceClientI.java b/src/jalview/ws2/client/api/WebServiceClientI.java
new file mode 100644 (file)
index 0000000..49d097e
--- /dev/null
@@ -0,0 +1,103 @@
+package jalview.ws2.client.api;
+
+import java.io.IOException;
+import java.util.List;
+
+import jalview.datamodel.SequenceI;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+
+/**
+ * A common interface for all web service clients that provide methods to get
+ * the URL of the server the client is talking to, submit new jobs to the server
+ * as well as poll or cancel the running jobs. This interface does not provide
+ * means to retrieve job results as those may differ between web services.
+ * Specialized sub-interfaces define methods to retrieve job results appropriate
+ * for specific service types.
+ * 
+ * @author mmwarowny
+ *
+ */
+public interface WebServiceClientI
+{
+  /**
+   * Get the hostname/url of the remote server which is supplying the service.
+   * 
+   * @return host name
+   */
+  String getUrl();
+
+  /**
+   * Get the name of the web service client.
+   * 
+   * @return client name
+   */
+  String getClientName();
+
+  /**
+   * Submit new job to the service with the supplied input sequences and
+   * arguments. Optionally, some services may require additional credentials to
+   * run. Implementations should perform all data serialization necessary for
+   * the job submission, start a new job on the remote server and return a
+   * handler for that job.
+   * 
+   * @param sequences
+   *          input sequences
+   * @param args
+   *          user provided arguments
+   * @param credentials
+   *          optional user credentials needed to run the job
+   * @return job handler
+   * @throws IOException
+   *           submission failed due to a connection error
+   */
+  WebServiceJobHandle submit(List<SequenceI> sequences, List<ArgumentI> args,
+      Credentials credentials) throws IOException;
+
+  /**
+   * Poll the server to get the current status of the job.
+   * 
+   * @param job
+   *          web service job
+   * @return job status
+   * @throws IOException
+   *           server communication error
+   */
+  JobStatus getStatus(WebServiceJobHandle job) throws IOException;
+
+  /**
+   * Retrieve log messages from the server for the job.
+   * 
+   * @param job
+   *          web service job
+   * @return log content
+   * @throws IOException
+   *           server communication error
+   */
+  String getLog(WebServiceJobHandle job) throws IOException;
+
+  /**
+   * Retrieve error log messages from the server for the job.
+   * 
+   * @param job
+   *          web service job
+   * @return error log content
+   * @throws IOException
+   *           server communication error
+   */
+  String getErrorLog(WebServiceJobHandle job) throws IOException;
+
+  /**
+   * Send the cancellation request to the server for the specified job.
+   * 
+   * @param job
+   *          job to cancel
+   * @throws IOException
+   *           server error occurred
+   * @throws UnsupportedOperationException
+   *           server does not support job cancellation
+   */
+  void cancel(WebServiceJobHandle job) throws IOException, UnsupportedOperationException;
+}
diff --git a/src/jalview/ws2/client/api/WebServiceDiscovererI.java b/src/jalview/ws2/client/api/WebServiceDiscovererI.java
new file mode 100644 (file)
index 0000000..4a09ef7
--- /dev/null
@@ -0,0 +1,121 @@
+package jalview.ws2.client.api;
+
+import java.net.URL;
+import java.util.EventListener;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import jalview.ws2.api.WebService;
+
+/**
+ * The discoverer and supplier of web services. The discoverer is responsible
+ * for building and storing {@link jalview.ws2.api.WebService} objects
+ * according to the data retrieved from the servers available at specified urls.
+ * @author mmwarowny
+ *
+ */
+public interface WebServiceDiscovererI extends WebServiceProviderI
+{
+  public static final int STATUS_OK = 1;
+
+  public static final int STATUS_NO_SERVICES = 0;
+
+  public static final int STATUS_INVALID = -1;
+
+  public static final int STATUS_UNKNOWN = -2;
+
+  /**
+   * List the urls used by this discoverer.
+   */
+  List<URL> getUrls();
+
+  /**
+   * Set the list of urls where the discoverer will search for services.
+   */
+  void setUrls(List<URL> wsUrls);
+
+  /**
+   * Test if the url is a valid url for that discoverer.
+   */
+  default boolean testUrl(URL url)
+  {
+    return getStatusForUrl(url) == STATUS_OK;
+  }
+
+  /**
+   * Get the availability status of the services at the url. Return one of the
+   * status codes {@code STATUS_OK}, {@code STATUS_NO_SERVICES},
+   * {@code STATUS_INVALID} or {@code STATUS_UNKNOWN}.
+   * 
+   * @return services availability status
+   */
+  int getStatusForUrl(URL url);
+
+  /**
+   * @return {@value true} if there are services available
+   */
+  boolean hasServices();
+
+  /**
+   * Check if service discovery is still in progress. List of services may be
+   * incomplete when the discoverer is running.
+   * 
+   * @return whether the discoverer is running
+   */
+  boolean isRunning();
+
+  /**
+   * Check if the discoverer is done searching for services. List of services
+   * should be complete if this methods returns true.
+   * 
+   * @return whether the discoverer finished
+   */
+  boolean isDone();
+
+  /**
+   * Start the service discovery and return a future which will be set to the
+   * discovery result when the process is completed. This method should be
+   * called once on startup and then every time the urls list is updated.
+   * 
+   * @return services list future result
+   */
+  CompletableFuture<List<WebService<?>>> startDiscoverer();
+
+  /**
+   * An interface for the services list observers.
+   * 
+   * @author mmwarowny
+   */
+  @FunctionalInterface
+  interface ServicesChangeListener extends EventListener
+  {
+    /**
+     * Called whenever the services list of the observed discoverer changes with
+     * that discoverer as the first argument and current services list as the
+     * second. The list can be empty if there are no services or the list was
+     * cleared at the beginning of the discovery.
+     * 
+     * @param discoverer
+     * @param list
+     */
+    public void servicesChanged(WebServiceDiscovererI discoverer,
+        List<WebService<?>> services);
+  }
+
+  /**
+   * Add a services list observer that will be notified of any changes to the
+   * services list.
+   * 
+   * @param listener
+   *          services list change listener
+   */
+  public void addServicesChangeListener(ServicesChangeListener listener);
+
+  /**
+   * Remove a listener from the listeners list.
+   * 
+   * @param listener
+   *          listener to be removed
+   */
+  public void removeServicesChangeListener(ServicesChangeListener listener);
+}
diff --git a/src/jalview/ws2/client/api/WebServiceProviderI.java b/src/jalview/ws2/client/api/WebServiceProviderI.java
new file mode 100644 (file)
index 0000000..7e054f8
--- /dev/null
@@ -0,0 +1,29 @@
+package jalview.ws2.client.api;
+
+import java.util.List;
+
+import jalview.ws2.actions.api.ActionI;
+import jalview.ws2.api.WebService;
+
+/*
+ * A view of services that allows to retrieve the services by the type
+ * of action.
+ */
+public interface WebServiceProviderI
+{
+  /**
+   * Retrieve list of all web services.
+   * 
+   * @return all web services
+   */
+  public List<WebService<?>> getServices();
+
+  /**
+   * Retrieve services by their action type.
+   * 
+   * @param type
+   *          action type
+   * @return list of services
+   */
+  public <A extends ActionI<?>> List<WebService<A>> getServices(Class<A> type);
+}
diff --git a/src/jalview/ws2/client/slivka/SlivkaParamStoreFactory.java b/src/jalview/ws2/client/slivka/SlivkaParamStoreFactory.java
new file mode 100644 (file)
index 0000000..05e6f0c
--- /dev/null
@@ -0,0 +1,228 @@
+package jalview.ws2.client.slivka;
+
+import static java.util.Objects.requireNonNullElse;
+
+import java.io.File;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import com.stevesoft.pat.NotImplementedError;
+
+import jalview.bin.Cache;
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws.params.ParamManager;
+import jalview.ws.params.WsParamSetI;
+import jalview.ws.params.simple.BooleanOption;
+import jalview.ws.params.simple.DoubleParameter;
+import jalview.ws.params.simple.IntegerParameter;
+import jalview.ws.params.simple.Option;
+import jalview.ws.params.simple.StringParameter;
+import jalview.ws2.params.SimpleParamDatastore;
+import jalview.ws2.params.SimpleParamSet;
+import uk.ac.dundee.compbio.slivkaclient.Parameter;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+
+class SlivkaParamStoreFactory
+{
+  private final SlivkaService service;
+  private final ParamManager manager;
+
+  SlivkaParamStoreFactory(SlivkaService service, ParamManager manager)
+  {
+    this.service = service;
+    this.manager = manager;
+  }
+  
+  ParamDatastoreI createParamDatastore()
+  {
+    URL url = null;
+    try
+    {
+      url = service.getUrl().toURL();
+    } catch (MalformedURLException e)
+    {
+      Cache.log.warn("Invalid service url " + service.getUrl(), e);
+    }
+    List<WsParamSetI> presets = new ArrayList<>(service.getPresets().size());
+    for (var preset : service.getPresets())
+    {
+      presets.add(createPreset(preset));
+    }
+    List<ArgumentI> arguments = createPresetArguments(Collections.emptyMap());
+    return new SimpleParamDatastore(url, arguments, presets, manager);
+  }
+  
+  WsParamSetI createPreset(SlivkaService.Preset preset)
+  {
+    var builder = SimpleParamSet.newBuilder();
+    builder.name(preset.name);
+    builder.description(preset.description);
+    builder.url(service.getUrl().toString());
+    builder.modifiable(false);
+    builder.arguments(createPresetArguments(preset.values));
+    return builder.build();
+  }
+
+  List<ArgumentI> createPresetArguments(Map<String, Object> values)
+  {
+    var args = new ArrayList<ArgumentI>();
+    for (Parameter param : service.getParameters())
+    {
+      if (param instanceof Parameter.IntegerParameter)
+      {
+        args.add(createOption((Parameter.IntegerParameter) param,
+            (Integer) values.get(param.getId())));
+      }
+      else if (param instanceof Parameter.DecimalParameter)
+      {
+        args.add(createOption((Parameter.DecimalParameter) param,
+            (Double) values.get(param.getId())));
+      }
+      else if (param instanceof Parameter.TextParameter)
+      {
+        args.add(createOption((Parameter.TextParameter) param,
+            (String) values.get(param.getId())));
+      }
+      else if (param instanceof Parameter.FlagParameter)
+      {
+        args.add(createOption((Parameter.FlagParameter) param,
+            (Boolean) values.get(param.getId())));
+      }
+      else if (param instanceof Parameter.ChoiceParameter)
+      {
+        Object ovalue = values.get(param.getId());
+        List<String> lvalue = null;
+        if (param.isArray())
+          lvalue = (List<String>) ovalue;
+        else if (ovalue != null)
+          lvalue = List.of((String) ovalue);
+        args.addAll(createChoiceOptions((Parameter.ChoiceParameter) param, lvalue));
+      }
+      else if (param instanceof Parameter.FileParameter)
+      {
+        // args.add(createOption((Parameter.FileParameter) param, null));
+      }
+      else
+      {
+        args.add(createOption(param, values.get(param.getId())));
+      }
+    }
+    return args;
+  }
+
+  private Option createOption(Parameter.IntegerParameter param, Integer value)
+  {
+    var builder = IntegerParameter.newBuilder();
+    setCommonProperties(param, builder);
+    builder.setDefaultValue((Integer) param.getDefault());
+    builder.setValue(value);
+    builder.setBounds(param.getMin(), param.getMax());
+    return builder.build();
+  }
+
+  private Option createOption(Parameter.DecimalParameter param, Double value)
+  {
+    var builder = DoubleParameter.newBuilder();
+    setCommonProperties(param, builder);
+    builder.setDefaultValue((Double) param.getDefault());
+    builder.setValue(value);
+    builder.setBounds(param.getMin(), param.getMax());
+    return builder.build();
+  }
+
+  private Option createOption(Parameter.TextParameter param, String value)
+  {
+    var builder = StringParameter.newBuilder();
+    setCommonProperties(param, builder);
+    builder.setDefaultValue((String) param.getDefault());
+    builder.setValue(value);
+    return builder.build();
+  }
+
+  private Option createOption(Parameter.FlagParameter param, Boolean value)
+  {
+    var builder = BooleanOption.newBuilder();
+    setCommonProperties(param, builder);
+    builder.setDefaultValue((Boolean) param.getDefault());
+    builder.setValue(value);
+    return builder.build();
+  }
+
+  private List<Option> createChoiceOptions(Parameter.ChoiceParameter param, List<String> value)
+  {
+    value = requireNonNullElse(value, Collections.emptyList());
+    if (param.isArray())
+    {
+      /*
+       * Array parameter means that multiple values can be provided.
+       * Use multiple boolean checkboxes to represent the value.
+       */
+      List<Option> options = new ArrayList<>();
+      List<?> selected = requireNonNullElse(
+          (List<?>) param.getDefault(), Collections.emptyList());
+      int i = 0;
+      var builder = BooleanOption.newBuilder();
+      setCommonProperties(param, builder);
+      for (String choice : param.getChoices())
+      {
+        builder.setName(String.format("%s$%d", param.getId(), i++));
+        builder.setLabel(choice);
+        builder.setDefaultValue(selected.contains(choice));
+        builder.setValue(value.contains(choice));
+        builder.setReprValue(choice);
+        options.add(builder.build());
+      }
+      return options;
+    }
+    else
+    {
+      /*
+       * Single value parameter means a single string with limited possible
+       * values can be used.
+       */
+      var builder = StringParameter.newBuilder();
+      setCommonProperties(param, builder);
+      builder.setDefaultValue((String) param.getDefault());
+      if (value.size() > 0)
+        builder.setValue(value.get(0));
+      builder.setPossibleValues(param.getChoices());
+      return List.of(builder.build());
+    }
+  }
+
+  private Option createOption(Parameter.FileParameter param, File value)
+  {
+    throw new NotImplementedError("file paramters are not implemented for slivka");
+  }
+
+  private Option createOption(Parameter param, Object value)
+  {
+    var builder = StringParameter.newBuilder();
+    setCommonProperties(param, builder);
+    if (param.getDefault() != null)
+      builder.setDefaultValue(param.getDefault().toString());
+    if (value != null)
+      builder.setValue(value.toString());
+    return builder.build();
+  }
+
+  private void setCommonProperties(Parameter param, Option.Builder builder)
+  {
+    builder.setName(param.getId());
+    builder.setLabel(param.getName());
+    builder.setDescription(param.getDescription());
+    builder.setRequired(param.isRequired());
+    try
+    {
+      builder.setDetailsUrl(service.getUrl().toURL());
+    } catch (MalformedURLException e)
+    {
+      Cache.log.warn("invalid service url " + service.getUrl(), e);
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/jalview/ws2/client/slivka/SlivkaWSClient.java b/src/jalview/ws2/client/slivka/SlivkaWSClient.java
new file mode 100644 (file)
index 0000000..d7841af
--- /dev/null
@@ -0,0 +1,291 @@
+package jalview.ws2.client.slivka;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import jalview.api.FeatureColourI;
+import jalview.bin.Cache;
+import jalview.datamodel.Alignment;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.io.AnnotationFile;
+import jalview.io.DataSourceType;
+import jalview.io.FeaturesFile;
+import jalview.io.FileFormat;
+import jalview.io.FormatAdapter;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AlignmentWebServiceClientI;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+import jalview.ws2.client.api.WebServiceClientI;
+import uk.ac.dundee.compbio.slivkaclient.Job;
+import uk.ac.dundee.compbio.slivkaclient.Parameter;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+
+import static java.lang.String.format;
+
+public class SlivkaWSClient implements WebServiceClientI
+{
+  final SlivkaService service;
+
+  final SlivkaClient client;
+
+  SlivkaWSClient(SlivkaService service)
+  {
+    this.service = service;
+    this.client = service.getClient();
+  }
+
+  @Override
+  public String getUrl()
+  {
+    return client.getUrl().toString();
+  }
+
+  @Override
+  public String getClientName()
+  {
+    return "slivka";
+  }
+
+  // pattern for matching media types
+  static final Pattern mediaTypePattern = Pattern.compile(
+      "(?:text|application)\\/(?:x-)?([\\w-]+)");
+
+  @Override
+  public WebServiceJobHandle submit(List<SequenceI> sequences,
+      List<ArgumentI> args, Credentials credentials) throws IOException
+  {
+    var request = new uk.ac.dundee.compbio.slivkaclient.JobRequest();
+    for (Parameter param : service.getParameters())
+    {
+      // TODO: restrict input sequences parameter name to "sequences"
+      if (param instanceof Parameter.FileParameter)
+      {
+        Parameter.FileParameter fileParam = (Parameter.FileParameter) param;
+        FileFormat format = null;
+        var match = mediaTypePattern.matcher(fileParam.getMediaType());
+        if (match.find())
+        {
+          String fmt = match.group(1);
+          if (fmt.equalsIgnoreCase("pfam"))
+            format = FileFormat.Pfam;
+          else if (fmt.equalsIgnoreCase("stockholm"))
+            format = FileFormat.Stockholm;
+          else if (fmt.equalsIgnoreCase("clustal"))
+            format = FileFormat.Clustal;
+          else if (fmt.equalsIgnoreCase("fasta"))
+            format = FileFormat.Fasta;
+        }
+        if (format == null)
+        {
+          Cache.log.warn(String.format(
+              "Unknown input format %s, assuming fasta.",
+              fileParam.getMediaType()));
+          format = FileFormat.Fasta;
+        }
+        InputStream stream = new ByteArrayInputStream(format.getWriter(null)
+            .print(sequences.toArray(new SequenceI[0]), false)
+            .getBytes());
+        request.addFile(param.getId(), stream);
+      }
+    }
+    if (args != null)
+    {
+      for (ArgumentI arg : args)
+      {
+        // multiple choice field names are name$number to avoid duplications
+        // the number is stripped here
+        String paramId = arg.getName().split("\\$", 2)[0];
+        Parameter param = service.getParameter(paramId);
+        if (param instanceof Parameter.FlagParameter)
+        {
+          if (arg.getValue() != null && !arg.getValue().isBlank())
+            request.addData(paramId, true);
+          else
+            request.addData(paramId, false);
+        }
+        else if (param instanceof Parameter.FileParameter)
+        {
+          request.addFile(paramId, new File(arg.getValue()));
+        }
+        else
+        {
+          request.addData(paramId, arg.getValue());
+        }
+      }
+    }
+    var job = service.submitJob(request);
+    return createJobHandle(job.getId());
+  }
+
+  protected WebServiceJobHandle createJobHandle(String jobId)
+  {
+    return new WebServiceJobHandle(
+        getClientName(), service.getName(), client.getUrl().toString(),
+        jobId);
+  }
+
+  @Override
+  public JobStatus getStatus(WebServiceJobHandle job) throws IOException
+  {
+    var slivkaJob = client.getJob(job.getJobId());
+    return statusMap.getOrDefault(slivkaJob.getStatus(), JobStatus.UNKNOWN);
+  }
+
+  protected static final EnumMap<Job.Status, JobStatus> statusMap = new EnumMap<>(Job.Status.class);
+  static
+  {
+    statusMap.put(Job.Status.PENDING, JobStatus.SUBMITTED);
+    statusMap.put(Job.Status.REJECTED, JobStatus.INVALID);
+    statusMap.put(Job.Status.ACCEPTED, JobStatus.SUBMITTED);
+    statusMap.put(Job.Status.QUEUED, JobStatus.QUEUED);
+    statusMap.put(Job.Status.RUNNING, JobStatus.RUNNING);
+    statusMap.put(Job.Status.COMPLETED, JobStatus.COMPLETED);
+    statusMap.put(Job.Status.INTERRUPTED, JobStatus.CANCELLED);
+    statusMap.put(Job.Status.DELETED, JobStatus.CANCELLED);
+    statusMap.put(Job.Status.FAILED, JobStatus.FAILED);
+    statusMap.put(Job.Status.ERROR, JobStatus.SERVER_ERROR);
+    statusMap.put(Job.Status.UNKNOWN, JobStatus.UNKNOWN);
+  }
+
+  @Override
+  public String getLog(WebServiceJobHandle job) throws IOException
+  {
+    var slivkaJob = client.getJob(job.getJobId());
+    for (var f : slivkaJob.getResults())
+    {
+      if (f.getLabel().equals("log"))
+      {
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+        f.writeTo(stream);
+        return stream.toString(StandardCharsets.UTF_8);
+      }
+    }
+    return "";
+  }
+
+  @Override
+  public String getErrorLog(WebServiceJobHandle job) throws IOException
+  {
+    var slivkaJob = client.getJob(job.getJobId());
+    for (var f : slivkaJob.getResults())
+    {
+      if (f.getLabel().equals("error-log"))
+      {
+        ByteArrayOutputStream stream = new ByteArrayOutputStream();
+        f.writeTo(stream);
+        return stream.toString(StandardCharsets.UTF_8);
+      }
+    }
+    return "";
+  }
+
+  @Override
+  public void cancel(WebServiceJobHandle job)
+      throws IOException, UnsupportedOperationException
+  {
+    Cache.log.warn(
+        "slivka client does not support job cancellation");
+  }
+}
+
+class SlivkaAlignmentWSClient extends SlivkaWSClient
+    implements AlignmentWebServiceClientI
+{
+
+  SlivkaAlignmentWSClient(SlivkaService service)
+  {
+    super(service);
+  }
+
+  @Override
+  public AlignmentI getAlignment(WebServiceJobHandle job) throws IOException
+  {
+    var slivkaJob = client.getJob(job.getJobId());
+    for (var f : slivkaJob.getResults())
+    {
+      // TODO: restrict result file label to "alignment"
+      FileFormat format;
+      var match = mediaTypePattern.matcher(f.getMediaType());
+      if (!match.find())
+        continue;
+      String fmt = match.group(1);
+      if (fmt.equalsIgnoreCase("clustal"))
+        format = FileFormat.Clustal;
+      else if (fmt.equalsIgnoreCase("fasta"))
+        format = FileFormat.Fasta;
+      else
+        continue;
+      return new FormatAdapter().readFile(f.getContentUrl().toString(),
+          DataSourceType.URL, format);
+    }
+    Cache.log.warn("No alignment found on the server");
+    throw new IOException("no alignment found");
+  }
+
+}
+
+class SlivkaAnnotationWSClient extends SlivkaWSClient
+    implements AnnotationWebServiceClientI
+{
+  SlivkaAnnotationWSClient(SlivkaService service)
+  {
+    super(service);
+  }
+
+  @Override
+  public List<AlignmentAnnotation> attachAnnotations(WebServiceJobHandle job,
+      List<SequenceI> sequences, Map<String, FeatureColourI> colours,
+      Map<String, FeatureMatcherSetI> filters) throws IOException
+  {
+    var slivkaJob = client.getJob(job.getJobId());
+    var aln = new Alignment(sequences.toArray(new SequenceI[sequences.size()]));
+    boolean featPresent = false, annotPresent = false;
+    for (var f : slivkaJob.getResults())
+    {
+      // TODO: restrict file label to "annotations" or "features"
+      var match = mediaTypePattern.matcher(f.getMediaType());
+      if (!match.find())
+        continue;
+      String fmt = match.group(1);
+      if (fmt.equalsIgnoreCase("jalview-annotations"))
+      {
+        annotPresent = new AnnotationFile().readAnnotationFileWithCalcId(
+            aln, service.getId(), f.getContentUrl().toString(),
+            DataSourceType.URL);
+        if (annotPresent)
+          Cache.log.debug(format("loaded annotations for %s", service.getId()));
+      }
+      else if (fmt.equalsIgnoreCase("jalview-features"))
+      {
+        FeaturesFile ff = new FeaturesFile(f.getContentUrl().toString(),
+            DataSourceType.URL);
+        featPresent = ff.parse(aln, colours, true);
+        if (featPresent)
+          Cache.log.debug(format("loaded features for %s", service.getId()));
+      }
+    }
+    if (!annotPresent)
+      Cache.log.debug(format("no annotations found for %s", service.getId()));
+    if (!featPresent)
+      Cache.log.debug(format("no features found for %s", service.getId()));
+    return aln.getAlignmentAnnotation() != null ? Arrays.asList(aln.getAlignmentAnnotation())
+        : Collections.emptyList();
+  }
+}
diff --git a/src/jalview/ws2/client/slivka/SlivkaWSDiscoverer.java b/src/jalview/ws2/client/slivka/SlivkaWSDiscoverer.java
new file mode 100644 (file)
index 0000000..58f6d67
--- /dev/null
@@ -0,0 +1,245 @@
+package jalview.ws2.client.slivka;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+
+import jalview.bin.Cache;
+import jalview.ws.params.ParamManager;
+import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.annotation.AnnotationAction;
+import jalview.ws2.api.WebService;
+import jalview.ws2.client.api.AbstractWebServiceDiscoverer;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
+import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+
+public class SlivkaWSDiscoverer extends AbstractWebServiceDiscoverer
+{
+  private static final String SLIVKA_HOST_URLS = "SLIVKAHOSTURLS";
+
+  private static final URL DEFAULT_URL;
+  static
+  {
+    try
+    {
+      DEFAULT_URL = new URL("https://www.compbio.dundee.ac.uk/slivka/");
+    } catch (MalformedURLException e)
+    {
+      throw new AssertionError(e);
+    }
+  }
+
+  private static SlivkaWSDiscoverer instance = null;
+
+  private static ParamManager paramManager = null;
+
+  private SlivkaWSDiscoverer()
+  {
+  }
+
+  public static SlivkaWSDiscoverer getInstance()
+  {
+    if (instance == null)
+      instance = new SlivkaWSDiscoverer();
+    return instance;
+  }
+
+  public static void setParamManager(ParamManager manager)
+  {
+    paramManager = manager;
+  }
+
+  @Override
+  public int getStatusForUrl(URL url)
+  {
+    try
+    {
+      List<?> services = new SlivkaClient(url.toString()).getServices();
+      return services.isEmpty() ? STATUS_NO_SERVICES : STATUS_OK;
+    } catch (IOException e)
+    {
+      Cache.log.error("slivka could not retrieve services from " + url, e);
+      return STATUS_INVALID;
+    }
+  }
+
+  @Override
+  protected String getUrlsPropertyKey()
+  {
+    return SLIVKA_HOST_URLS;
+  }
+
+  @Override
+  protected URL getDefaultUrl()
+  {
+    return DEFAULT_URL;
+  }
+
+  @Override
+  protected List<WebService<?>> fetchServices(URL url) throws IOException
+  {
+    ArrayList<WebService<?>> allServices = new ArrayList<>();
+    SlivkaClient slivkaClient;
+    try
+    {
+      slivkaClient = new SlivkaClient(url.toURI());
+    } catch (URISyntaxException e)
+    {
+      throw new MalformedURLException(e.getMessage());
+    }
+    for (var slivkaService : slivkaClient.getServices())
+    {
+      int serviceClass = getServiceClass(slivkaService);
+      if (serviceClass == SERVICE_CLASS_MSA)
+      {
+        var wsb = WebService.<AlignmentAction> newBuilder();
+        initServiceBuilder(slivkaService, wsb);
+        wsb.category("Alignment");
+        wsb.interactive(false);
+        wsb.actionClass(AlignmentAction.class);
+        var msaService = wsb.build();
+
+        boolean canRealign = msaService.getName().contains("lustal");
+        var client = new SlivkaAlignmentWSClient(slivkaService);
+        var actionBuilder = AlignmentAction.newBuilder(client);
+        actionBuilder.name("Alignment");
+        actionBuilder.webService(msaService);
+        if (canRealign)
+          actionBuilder.subcategory("Align");
+        actionBuilder.minSequences(2);
+        msaService.addAction(actionBuilder.build());
+        if (canRealign)
+        {
+          actionBuilder.name("Re-alignment");
+          actionBuilder.subcategory("Realign");
+          actionBuilder.submitGaps(true);
+          msaService.addAction(actionBuilder.build());
+        }
+        allServices.add(msaService);
+      }
+      else if (serviceClass == SERVICE_CLASS_PROT_SEQ_ANALYSIS)
+      {
+        var wsb = WebService.<AnnotationAction> newBuilder();
+        initServiceBuilder(slivkaService, wsb);
+        wsb.category("Protein Disorder");
+        wsb.interactive(false);
+        wsb.actionClass(AnnotationAction.class);
+        var psaService = wsb.build();
+        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var actionBuilder = AnnotationAction.newBuilder(client);
+        actionBuilder.webService(psaService);
+        actionBuilder.name("Analysis");
+        psaService.addAction(actionBuilder.build());
+        allServices.add(psaService);
+      }
+      else if (serviceClass == SERVICE_CLASS_CONSERVATION)
+      {
+        var wsb = WebService.<AnnotationAction> newBuilder();
+        initServiceBuilder(slivkaService, wsb);
+        wsb.category("Conservation");
+        wsb.interactive(true);
+        wsb.actionClass(AnnotationAction.class);
+        var conService = wsb.build();
+        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var actionBuilder = AnnotationAction.newBuilder(client);
+        actionBuilder.webService(conService);
+        actionBuilder.name("");
+        actionBuilder.alignmentAnalysis(true);
+        actionBuilder.requireAlignedSequences(true);
+        actionBuilder.filterSymbols(true);
+        conService.addAction(actionBuilder.build());
+        allServices.add(conService);
+      }
+      else if (serviceClass == SERVICE_CLASS_RNA_SEC_STR_PRED)
+      {
+        var wsb = WebService.<AnnotationAction> newBuilder();
+        initServiceBuilder(slivkaService, wsb);
+        wsb.category("Secondary Structure Prediction");
+        wsb.interactive(true);
+        wsb.actionClass(AnnotationAction.class);
+        var predService = wsb.build();
+        var client = new SlivkaAnnotationWSClient(slivkaService);
+        var actionBuilder = AnnotationAction.newBuilder(client);
+        actionBuilder.webService(predService);
+        actionBuilder.name("Prediction");
+        actionBuilder.minSequences(2);
+        actionBuilder.allowNucleotide(true);
+        actionBuilder.allowProtein(false);
+        actionBuilder.alignmentAnalysis(true);
+        actionBuilder.requireAlignedSequences(true);
+        actionBuilder.filterSymbols(false);
+        predService.addAction(actionBuilder.build());
+        allServices.add(predService);
+      }
+      else
+      {
+        continue;
+      }
+    }
+    return allServices;
+  }
+
+  private void initServiceBuilder(SlivkaService service, WebService.Builder<?> wsBuilder)
+  {
+    try
+    {
+      wsBuilder.url(service.getClient().getUrl().toURL());
+    } catch (MalformedURLException e)
+    {
+      e.printStackTrace();
+    }
+    wsBuilder.clientName("slivka");
+    wsBuilder.name(service.getName());
+    wsBuilder.description(service.getDescription());
+    var storeBuilder = new SlivkaParamStoreFactory(service, paramManager);
+    wsBuilder.paramDatastore(storeBuilder.createParamDatastore());
+  }
+
+  static final int SERVICE_CLASS_UNSUPPORTED = -1;
+
+  static final int SERVICE_CLASS_MSA = 1;
+
+  static final int SERVICE_CLASS_RNA_SEC_STR_PRED = 2;
+
+  static final int SERVICE_CLASS_CONSERVATION = 3;
+
+  static final int SERVICE_CLASS_PROT_SEQ_ANALYSIS = 4;
+
+  static final int SERVICE_CLASS_PROT_SEC_STR_PRED = 5;
+
+  /**
+   * Scan service classifiers starting with operation :: analysis to decide the
+   * operation class.
+   * 
+   * @return service class flag
+   */
+  private static int getServiceClass(SlivkaService service)
+  {
+    for (String classifier : service.getClassifiers())
+    {
+      String[] path = classifier.split("\\s*::\\s*");
+      if (path.length < 3 || !path[0].equalsIgnoreCase("operation") ||
+          !path[1].equalsIgnoreCase("analysis"))
+        continue;
+      // classifier is operation :: analysis :: *
+      var tail = path[path.length - 1].toLowerCase();
+      switch (tail)
+      {
+      case "multiple sequence alignment":
+        return SERVICE_CLASS_MSA;
+      case "rna secondary structure prediction":
+        return SERVICE_CLASS_RNA_SEC_STR_PRED;
+      case "sequence alignment analysis (conservation)":
+        return SERVICE_CLASS_CONSERVATION;
+      case "protein sequence analysis":
+        return SERVICE_CLASS_PROT_SEQ_ANALYSIS;
+      case "protein secondary structure prediction":
+        return SERVICE_CLASS_PROT_SEC_STR_PRED;
+      }
+    }
+    return SERVICE_CLASS_UNSUPPORTED;
+  }
+}
diff --git a/src/jalview/ws2/gui/AlignmentServiceGuiHandler.java b/src/jalview/ws2/gui/AlignmentServiceGuiHandler.java
new file mode 100644 (file)
index 0000000..b484ccc
--- /dev/null
@@ -0,0 +1,300 @@
+package jalview.ws2.gui;
+
+import static java.util.Objects.requireNonNullElse;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+
+import javax.swing.JInternalFrame;
+import javax.swing.SwingUtilities;
+
+import jalview.bin.Cache;
+import jalview.datamodel.Alignment;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AlignmentOrder;
+import jalview.datamodel.HiddenColumns;
+import jalview.gui.AlignFrame;
+import jalview.gui.Desktop;
+import jalview.gui.JvOptionPane;
+import jalview.gui.SplitFrame;
+import jalview.gui.WebserviceInfo;
+import jalview.util.ArrayUtils;
+import jalview.util.MessageManager;
+import jalview.util.Pair;
+import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.alignment.AlignmentResult;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebService;
+import jalview.ws2.helpers.WSClientTaskWrapper;
+
+class AlignmentServiceGuiHandler
+    implements TaskEventListener<AlignmentResult>
+{
+  private final WebService<?> service;
+
+  private final AlignFrame frame;
+
+  private WebserviceInfo infoPanel;
+
+  private String alnTitle; // title of the alignment used in new window
+
+  private JobI[] jobs = new JobI[0];
+
+  private int[] tabs = new int[0];
+
+  private int[] logOffset = new int[0];
+
+  private int[] errLogOffset = new int[0];
+
+  public AlignmentServiceGuiHandler(AlignmentAction action, AlignFrame frame)
+  {
+    this.service = action.getWebService();
+    this.frame = frame;
+    String panelInfo = String.format("%s using service hosted at %s%n%s",
+        service.getName(), service.getUrl(), service.getDescription());
+    infoPanel = new WebserviceInfo(service.getName(), panelInfo, false);
+    String actionName = requireNonNullElse(action.getName(), "Alignment");
+    alnTitle = String.format("%s %s of %s", service.getName(), actionName,
+        frame.getTitle());
+  }
+
+  @Override
+  public void taskStatusChanged(TaskI<AlignmentResult> source, JobStatus status)
+  {
+    switch (status)
+    {
+    case INVALID:
+      infoPanel.setVisible(false);
+      JvOptionPane.showMessageDialog(frame,
+          MessageManager.getString("info.invalid_msa_input_mininfo"),
+          MessageManager.getString("info.invalid_msa_notenough"),
+          JvOptionPane.INFORMATION_MESSAGE);
+      break;
+    case READY:
+      infoPanel.setthisService(new WSClientTaskWrapper(source));
+      infoPanel.setVisible(true);
+      // intentional no break
+    case SUBMITTED:
+    case QUEUED:
+      infoPanel.setStatus(WebserviceInfo.STATE_QUEUING);
+      break;
+    case RUNNING:
+    case UNKNOWN: // unsure what to do with unknown
+      infoPanel.setStatus(WebserviceInfo.STATE_RUNNING);
+      break;
+    case COMPLETED:
+      infoPanel.setProgressBar(
+          MessageManager.getString("status.collecting_job_results"),
+          jobs[0].getInternalId());
+      infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_OK);
+      break;
+    case FAILED:
+      infoPanel.removeProgressBar(jobs[0].getInternalId());
+      infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_ERROR);
+      break;
+    case CANCELLED:
+      infoPanel.setStatus(WebserviceInfo.STATE_CANCELLED_OK);
+      break;
+    case SERVER_ERROR:
+      infoPanel.removeProgressBar(jobs[0].getInternalId());
+      infoPanel.setStatus(WebserviceInfo.STATE_STOPPED_SERVERERROR);
+      break;
+    }
+  }
+
+  @Override
+  public void taskStarted(TaskI<AlignmentResult> source, List<? extends JobI> subJobs)
+  {
+    jobs = subJobs.toArray(new JobI[0]);
+    tabs = new int[subJobs.size()];
+    logOffset = new int[subJobs.size()];
+    errLogOffset = new int[subJobs.size()];
+    for (int i = 0; i < subJobs.size(); i++)
+    {
+      JobI job = jobs[i];
+      int tabIndex = infoPanel.addJobPane();
+      tabs[i] = tabIndex;
+      infoPanel.setProgressName(String.format("region %d", i), tabIndex);
+      infoPanel.setProgressText(tabIndex, alnTitle + "\nJob details\n");
+      // jobs should not have states other than invalid or ready at this point
+      if (job.getStatus() == JobStatus.INVALID)
+        infoPanel.setStatus(tabIndex, WebserviceInfo.STATE_STOPPED_OK);
+      else if (job.getStatus() == JobStatus.READY)
+        infoPanel.setStatus(tabIndex, WebserviceInfo.STATE_QUEUING);
+    }
+  }
+
+  @Override
+  public void taskCompleted(TaskI<AlignmentResult> source, AlignmentResult result)
+  {
+    SwingUtilities.invokeLater(() -> infoPanel.removeProgressBar(jobs[0].getInternalId()));
+    if (result == null)
+    {
+      SwingUtilities.invokeLater(infoPanel::setFinishedNoResults);
+      return;
+    }
+    infoPanel.showResultsNewFrame.addActionListener(evt -> {
+      var aln = result.getAlignment();
+      // copy alignment for each frame to have its own isntance
+      var alnCpy = new Alignment(aln);
+      alnCpy.setGapCharacter(aln.getGapCharacter());
+      alnCpy.setDataset(aln.getDataset());
+      displayResultsNewFrame(alnCpy, result.getAlignmentOrders(),
+          result.getHiddenColumns());
+    });
+    SwingUtilities.invokeLater(infoPanel::setResultsReady);
+  }
+
+  private void displayResultsNewFrame(Alignment aln,
+      List<AlignmentOrder> alorders, HiddenColumns hidden)
+  {
+    AlignFrame newFrame = new AlignFrame(aln, hidden, AlignFrame.DEFAULT_WIDTH,
+        AlignFrame.DEFAULT_HEIGHT);
+    newFrame.getFeatureRenderer().transferSettings(
+        frame.getFeatureRenderer().getSettings());
+    if (alorders.size() > 0)
+    {
+      addSortByMenuItems(newFrame, alorders);
+    }
+
+    var requestingFrame = frame;
+    var splitContainer = requestingFrame.getSplitViewContainer();
+    if (splitContainer != null && splitContainer.getComplement(requestingFrame) != null)
+    {
+      AlignmentI complement = splitContainer.getComplement(requestingFrame);
+      String complementTitle = splitContainer.getComplementTitle(requestingFrame);
+      Alignment copyComplement = new Alignment(complement);
+      copyComplement.setGapCharacter(complement.getGapCharacter());
+      copyComplement.setDataset(complement.getDataset());
+      copyComplement.alignAs(aln);
+      if (copyComplement.getHeight() > 0)
+      {
+        newFrame.setTitle(alnTitle);
+        AlignFrame newFrame2 = new AlignFrame(copyComplement,
+            AlignFrame.DEFAULT_WIDTH, AlignFrame.DEFAULT_HEIGHT);
+        newFrame2.setTitle(complementTitle);
+        String linkedTitle = MessageManager.getString("label.linked_view_title");
+        JInternalFrame splitFrame = new SplitFrame(
+            aln.isNucleotide() ? newFrame : newFrame2,
+            aln.isNucleotide() ? newFrame2 : newFrame);
+        Desktop.addInternalFrame(splitFrame, linkedTitle, -1, -1);
+        return;
+      }
+    }
+    // no split frame or failed to create complementary alignment
+    Desktop.addInternalFrame(newFrame, alnTitle, AlignFrame.DEFAULT_WIDTH,
+        AlignFrame.DEFAULT_HEIGHT);
+  }
+  
+  private void addSortByMenuItems(AlignFrame frame, List<AlignmentOrder> alorders)
+  {
+    if (alorders.size() == 1)
+    {
+      frame.addSortByOrderMenuItem(service.getName() + " Ordering",
+          alorders.get(0));
+      return;
+    }
+    BitSet collected = new BitSet(alorders.size());
+    for (int i = 0, N = alorders.size(); i < N; i++)
+    {
+      if (collected.get(i))
+        continue;
+      var regions = new ArrayList<String>();
+      var order = alorders.get(i);
+      for (int j = i; j < N; j++)
+      {
+        if (!collected.get(j) && alorders.get(j).equals(order))
+        {
+          regions.add(Integer.toString(j + 1));
+          collected.set(j);
+        }
+      }
+      var orderName = String.format("%s Region %s Ordering",
+          service.getName(), String.join(",", regions));
+      frame.addSortByOrderMenuItem(orderName, order);
+    }
+  }
+
+  @Override
+  public void taskException(TaskI<AlignmentResult> source, Exception e)
+  {
+    Cache.log.error(String.format("Service %s raised an exception.", service.getName()), e);
+    infoPanel.appendProgressText(e.getMessage());
+  }
+
+  @Override
+  public void taskRestarted(TaskI<AlignmentResult> source)
+  {
+    // alignment services are not restartable
+  }
+
+  @Override
+  public void subJobStatusChanged(TaskI<AlignmentResult> source, JobI job, JobStatus status)
+  {
+    int i = ArrayUtils.indexOf(jobs, job);
+    assert i >= 0 : "job does not exist";
+    if (i < 0)
+      // safeguard that should not happen irl
+      return;
+    int wsStatus;
+    switch (status)
+    {
+    case INVALID:
+    case COMPLETED:
+      wsStatus = WebserviceInfo.STATE_STOPPED_OK;
+      break;
+    case READY:
+    case SUBMITTED:
+    case QUEUED:
+      wsStatus = WebserviceInfo.STATE_QUEUING;
+      break;
+    case RUNNING:
+    case UNKNOWN:
+      wsStatus = WebserviceInfo.STATE_RUNNING;
+      break;
+    case FAILED:
+      wsStatus = WebserviceInfo.STATE_STOPPED_ERROR;
+      break;
+    case CANCELLED:
+      wsStatus = WebserviceInfo.STATE_CANCELLED_OK;
+      break;
+    case SERVER_ERROR:
+      wsStatus = WebserviceInfo.STATE_STOPPED_SERVERERROR;
+      break;
+    default:
+      throw new AssertionError("Non-exhaustive switch statement");
+    }
+    infoPanel.setStatus(tabs[i], wsStatus);
+  }
+
+  @Override
+  public void subJobLogChanged(TaskI<AlignmentResult> source, JobI job, String log)
+  {
+    int i = ArrayUtils.indexOf(jobs, job);
+    assert i >= 0 : "job does not exist";
+    if (i < 0)
+      // safeguard that should never happen
+      return;
+    infoPanel.appendProgressText(tabs[i], log.substring(logOffset[i]));
+  }
+
+  @Override
+  public void subJobErrorLogChanged(TaskI<AlignmentResult> source, JobI job, String log)
+  {
+    int i = ArrayUtils.indexOf(jobs, job);
+    assert i >= 0 : "job does not exist";
+    if (i < 0)
+      // safeguard that should never happen
+      return;
+    infoPanel.appendProgressText(tabs[i], log.substring(errLogOffset[i]));
+  }
+
+}
diff --git a/src/jalview/ws2/gui/AnnotationServiceGuiHandler.java b/src/jalview/ws2/gui/AnnotationServiceGuiHandler.java
new file mode 100644 (file)
index 0000000..43e2680
--- /dev/null
@@ -0,0 +1,120 @@
+package jalview.ws2.gui;
+
+import java.util.List;
+
+import jalview.gui.AlignFrame;
+import jalview.gui.AlignmentPanel;
+import jalview.gui.IProgressIndicator;
+import jalview.gui.IProgressIndicatorHandler;
+import jalview.ws2.actions.annotation.AnnotationAction;
+import jalview.ws2.actions.annotation.AnnotationResult;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.JobStatus;
+
+public class AnnotationServiceGuiHandler
+    implements TaskEventListener<AnnotationResult>
+{
+  private final AlignFrame alignFrame;
+
+  private final AlignmentPanel alignPanel;
+
+  private final IProgressIndicator progressIndicator;
+
+  private final AnnotationAction action;
+
+  public AnnotationServiceGuiHandler(AnnotationAction action, AlignFrame frame)
+  {
+    this.alignFrame = frame;
+    this.alignPanel = frame.alignPanel;
+    this.progressIndicator = frame;
+    this.action = action;
+  }
+
+  @Override
+  public void taskStarted(TaskI<AnnotationResult> source, List<? extends JobI> subJobs)
+  {
+    progressIndicator.registerHandler(source.getUid(),
+        new IProgressIndicatorHandler()
+        {
+          @Override
+          public boolean cancelActivity(long id)
+          {
+            source.cancel();
+            return true;
+          }
+
+          @Override
+          public boolean canCancel()
+          {
+            return true;
+          }
+        });
+  }
+
+  @Override
+  public void taskStatusChanged(TaskI<AnnotationResult> source, JobStatus status)
+  {
+    switch (status)
+    {
+    case INVALID:
+    case COMPLETED:
+    case CANCELLED:
+    case FAILED:
+    case SERVER_ERROR:
+      progressIndicator.removeProgressBar(source.getUid());
+      break;
+    case READY:
+    case SUBMITTED:
+    case QUEUED:
+    case RUNNING:
+    case UNKNOWN:
+      progressIndicator.addProgressBar(source.getUid(), action.getFullName());
+      break;
+    }
+  }
+
+  @Override
+  public void taskCompleted(TaskI<AnnotationResult> source, AnnotationResult result)
+  {
+    if (result == null)
+      return;
+    if (result.getTransferFeatures() && alignFrame.alignPanel == alignPanel)
+    {
+      alignFrame.getViewport().setShowSequenceFeatures(true);
+      alignFrame.setMenusForViewport();
+    }
+    alignPanel.adjustAnnotationHeight();
+  }
+
+  @Override
+  public void taskException(TaskI<AnnotationResult> source, Exception e)
+  {
+
+  }
+
+  @Override
+  public void taskRestarted(TaskI<AnnotationResult> source)
+  {
+
+  }
+
+  @Override
+  public void subJobStatusChanged(TaskI<AnnotationResult> source, JobI job, JobStatus status)
+  {
+
+  }
+
+  @Override
+  public void subJobLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
+  {
+
+  }
+
+  @Override
+  public void subJobErrorLogChanged(TaskI<AnnotationResult> source, JobI job, String log)
+  {
+
+  }
+}
diff --git a/src/jalview/ws2/gui/WebServicesMenuManager.java b/src/jalview/ws2/gui/WebServicesMenuManager.java
new file mode 100644 (file)
index 0000000..2c405ff
--- /dev/null
@@ -0,0 +1,490 @@
+package jalview.ws2.gui;
+
+import java.awt.Color;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+import java.lang.ref.WeakReference;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.concurrent.CompletionStage;
+
+import javax.swing.JCheckBoxMenuItem;
+import javax.swing.JLabel;
+import javax.swing.JMenu;
+import javax.swing.JMenuItem;
+import javax.swing.ToolTipManager;
+import javax.swing.border.EmptyBorder;
+
+import jalview.gui.AlignFrame;
+import jalview.gui.Desktop;
+import jalview.gui.JvSwingUtils;
+import jalview.gui.WsJobParameters;
+import jalview.util.MessageManager;
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws.params.WsParamSetI;
+import jalview.ws2.actions.alignment.AlignmentAction;
+import jalview.ws2.actions.annotation.AnnotationAction;
+import jalview.ws2.actions.api.ActionI;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.WebService;
+import jalview.ws2.client.api.WebServiceProviderI;
+
+import static java.lang.String.format;
+import static java.util.Objects.requireNonNullElse;
+
+public class WebServicesMenuManager
+{
+  private final JMenu menu;
+
+  private final AlignFrame frame;
+
+  private JMenuItem inProgressItem = new JMenuItem("Service discovery in progress");
+
+  private JMenuItem noServicesItem = new JMenuItem("No services available");
+  {
+    inProgressItem.setEnabled(false);
+    inProgressItem.setVisible(false);
+    noServicesItem.setEnabled(false);
+  }
+
+  private Map<String, WeakReference<TaskI<?>>> interactiveTasks = new HashMap<>();
+
+  public WebServicesMenuManager(String name, AlignFrame frame)
+  {
+    this.frame = frame;
+    menu = new JMenu(name);
+    menu.add(inProgressItem);
+    menu.add(noServicesItem);
+  }
+
+  public JMenu getMenu()
+  {
+    return menu;
+  }
+
+  public void setNoServices(boolean noServices)
+  {
+    noServicesItem.setVisible(noServices);
+  }
+
+  public void setInProgress(boolean inProgress)
+  {
+    inProgressItem.setVisible(inProgress);
+  }
+
+  public void setServices(WebServiceProviderI services)
+  {
+    menu.removeAll();
+    // services grouped by their category
+    Map<String, List<WebService<?>>> oneshotServices = new HashMap<>();
+    Map<String, List<WebService<?>>> interactiveServices = new HashMap<>();
+    for (WebService<?> service : services.getServices())
+    {
+      var map = service.isInteractive() ? interactiveServices : oneshotServices;
+      map.computeIfAbsent(service.getCategory(), k -> new ArrayList<>())
+          .add(service);
+    }
+    var allKeysSet = new HashSet<>(oneshotServices.keySet());
+    allKeysSet.addAll(interactiveServices.keySet());
+    var allKeys = new ArrayList<>(allKeysSet);
+    allKeys.sort(Comparator.naturalOrder());
+    for (String category : allKeys)
+    {
+      var categoryMenu = new JMenu(category);
+      var oneshot = oneshotServices.get(category);
+      if (oneshot != null)
+        addOneshotEntries(oneshot, categoryMenu);
+      var interactive = interactiveServices.get(category);
+      if (interactive != null)
+      {
+        if (oneshot != null)
+          categoryMenu.addSeparator();
+        addInteractiveEntries(interactive, categoryMenu);
+      }
+      menu.add(categoryMenu);
+    }
+    menu.add(inProgressItem);
+    menu.add(noServicesItem);
+  }
+
+  private void addOneshotEntries(List<WebService<?>> services, JMenu menu)
+  {
+    services.sort(Comparator
+        .<WebService<?>, String> comparing(s -> s.getUrl().toString())
+        .thenComparing(WebService::getName));
+    URL lastHost = null;
+    for (WebService<?> service : services)
+    {
+      // if new host differs from the last one, add entry separating them
+      URL host = service.getUrl();
+      if (!host.equals(lastHost))
+      {
+        if (lastHost != null)
+          menu.addSeparator();
+        var item = new JMenuItem(host.toString());
+        item.setForeground(Color.BLUE);
+        item.addActionListener(e -> Desktop.showUrl(host.toString()));
+        menu.add(item);
+        lastHost = host;
+      }
+      menu.addSeparator();
+      // group actions by their subcategory, sorted
+      var actionsByCategory = new TreeMap<String, List<ActionI<?>>>();
+      for (ActionI<?> action : service.getActions())
+      {
+        actionsByCategory
+            .computeIfAbsent(
+                Objects.requireNonNullElse(action.getSubcategory(), ""),
+                k -> new ArrayList<>())
+            .add(action);
+      }
+      actionsByCategory.forEach((k, v) -> {
+        // create submenu named {subcategory} with {service} or use root menu
+        var atMenu = k.isEmpty() ? menu : new JMenu(String.format("%s with %s", k, service.getName()));
+        if (atMenu != menu)
+          menu.add(atMenu); // add only if submenu
+        // sort actions by name pulling nulls to the front
+        v.sort(Comparator.comparing(
+            ActionI::getName, Comparator.nullsFirst(Comparator.naturalOrder())));
+        for (ActionI<?> action : v)
+        {
+          addEntriesForAction(action, atMenu, atMenu == menu);
+        }
+      });
+    }
+  }
+
+  private void addEntriesForAction(ActionI<?> action, JMenu menu, boolean isTopLevel)
+  {
+    var service = action.getWebService();
+    String itemName;
+    if (isTopLevel)
+    {
+      itemName = service.getName();
+      if (action.getName() != null && !action.getName().isEmpty())
+        itemName += " " + action.getName();
+    }
+    else
+    {
+      if (action.getName() == null || action.getName().isEmpty())
+        itemName = "Run";
+      else
+        itemName = action.getName();
+    }
+    var datastore = service.getParamDatastore();
+    {
+      String text = itemName;
+      if (datastore.hasParameters() || datastore.hasPresets())
+        text += " with defaults";
+      JMenuItem item = new JMenuItem(text);
+      item.addActionListener(e -> {
+        runAction(action, frame.getCurrentView(), Collections.emptyList(),
+            Credentials.empty());
+      });
+      menu.add(item);
+    }
+    if (datastore.hasParameters())
+    {
+      JMenuItem item = new JMenuItem("Edit settings and run...");
+      item.addActionListener(e -> {
+        openEditParamsDialog(datastore, null, null).thenAccept(args -> {
+          if (args != null)
+            runAction(action, frame.getCurrentView(), args, Credentials.empty());
+        });
+      });
+      menu.add(item);
+    }
+    var presets = datastore.getPresets();
+    if (presets != null && presets.size() > 0)
+    {
+      final var presetsMenu = new JMenu(MessageManager.formatMessage(
+          "label.run_with_preset_params", service.getName()));
+      final int dismissDelay = ToolTipManager.sharedInstance()
+          .getDismissDelay();
+      final int QUICK_TOOLTIP = 1500;
+      for (var preset : presets)
+      {
+        var item = new JMenuItem(preset.getName());
+        item.addMouseListener(new MouseAdapter()
+        {
+          @Override
+          public void mouseEntered(MouseEvent evt)
+          {
+            ToolTipManager.sharedInstance().setDismissDelay(QUICK_TOOLTIP);
+          }
+
+          @Override
+          public void mouseExited(MouseEvent evt)
+          {
+            ToolTipManager.sharedInstance().setDismissDelay(dismissDelay);
+          }
+        });
+        String tooltipTitle = MessageManager.getString(
+            preset.isModifiable() ? "label.user_preset" : "label.service_preset");
+        String tooltip = String.format("<strong>%s</strong><br/>%s",
+            tooltipTitle, preset.getDescription());
+        tooltip = JvSwingUtils.wrapTooltip(true, tooltip);
+        item.setToolTipText(tooltip);
+        item.addActionListener(event -> {
+          runAction(action, frame.getCurrentView(), preset.getArguments(),
+              Credentials.empty());
+        });
+        presetsMenu.add(item);
+      }
+      menu.add(presetsMenu);
+    }
+  }
+
+  private void addInteractiveEntries(List<WebService<?>> services, JMenu menu)
+  {
+    Map<String, List<WebService<?>>> byServiceName = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+    for (var service : services)
+    {
+      byServiceName.computeIfAbsent(service.getName(), k -> new ArrayList<>())
+          .add(service);
+    }
+    for (var entry : byServiceName.entrySet())
+    {
+      var group = new InteractiveServiceEntryGroup(entry.getKey(), entry.getValue());
+      group.appendTo(menu);
+    }
+  }
+
+  private class InteractiveServiceEntryGroup
+  {
+    JLabel serviceLabel;
+
+    JMenuItem urlItem = new JMenuItem();
+
+    JCheckBoxMenuItem serviceItem = new JCheckBoxMenuItem();
+
+    JMenuItem editParamsItem = new JMenuItem("Edit parameters...");
+
+    JMenu presetsMenu = new JMenu("Change preset");
+
+    JMenu alternativesMenu = new JMenu("Choose action");
+    {
+      urlItem.setForeground(Color.BLUE);
+      urlItem.setVisible(false);
+      serviceItem.setVisible(false);
+      editParamsItem.setVisible(false);
+      presetsMenu.setVisible(false);
+    }
+
+    InteractiveServiceEntryGroup(String name, List<WebService<?>> services)
+    {
+      serviceLabel = new JLabel(name);
+      serviceLabel.setBorder(new EmptyBorder(0, 6, 0, 6));
+      buildAlternativesMenu(services);
+    }
+
+    private void buildAlternativesMenu(List<WebService<?>> services)
+    {
+      var menu = alternativesMenu;
+      services.sort(Comparator
+          .<WebService<?>, String> comparing(s -> s.getUrl().toString())
+          .thenComparing(s -> s.getName()));
+      URL lastHost = null;
+      for (var service : services)
+      {
+        // Adding url "separator" before each group
+        URL host = service.getUrl();
+        if (!host.equals(lastHost))
+        {
+          if (lastHost != null)
+            menu.addSeparator();
+          var item = new JMenuItem(host.toString());
+          item.setForeground(Color.BLUE);
+          item.addActionListener(e -> Desktop.showUrl(host.toString()));
+          menu.add(item);
+          lastHost = host;
+        }
+        menu.addSeparator();
+        var actionsByCategory = new TreeMap<String, List<ActionI<?>>>();
+        for (ActionI<?> action : service.getActions())
+        {
+          actionsByCategory
+              .computeIfAbsent(
+                  requireNonNullElse(action.getSubcategory(), ""),
+                  k -> new ArrayList<>())
+              .add(action);
+        }
+        actionsByCategory.forEach((key, actions) -> {
+          var atMenu = key.isEmpty() ? menu : new JMenu(key + " with " + service.getName());
+          boolean topLevel = atMenu == menu;
+          if (!topLevel)
+            menu.add(atMenu);
+          actions.sort(Comparator.comparing(
+              a -> a.getName(),
+              Comparator.nullsFirst(Comparator.naturalOrder())));
+          for (ActionI<?> action : actions)
+          {
+            var item = new JMenuItem(action.getFullName());
+            item.addActionListener(e -> setAlternative(action));
+            atMenu.add(item);
+          }
+        });
+      }
+    }
+
+    private void setAlternative(ActionI<?> action)
+    {
+      final var arguments = new ArrayList<ArgumentI>();
+      final WsParamSetI[] lastPreset = { null };
+
+      // update selected url menu item
+      String url = action.getWebService().getUrl().toString();
+      urlItem.setText(url);
+      urlItem.setVisible(true);
+      for (var l : urlItem.getActionListeners())
+        urlItem.removeActionListener(l);
+      urlItem.addActionListener(e -> Desktop.showUrl(url));
+
+      // update selected service menu item
+      serviceItem.setText(action.getFullName());
+      serviceItem.setVisible(true);
+      for (var l : serviceItem.getActionListeners())
+        serviceItem.removeActionListener(l);
+      WebService<?> service = action.getWebService();
+      serviceItem.addActionListener(e -> {
+        if (serviceItem.getState())
+        {
+          cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+              Credentials.empty());
+        }
+        else
+        {
+          cancelInteractive(service.getName());
+        }
+      });
+      serviceItem.setSelected(true);
+
+      // update edit parameters menu item
+      var datastore = service.getParamDatastore();
+      editParamsItem.setVisible(datastore.hasParameters());
+      for (var l : editParamsItem.getActionListeners())
+        editParamsItem.removeActionListener(l);
+      if (datastore.hasParameters())
+      {
+        editParamsItem.addActionListener(e -> {
+          openEditParamsDialog(service.getParamDatastore(), lastPreset[0], arguments)
+              .thenAccept(args -> {
+                if (args != null)
+                {
+                  lastPreset[0] = null;
+                  arguments.clear();
+                  arguments.addAll(args);
+                  cancelAndRunInteractive(action, frame.getCurrentView(),
+                      arguments, Credentials.empty());
+                }
+              });
+        });
+      }
+
+      // update presets menu
+      presetsMenu.removeAll();
+      presetsMenu.setEnabled(datastore.hasPresets());
+      if (datastore.hasPresets())
+      {
+        for (WsParamSetI preset : datastore.getPresets())
+        {
+          var item = new JMenuItem(preset.getName());
+          item.addActionListener(e -> {
+            lastPreset[0] = preset;
+            cancelAndRunInteractive(action, frame.getCurrentView(),
+                preset.getArguments(), Credentials.empty());
+          });
+          presetsMenu.add(item);
+        }
+      }
+
+      cancelAndRunInteractive(action, frame.getCurrentView(), arguments,
+          Credentials.empty());
+    }
+
+    void appendTo(JMenu menu)
+    {
+      menu.add(serviceLabel);
+      menu.add(urlItem);
+      menu.add(serviceItem);
+      menu.add(editParamsItem);
+      menu.add(presetsMenu);
+      menu.add(alternativesMenu);
+    }
+  }
+
+  private void cancelInteractive(String wsName)
+  {
+    var taskRef = interactiveTasks.get(wsName);
+    if (taskRef != null && taskRef.get() != null)
+      taskRef.get().cancel();
+    interactiveTasks.put(wsName, null);
+  }
+
+  private void cancelAndRunInteractive(ActionI<?> action,
+      AlignmentViewport viewport, List<ArgumentI> args, Credentials credentials)
+  {
+    var wsName = action.getWebService().getName();
+    cancelInteractive(wsName);
+    var task = runAction(action, viewport, args, credentials);
+    interactiveTasks.put(wsName, new WeakReference<>(task));
+  }
+
+  private TaskI<?> runAction(ActionI<?> action, AlignmentViewport viewport,
+      List<ArgumentI> args, Credentials credentials)
+  {
+    // casting and instance checks can be avoided with some effort,
+    // let them be for now.
+    if (action instanceof AlignmentAction)
+    {
+      // TODO: test if selection contains enough sequences
+      var _action = (AlignmentAction) action;
+      var handler = new AlignmentServiceGuiHandler(_action, frame);
+      return _action.perform(viewport, args, credentials, handler);
+    }
+    if (action instanceof AnnotationAction)
+    {
+      var _action = (AnnotationAction) action;
+      var handler = new AnnotationServiceGuiHandler(_action, frame);
+      return _action.perform(viewport, args, credentials, handler);
+    }
+    throw new IllegalArgumentException(
+        String.format("Illegal action type %s", action.getClass().getName()));
+  }
+
+  private static CompletionStage<List<ArgumentI>> openEditParamsDialog(
+      ParamDatastoreI paramStore, WsParamSetI preset, List<ArgumentI> arguments)
+  {
+    final WsJobParameters jobParams;
+    if (preset == null && arguments != null && arguments.size() > 0)
+      jobParams = new WsJobParameters(paramStore, null, arguments);
+    else
+      jobParams = new WsJobParameters(paramStore, preset, null);
+    if (preset != null)
+      jobParams.setName(MessageManager.getString(
+          "label.adjusting_parameters_for_calculation"));
+    var stage = jobParams.showRunDialog();
+    return stage.thenApply(startJob -> {
+      if (!startJob)
+        return null; // null if cancelled
+      if (jobParams.getPreset() != null)
+        return jobParams.getPreset().getArguments();
+      if (jobParams.isServiceDefaults())
+        return Collections.emptyList();
+      else
+        return jobParams.getJobParams();
+    });
+  }
+}
diff --git a/src/jalview/ws2/helpers/DelegateJobEventListener.java b/src/jalview/ws2/helpers/DelegateJobEventListener.java
new file mode 100644 (file)
index 0000000..45afefc
--- /dev/null
@@ -0,0 +1,73 @@
+package jalview.ws2.helpers;
+
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+
+import jalview.ws2.actions.BaseJob;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.api.JobStatus;
+
+/**
+ * A property change listener to be used by web service tasks that delegates all
+ * sub-job related events from {@link BaseJob} subclasses to
+ * {@link TaskEventSupport}. Tasks can create one instance of this class with
+ * their event handler as a delegate and add it as a property change listener to
+ * each sub-job supporting property change listeners. It ensures that an
+ * appropriate {@code fireSubJob*Changed} method of the delegate object will be
+ * called whenever a {@link PropertyChagneEvent} is emitted by the sub-job.
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ *          result type of the task
+ */
+public class DelegateJobEventListener<T> implements PropertyChangeListener
+{
+  private final TaskEventSupport<T> delegate;
+
+  public DelegateJobEventListener(TaskEventSupport<T> delegate)
+  {
+    this.delegate = delegate;
+  }
+
+  @Override
+  public void propertyChange(PropertyChangeEvent evt)
+  {
+    switch (evt.getPropertyName())
+    {
+    case "status":
+      statusChanged(evt);
+      break;
+    case "log":
+      logChanged(evt);
+      break;
+    case "errorLog":
+      errorLogChanged(evt);
+      break;
+    default:
+      throw new AssertionError(String.format(
+          "illegal property name \"%s\"", evt.getPropertyName()));
+    }
+  }
+
+  private void statusChanged(PropertyChangeEvent evt)
+  {
+    JobI job = (JobI) evt.getSource();
+    JobStatus status = (JobStatus) evt.getNewValue();
+    delegate.fireSubJobStatusChanged(job, status);
+  }
+
+  private void logChanged(PropertyChangeEvent evt)
+  {
+    JobI job = (JobI) evt.getSource();
+    String log = (String) evt.getNewValue();
+    delegate.fireSubJobLogChanged(job, log);
+  }
+
+  private void errorLogChanged(PropertyChangeEvent evt)
+  {
+    JobI job = (JobI) evt.getSource();
+    String errorLog = (String) evt.getNewValue();
+    delegate.fireSubJobErrorLogChanged(job, errorLog);
+  }
+}
diff --git a/src/jalview/ws2/helpers/TaskEventSupport.java b/src/jalview/ws2/helpers/TaskEventSupport.java
new file mode 100644 (file)
index 0000000..c7b6052
--- /dev/null
@@ -0,0 +1,60 @@
+package jalview.ws2.helpers;
+
+import java.util.List;
+
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.JobStatus;
+
+public class TaskEventSupport<T>
+{
+  TaskI<T> source;
+  TaskEventListener<T> handler;
+  
+  public TaskEventSupport(TaskI<T> source, TaskEventListener<T> handler)
+  {
+    this.source = source;
+    this.handler = handler;
+  }
+  
+  public void fireTaskStarted(List<? extends JobI> subJobs)
+  {
+    handler.taskStarted(source, subJobs);
+  }
+  
+  public void fireTaskStatusChanged(JobStatus status)
+  {
+    handler.taskStatusChanged(source, status);
+  }
+  
+  public void fireTaskCompleted(T result)
+  {
+    handler.taskCompleted(source, result);
+  }
+  
+  public void fireTaskException(Exception e)
+  {
+    handler.taskException(source, e);
+  }
+  
+  public void fireTaskRestarted()
+  {
+    handler.taskRestarted(source);
+  }
+  
+  public void fireSubJobStatusChanged(JobI job, JobStatus status)
+  {
+    handler.subJobStatusChanged(source, job, status);
+  }
+  
+  public void fireSubJobLogChanged(JobI job, String log)
+  {
+    handler.subJobLogChanged(source, job, log);
+  }
+  
+  public void fireSubJobErrorLogChanged(JobI job, String log)
+  {
+    handler.subJobErrorLogChanged(source, job, log);
+  }
+}
diff --git a/src/jalview/ws2/helpers/WSClientTaskWrapper.java b/src/jalview/ws2/helpers/WSClientTaskWrapper.java
new file mode 100644 (file)
index 0000000..c9032f6
--- /dev/null
@@ -0,0 +1,52 @@
+package jalview.ws2.helpers;
+
+import jalview.gui.WebserviceInfo;
+import jalview.ws.WSClientI;
+import jalview.ws2.actions.api.TaskI;
+
+/**
+ * A simple wrapper around the {@link TaskI} implementing {@link WSClientI}. Its
+ * main purpose is to delegate the call to {@link #cancelJob} to the underlying
+ * task.
+ * 
+ * @author mmwarowny
+ */
+public class WSClientTaskWrapper implements WSClientI
+{
+  private TaskI<?> delegate;
+
+  private boolean cancellable;
+
+  private boolean canMerge;
+
+  public WSClientTaskWrapper(TaskI<?> task, boolean cancellable, boolean canMerge)
+  {
+    this.delegate = task;
+    this.cancellable = cancellable;
+    this.canMerge = canMerge;
+  }
+
+  public WSClientTaskWrapper(TaskI<?> task)
+  {
+    this(task, true, false);
+  }
+
+  @Override
+  public boolean isCancellable()
+  {
+    return cancellable;
+  }
+
+  @Override
+  public boolean canMergeResults()
+  {
+    return canMerge;
+  }
+
+  @Override
+  public void cancelJob()
+  {
+    delegate.cancel();
+  }
+
+}
diff --git a/src/jalview/ws2/params/ArgumentBean.java b/src/jalview/ws2/params/ArgumentBean.java
new file mode 100644 (file)
index 0000000..65b29bb
--- /dev/null
@@ -0,0 +1,82 @@
+package jalview.ws2.params;
+
+import javax.xml.bind.annotation.XmlAttribute;
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import jalview.ws.params.ArgumentI;
+
+/**
+ * A minimal bean implementing {@link ArgumentI} which stores argument
+ * name, label and value. It's mainly used to marshal and unmarshal
+ * parameter values of a preset.
+ * 
+ * @author mmwarowny
+ *
+ */
+@XmlRootElement(name = "parameter")
+class ArgumentBean implements ArgumentI
+{
+  String name;
+
+  String label;
+
+  String value;
+
+  ArgumentBean()
+  {
+    this.name = null;
+    this.label = null;
+    this.value = null;
+  }
+
+  ArgumentBean(ArgumentI copyof)
+  {
+    this.name = copyof.getName();
+    this.label = copyof.getLabel();
+    this.value = copyof.getValue();
+  }
+
+  @XmlAttribute
+  @Override
+  public String getName()
+  {
+    return name;
+  }
+
+  public void setName(String name)
+  {
+    this.name = name;
+  }
+
+  @XmlElement
+  @Override
+  public String getLabel()
+  {
+    return label;
+  }
+
+  public void setLabel(String label)
+  {
+    this.label = label;
+  }
+
+  @XmlElement
+  @Override
+  public String getValue()
+  {
+    return value;
+  }
+
+  @Override
+  public void setValue(String selectedItem)
+  {
+    this.value = selectedItem;
+  }
+
+  @Override
+  public String toString()
+  {
+    return String.format("Parameter(name=%s, value=%s)", name, value);
+  }
+}
\ No newline at end of file
diff --git a/src/jalview/ws2/params/ArgumentBeanList.java b/src/jalview/ws2/params/ArgumentBeanList.java
new file mode 100644 (file)
index 0000000..87c15bd
--- /dev/null
@@ -0,0 +1,51 @@
+package jalview.ws2.params;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.annotation.XmlElement;
+import javax.xml.bind.annotation.XmlRootElement;
+
+import jalview.ws.params.ArgumentI;
+
+/**
+ * A wrapper of {@link ArgumentBean} list that can be marshaled or unmarshalled.
+ * Used by {@link SimpleParamDatastore} to read and store parameters in a file.
+ * 
+ * @see ArgumentBean
+ * @see SimpleParamDatastore
+ * @author mmwarowny
+ */
+@XmlRootElement(name = "arguments")
+class ArgumentBeanList
+{
+  @XmlElement(name = "argument")
+  public List<ArgumentBean> arguments = Collections.emptyList();
+
+  ArgumentBeanList()
+  {
+  }
+
+  ArgumentBeanList(List<ArgumentBean> arguments)
+  {
+    this.arguments = arguments;
+  }
+
+  static ArgumentBeanList fromList(List<? extends ArgumentI> list)
+  {
+    var args = new ArrayList<ArgumentBean>();
+    for (var item : list)
+      args.add(item instanceof ArgumentBean ? (ArgumentBean) item : new ArgumentBean(item));
+    return new ArgumentBeanList(args);
+  }
+
+  @Override
+  public String toString()
+  {
+    var elements = new String[arguments.size()];
+    for (int i = 0; i < arguments.size(); i++)
+      elements[i] = arguments.toString();
+    return "[" + String.join(", ", elements) + "]";
+  }
+}
\ No newline at end of file
diff --git a/src/jalview/ws2/params/SimpleParamDatastore.java b/src/jalview/ws2/params/SimpleParamDatastore.java
new file mode 100644 (file)
index 0000000..259bdca
--- /dev/null
@@ -0,0 +1,256 @@
+package jalview.ws2.params;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.StringReader;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.Marshaller;
+import javax.xml.bind.Unmarshaller;
+
+import jalview.bin.Cache;
+import jalview.util.MessageManager;
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.ParamDatastoreI;
+import jalview.ws.params.ParamManager;
+import jalview.ws.params.WsParamSetI;
+
+/**
+ * A web client agnostic parameters datastore that provides view of the
+ * parameters and delegates parameters storage to {@link ParamManager}
+ * if given. Parameter datastore maintains the applicable service url
+ * the list of service parameters and both presets and user defined
+ * parameter sets 
+ */
+public class SimpleParamDatastore implements ParamDatastoreI
+{
+  protected URL serviceUrl;
+  protected List<ArgumentI> parameters;
+  protected List<SimpleParamSet> servicePresets;
+  protected List<SimpleParamSet> userPresets = new ArrayList<>();
+  protected ParamManager manager;
+
+  /**
+   * Create new parameter datastore bound to the specified url with
+   * given service parameters and presets. Additionally, a parameters
+   * manager may be provided that will be used to load and store
+   * user parameter sets.
+   *  
+   * @param serviceUrl applicable url
+   * @param parameters service parameters
+   * @param presets unmodifiable service presets
+   * @param manager parameter manager used to load and store user presets
+   */
+  public SimpleParamDatastore(URL serviceUrl, List<ArgumentI> parameters,
+      List<? extends WsParamSetI> presets, ParamManager manager)
+  {
+    this.serviceUrl = serviceUrl;
+    this.parameters = Collections.unmodifiableList(new ArrayList<>(parameters));
+    this.servicePresets = new ArrayList<>(presets.size());
+    for (var preset : presets)
+    {
+      if (preset instanceof SimpleParamSet)
+        servicePresets.add((SimpleParamSet) preset);
+      else
+        servicePresets.add(new SimpleParamSet(preset));
+    }
+    this.servicePresets = Collections.unmodifiableList(this.servicePresets);
+    this.manager = manager;
+    if (manager != null)
+      _initManager(manager);
+  }
+  
+  private void _initManager(ParamManager manager)
+  {
+    manager.registerParser(serviceUrl.toString(), this);
+    WsParamSetI[] paramSets = manager.getParameterSet(null, serviceUrl.toString(),
+        true, false);
+    if (paramSets != null)
+    {
+      for (WsParamSetI paramSet : paramSets)
+      {
+        // TODO: handle mismatch between preset and current parameters
+        if (paramSet instanceof SimpleParamSet)
+          userPresets.add((SimpleParamSet) paramSet);
+        else
+        {
+          userPresets.add(new SimpleParamSet(paramSet));
+          Cache.log.warn(String.format(
+              "Parameter set instance type %s is not applicable to service"
+              + "at %s.", paramSet.getClass(), serviceUrl));
+        }
+      }
+    }
+  }
+  
+  @Override
+  public List<WsParamSetI> getPresets()
+  {
+    List<WsParamSetI> presets = new ArrayList<>();
+    presets.addAll(servicePresets);
+    presets.addAll(userPresets);
+    return presets;
+  }
+
+  @Override
+  public SimpleParamSet getPreset(String name)
+  {
+    SimpleParamSet preset = null;
+    preset = getUserPreset(name);
+    if (preset != null)
+      return preset;
+    preset = getServicePreset(name);
+    if (preset != null)
+      return preset;
+    return null;
+  }
+  
+  public SimpleParamSet getUserPreset(String name)
+  {
+    for (SimpleParamSet preset : userPresets)
+    {
+      if (name.equals(preset.getName()))
+        return preset;
+    }
+    return null;
+  }
+  
+  public SimpleParamSet getServicePreset(String name)
+  {
+    for (SimpleParamSet preset : servicePresets)
+    {
+      if (name.equals(preset.getName()))
+        return preset;
+    }
+    return null;
+  }
+
+  @Override
+  public List<ArgumentI> getServiceParameters()
+  {
+    return parameters;
+  }
+
+  @Override
+  public boolean presetExists(String name)
+  {
+    return getPreset(name) != null;
+  }
+
+  @Override
+  public void deletePreset(String name)
+  {
+    var userPreset = getUserPreset(name);
+    if (userPreset != null)
+    {
+      userPresets.remove(userPreset);
+      if (manager != null)
+      {
+        manager.deleteParameterSet(userPreset);
+      }
+    }
+    else if (getServicePreset(name) != null)
+    {
+      throw new RuntimeException(MessageManager.getString(
+          "error.implementation_error_attempt_to_delete_service_preset"));
+    }
+    else
+    {
+      Cache.log.warn("Implementation error: no preset to delete");
+    }
+  }
+
+  @Override
+  public void storePreset(String presetName, String text, List<ArgumentI> jobParams)
+  {
+    var builder = SimpleParamSet.newBuilder();
+    builder.name(presetName);
+    builder.description(text);
+    builder.arguments(jobParams);
+    builder.url(serviceUrl.toString());
+    builder.modifiable(true);
+    var preset = builder.build();
+    userPresets.add(preset);
+    if (manager != null)
+      manager.storeParameterSet(preset);
+  }
+
+  @Override
+  public void updatePreset(String oldName, String newName, String text, List<ArgumentI> jobParams)
+  {
+    var preset = getPreset(oldName != null ? oldName : newName);
+    if (preset == null)
+      throw new RuntimeException(MessageManager.formatMessage(
+          "error.implementation_error_cannot_locate_oldname_presetname",
+          oldName, newName));
+    preset.setName(newName);
+    preset.setDescription(text);
+    preset.setArguments(jobParams);
+    preset.setApplicableUrls(new String[] { serviceUrl.toString() });
+    if (manager != null)
+      manager.storeParameterSet(preset);
+  }
+
+  @Override
+  public WsParamSetI parseServiceParameterFile(String name, String description,
+      String[] serviceURL, String parameters)
+      throws IOException
+  {
+    var builder = SimpleParamSet.newBuilder();
+    builder.name(name);
+    builder.description(description);
+    builder.urls(serviceURL);
+    builder.modifiable(true);
+    Unmarshaller unmarshaller;
+    try
+    {
+      var ctx = JAXBContext.newInstance(ArgumentBeanList.class);
+      unmarshaller = ctx.createUnmarshaller();
+    } catch (JAXBException e)
+    {
+      throw new RuntimeException(e);
+    }
+    ArgumentBeanList argList;
+    try
+    {
+      argList = (ArgumentBeanList) unmarshaller.unmarshal(new StringReader(parameters));
+    } catch (JAXBException | ClassCastException e)
+    {
+      throw new IOException("Unable to load parameters from file", e);
+    }
+    builder.arguments(argList.arguments);
+    return builder.build();
+  }
+
+  @Override
+  public String generateServiceParameterFile(WsParamSetI pset) throws IOException
+  {
+    Marshaller marshaller;
+    try 
+    {
+      var ctx = JAXBContext.newInstance(ArgumentBeanList.class);
+      marshaller = ctx.createMarshaller();
+      marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, true);
+      marshaller.setProperty(Marshaller.JAXB_FRAGMENT, true);
+    } catch (JAXBException e)
+    {
+      throw new RuntimeException(e);
+    }
+    ArgumentBeanList argList = ArgumentBeanList.fromList(pset.getArguments());
+    var out = new ByteArrayOutputStream();
+    try
+    {
+      marshaller.marshal(argList, out);
+    } catch (JAXBException e)
+    {
+      throw new IOException("Unable to generate parameters file", e);
+    }
+    return out.toString();
+  }
+
+}
diff --git a/src/jalview/ws2/params/SimpleParamSet.java b/src/jalview/ws2/params/SimpleParamSet.java
new file mode 100644 (file)
index 0000000..9050c5f
--- /dev/null
@@ -0,0 +1,281 @@
+package jalview.ws2.params;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import jalview.ws.params.ArgumentI;
+import jalview.ws.params.WsParamSetI;
+
+/**
+ * A simple, web service client agnostic, representation of parameter sets.
+ * Instances are created from the service data fetched from the server or from
+ * the user preset files. This implementation of {@link WsParamSetI} is meant to
+ * decouple parameter set representation form specific clients.
+ * 
+ * @author mmwarowny
+ *
+ */
+public class SimpleParamSet implements WsParamSetI
+{
+  /**
+   * A convenience builder of {@link SimpleParamSet} objects.
+   * 
+   * @author mmwarowny
+   */
+  public static class Builder
+  {
+    private String name = "default";
+
+    private String description = "";
+
+    private List<String> applicableUrls = new ArrayList<>();
+
+    private boolean modifiable = false;
+
+    private List<ArgumentI> arguments = new ArrayList<>();
+
+    public Builder()
+    {
+    }
+
+    /**
+     * Set a name of parameter set.
+     * 
+     * @param val
+     *          name
+     */
+    public void name(String val)
+    {
+      name = val;
+    }
+
+    /**
+     * Set a description of parameter set.
+     * 
+     * @param val
+     *          description
+     */
+    public void description(String val)
+    {
+      description = val;
+    }
+
+    /**
+     * Add a url to applicable urls for parameter set.
+     * 
+     * @param val
+     *          applicable url
+     */
+    public void url(String val)
+    {
+      applicableUrls.add(val);
+    }
+
+    /**
+     * Set all applicable urls for parameter set. Current url list will be
+     * replaced by provided urls.
+     * 
+     * @param val
+     *          applicable urls
+     */
+    public void urls(String[] val)
+    {
+      applicableUrls.clear();
+      for (String url : val)
+        applicableUrls.add(url);
+    }
+
+    /**
+     * Set modifiable flag for parameter set.
+     * 
+     * @param val
+     *          modifiable
+     */
+    public void modifiable(boolean val)
+    {
+      modifiable = val;
+    }
+
+    /**
+     * Add an argument to the preset arguments.
+     * 
+     * @param val
+     *          argument to be added
+     */
+    public void argument(ArgumentI val)
+    {
+      arguments.add(val);
+    }
+
+    /**
+     * Set arguments for parameter set. Current parameters list will be
+     * replaced by provided arguments.
+     * 
+     * @param val
+     *          arguments to be added
+     */
+    public void arguments(List<? extends ArgumentI> val)
+    {
+      arguments.clear();
+      arguments.addAll(val);
+    }
+
+    /**
+     * Build a new {@link SimpleParamSet} object from the current state of this
+     * builder.
+     * 
+     * @return new paramset instance
+     */
+    public SimpleParamSet build()
+    {
+      return new SimpleParamSet(this);
+    }
+  }
+
+  protected String name;
+
+  protected String description;
+
+  protected String[] applicableUrls;
+
+  protected String sourceFile;
+
+  protected boolean modifiable;
+
+  protected List<ArgumentI> arguments;
+
+  protected SimpleParamSet(Builder builder)
+  {
+    this.name = builder.name;
+    this.description = builder.description;
+    this.applicableUrls = builder.applicableUrls.toArray(new String[0]);
+    this.sourceFile = null;
+    this.modifiable = builder.modifiable;
+    setArguments(builder.arguments);
+  }
+
+  /**
+   * Create a copy of the provided paramset. The new instance has the same
+   * properties as the original paramset. The arguments list is a shallow copy
+   * of the original arguments.
+   * 
+   * @param copy
+   */
+  public SimpleParamSet(WsParamSetI copy)
+  {
+    this.name = copy.getName();
+    this.description = copy.getDescription();
+    var urls = copy.getApplicableUrls();
+    this.applicableUrls = Arrays.copyOf(urls, urls.length);
+    this.sourceFile = copy.getSourceFile();
+    this.modifiable = copy.isModifiable();
+    setArguments(copy.getArguments());
+  }
+
+  /**
+   * Create a new instance of the parameter set builder.
+   * 
+   * @return new parameter set builder
+   */
+  public static Builder newBuilder()
+  {
+    return new Builder();
+  }
+
+  @Override
+  public String getName()
+  {
+    return name;
+  }
+
+  /**
+   * Set a human readable name for this parameter set.
+   * 
+   * @param name
+   *          new name
+   */
+  public void setName(String name)
+  {
+    this.name = name;
+  }
+
+  @Override
+  public String getDescription()
+  {
+    return description;
+  }
+
+  /**
+   * Set additional notes for this parameter set.
+   * 
+   * @param description
+   *          additional notes
+   */
+  public void setDescription(String description)
+  {
+    this.description = description;
+  }
+
+  @Override
+  public String[] getApplicableUrls()
+  {
+    return applicableUrls;
+  }
+
+  /**
+   * Set the list of service endpoints which this parameter set is valid for.
+   * 
+   * @param urls
+   *          new service endpoints
+   */
+  public void setApplicableUrls(String[] urls)
+  {
+    this.applicableUrls = urls;
+  }
+
+  @Override
+  public String getSourceFile()
+  {
+    return sourceFile;
+  }
+
+  @Override
+  public void setSourceFile(String newFile)
+  {
+    this.sourceFile = newFile;
+  }
+
+  @Override
+  public boolean isModifiable()
+  {
+    return this.modifiable;
+  }
+
+  /**
+   * Set whether this parameter set is modifiable or not.
+   * 
+   * @param modifiable
+   *          new modifiable value
+   */
+  public void setModifiable(boolean modifiable)
+  {
+    this.modifiable = modifiable;
+  }
+
+  @Override
+  public List<ArgumentI> getArguments()
+  {
+    return this.arguments;
+  }
+
+  @Override
+  public void setArguments(List<ArgumentI> args)
+  {
+    if (!isModifiable())
+      throw new UnsupportedOperationException(
+          "Attempting to modify an unmodifiable parameter set");
+    this.arguments = Collections.unmodifiableList(new ArrayList<>(args));
+  }
+}
diff --git a/test/jalview/ws2/client/slivka/SlivkaWSDiscovererTest.java b/test/jalview/ws2/client/slivka/SlivkaWSDiscovererTest.java
new file mode 100644 (file)
index 0000000..5519156
--- /dev/null
@@ -0,0 +1,33 @@
+package jalview.ws2.client.slivka;
+
+import java.io.IOException;
+
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+public class SlivkaWSDiscovererTest
+{
+  @BeforeClass
+  public void setupClass() throws IOException
+  {
+    var discoverer = SlivkaWSDiscoverer.getInstance();
+    
+  }
+  
+  @Test
+  public void testServiceFetch() throws IOException
+  {
+    var discoverer = SlivkaWSDiscoverer.getInstance();
+    var services = discoverer.fetchServices(discoverer.getDefaultUrl());
+    for (var service : services)
+    {
+      System.out.format("Service(%s>%s @%s)%n", service.getCategory(), 
+          service.getName(), service.getUrl());
+      var datastore = service.getParamDatastore();
+      for (var param : datastore.getServiceParameters())
+      {
+        System.out.format("  %s :%s%n", param.getName(), param.getClass().getSimpleName()); 
+      }
+    }
+  }
+}