Merge branch 'JAL-3878_web_services_overhaul' into try-to-update-slivka-jar
authorJim Procter <j.procter@dundee.ac.uk>
Tue, 29 Nov 2022 15:34:41 +0000 (15:34 +0000)
committerJim Procter <j.procter@dundee.ac.uk>
Tue, 29 Nov 2022 15:34:41 +0000 (15:34 +0000)
JAL-3066 JAL-3362 Update 2.12 alpha with latest slivka-client.jar
 Conflicts:
src/jalview/bin/Cache.java
src/jalview/gui/AlignFrame.java
src/jalview/gui/Desktop.java
src/jalview/workers/AlignCalcManager.java
src/jalview/ws/jws2/JPredThread.java
src/jalview/ws/slivkaws/SlivkaJPredServiceInstance.java
src/jalview/ws/slivkaws/SlivkaMsaServiceInstance.java
src/jalview/ws/slivkaws/SlivkaWSInstance.java
swingjs/ver/3.3.1/SwingJS-site.zip
swingjs/ver/3.3.1/timestamp

27 files changed:
1  2 
src/jalview/bin/Cache.java
src/jalview/bin/Jalview.java
src/jalview/gui/AlignFrame.java
src/jalview/gui/AnnotationColourChooser.java
src/jalview/gui/AnnotationRowFilter.java
src/jalview/gui/Desktop.java
src/jalview/gui/Preferences.java
src/jalview/gui/SlivkaPreferences.java
src/jalview/io/AnnotationFile.java
src/jalview/io/SequenceAnnotationReport.java
src/jalview/jbgui/GPreferences.java
src/jalview/schemes/AnnotationColourGradient.java
src/jalview/viewmodel/AlignmentViewport.java
src/jalview/workers/AlignCalcManager2.java
src/jalview/ws/gui/MsaWSThread.java
src/jalview/ws/jws2/SeqAnnotationServiceCalcWorker.java
src/jalview/ws/jws2/SequenceAnnotationWSClient.java
src/jalview/ws/slivkaws/SlivkaAnnotationServiceInstance.java
src/jalview/ws/slivkaws/SlivkaMsaServiceInstance.java
src/jalview/ws/slivkaws/SlivkaWSDiscoverer.java
src/jalview/ws2/PollingTaskExecutor.java
src/jalview/ws2/operations/AlignmentOperation.java
src/jalview/ws2/operations/AnnotationServiceWorker.java
src/jalview/ws2/slivka/SlivkaWSDiscoverer.java
src/jalview/ws2/slivka/SlivkaWebService.java
test/jalview/gui/AlignViewportTest.java
test/jalview/ws/jabaws/DisorderAnnotExportImport.java

@@@ -42,17 -36,15 +42,16 @@@ import java.util.Locale
  import java.util.Properties;
  import java.util.StringTokenizer;
  import java.util.TreeSet;
 +import java.util.regex.Pattern;
  
 -import org.apache.log4j.ConsoleAppender;
 -import org.apache.log4j.Level;
 -import org.apache.log4j.Logger;
 -import org.apache.log4j.SimpleLayout;
 +import javax.swing.LookAndFeel;
 +import javax.swing.UIManager;
  
  import jalview.bin.ApplicationSingletonProvider.ApplicationSingletonI;
  import jalview.datamodel.PDBEntry;
 +import jalview.gui.Preferences;
  import jalview.gui.UserDefinedColours;
 +import jalview.log.JLoggerLog4j;
  import jalview.schemes.ColourSchemeLoader;
  import jalview.schemes.ColourSchemes;
  import jalview.schemes.UserColourScheme;
@@@ -353,18 -358,11 +354,19 @@@ public class Cache implements Applicati
  
    private void loadPropertiesImpl(String propsFile)
    {
      propertiesFile = propsFile;
 +    String releasePropertiesFile = null;
 +    boolean defaultProperties = false;
      if (propsFile == null && !propsAreReadOnly)
      {
 -      propertiesFile = Platform.getUserPath(".jalview_properties");
 +      // TODO: @bsoares - for 2.12 testing: check test,develop,release props are located correctly
 +      String channelPrefsFilename = ChannelProperties
 +              .getProperty("preferences.filename");
 +      String releasePrefsFilename = ".jalview_properties";
 +      propertiesFile = Platform.getUserPath(channelPrefsFilename);
 +      releasePropertiesFile = Platform.getUserPath(releasePrefsFilename);
 +      defaultProperties = true;
      }
      else
      {
          System.out.println("Error reading properties file: " + ex);
        }
      }
 +    /* TO BE REPLACED WITH PROXY_TYPE SETTINGS 
      if (getDefault("USE_PROXY", false))
      {
        String proxyServer = getDefault("PROXY_SERVER", ""),
              && (System.getProperty("java.awt.headless") == null || System
                      .getProperty("java.awt.headless").equals("false")))
      {
 -      new Thread()
 +      class VersionChecker extends Thread
        {
          @Override
          public void run()
          {
 +          String remoteBuildPropertiesUrl = Cache
 +                  .getAppbaseBuildProperties();
            String orgtimeout = System
                    .getProperty("sun.net.client.defaultConnectTimeout");
            if (orgtimeout == null)
    {
      // consider returning more human friendly info
      // eg 'built from Source' or update channel
 -    return jalview.bin.Cache.getDefault("INSTALLATION", "unknown");
 +    return Cache.getDefault("INSTALLATION", "unknown");
    }
  
    /**
     * 
     * For AppletParams and Preferences ok_actionPerformed and
Simple merge
@@@ -182,7 -115,11 +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.WebServiceDiscoverer;
+ import jalview.ws2.WebServiceI;
+ import jalview.ws2.operations.Operation;
+ import jalview.ws2.slivka.SlivkaWSDiscoverer;
 -
  import java.io.IOException;
  import java.util.HashSet;
  import java.util.Set;
@@@ -294,9 -236,10 +302,10 @@@ public class AlignFrame extends GAlignF
    private int id;
  
    private DataSourceType protocol ;
    /**
     * Creates a new AlignFrame object with specific width and height.
 -   *
 +   * 
     * @param al
     * @param width
     * @param height
    /**
     * JavaScript will have this, maybe others. More dependable than a file name
     * and maintains a reference to the actual bytes loaded.
 -   *
 +   * 
     * @param file
     */
    public void setFileObject(File file)
    {
      this.fileObject = file;
    {
      buildWebServicesMenu();
    }
+   @Override
+   public void servicesChanged(WebServiceDiscoverer discoverer,
+           Collection<? extends WebServiceI> services)
+   {
+     buildWebServicesMenu();
+   }
 -
    /* Set up intrinsic listeners for dynamically generated GUI bits. */
    private void addServiceListeners()
    {
      if (Cache.getDefault("SHOW_SLIVKA_SERVICES", true))
      {
-       WSDiscovererI discoverer = SlivkaWSDiscoverer.getInstance();
 -      WebServiceDiscoverer discoverer = SlivkaWSDiscoverer.getInstance();
 -      discoverer.addServiceChangeListener((disc, srvcs) -> buildWebServicesMenu());
++      SlivkaWSDiscoverer discoverer = SlivkaWSDiscoverer.getInstance();
 +      discoverer.addServiceChangeListener(this);
      }
      if (Cache.getDefault("SHOW_JWS2_SERVICES", true))
      {
  
    /**
     * Set the enabled state of the 'Run Groovy' option in the Calculate menu
 -   *
 +   * 
     * @param b
     */
    public void setGroovyEnabled(boolean b)
    {
      runGroovy.setEnabled(b);
  
    /*
     * (non-Javadoc)
 -   *
 +   * 
     * @see jalview.gui.IProgressIndicator#setProgressBar(java.lang.String, long)
     */
    @Override
    public void setProgressBar(String message, long id)
    {
    }
  
    /**
 -   *
 +   * 
     * @return true if any progress bars are still active
     */
    @Override
    public boolean operationInProgress()
    {
    /*
     * Added so Castor Mapping file can obtain Jalview Version
     */
    public String getVersion()
    {
 -    return jalview.bin.Cache.getProperty("VERSION");
 +    return Cache.getProperty("VERSION");
    }
  
    public FeatureRenderer getFeatureRenderer()
            {
              String tempFilePath = doBackup ? backupfiles.getTempFilePath()
                      : file;
 +            Console.trace("ALIGNFRAME setting PrintWriter");
              PrintWriter out = new PrintWriter(new FileWriter(tempFilePath));
  
 +            if (backupfiles != null)
 +            {
 +              Console.trace("ALIGNFRAME about to write to temp file "
 +                      + backupfiles.getTempFilePath());
 +            }
              out.print(output);
 +            Console.trace("ALIGNFRAME about to close file");
              out.close();
 +            Console.trace("ALIGNFRAME closed file");
              AlignFrame.this.setTitle(file);
              statusBar.setText(MessageManager.formatMessage(
                      "label.successfully_saved_to_file_in_format",
     * Outputs the alignment to textbox in the requested format, if necessary
     * first prompting the user for whether to include hidden regions or
     * non-sequence data
 -   *
 +   * 
     * @param fileFormatName
     */
    @Override
    protected void outputText_actionPerformed(String fileFormatName)
    {
    /**
     * Creates a PNG image of the alignment and writes it to the given file. If
     * the file is null, the user is prompted to choose a file.
 -   *
 +   * 
     * @param f
     */
    @Override
    public void createPNG(File f)
    {
    /**
     * Creates an EPS image of the alignment and writes it to the given file. If
     * the file is null, the user is prompted to choose a file.
 -   *
 +   * 
     * @param f
     */
    @Override
    public void createEPS(File f)
    {
    /**
     * Creates an SVG image of the alignment and writes it to the given file. If
     * the file is null, the user is prompted to choose a file.
 -   *
 +   * 
     * @param f
     */
    @Override
    public void createSVG(File f)
    {
    /**
     * Close the current view or all views in the alignment frame. If the frame
     * only contains one view then the alignment will be removed from memory.
 -   *
 +   * 
     * @param closeAllTabs
     */
    @Override
    public void closeMenuItem_actionPerformed(boolean closeAllTabs)
    {
  
    /**
     * Close the specified panel and close up tabs appropriately.
 -   *
 +   * 
     * @param panelToClose
     */
    public void closeView(AlignmentPanel panelToClose)
    {
      int index = tabbedPane.getSelectedIndex();
    }
  
    /**
 -   *
 +   * 
     * @return alignment objects for all views
     */
    AlignmentI[] getViewAlignments()
    {
      if (alignPanels != null)
    }
  
    /**
 -   * DOCUMENT ME!
 -   *
 +   * Calls AlignmentI.moveSelectedSequencesByOne with current sequence selection
 +   * or the sequence under cursor in keyboard mode
 +   * 
     * @param up
 -   *          DOCUMENT ME!
 +   *          or down (if !up)
     */
    public void moveSelectedSequences(boolean up)
    {
      SequenceGroup sg = viewport.getSelectionGroup();
  
      if (sg == null)
      {
 +      if (viewport.cursorMode)
 +      {
 +        sg = new SequenceGroup();
 +        sg.addSequence(viewport.getAlignment().getSequenceAt(
 +                alignPanel.getSeqPanel().seqCanvas.cursorY), false);
 +      }
 +      else
 +      {
 +        return;
 +      }
 +    }
 +
 +    if (sg.getSize() < 1)
 +    {
        return;
      }
 +    // TODO: JAL-3733 - add an event to the undo buffer for this !
      viewport.getAlignment().moveSelectedSequencesByOne(sg,
              viewport.getHiddenRepSequences(), up);
      alignPanel.paintAlignment(true, false);
    }
  
    /**
 -   * DOCUMENT ME!
 -   *
 +   * Opens a Finder dialog
 +   * 
     * @param e
 -   *          DOCUMENT ME!
     */
    @Override
    public void findMenuItem_actionPerformed(ActionEvent e)
    {
  
    /*
     * (non-Javadoc)
 -   *
 +   * 
     * @see jalview.jbgui.GAlignFrame#followHighlight_actionPerformed()
     */
    @Override
    protected void followHighlight_actionPerformed()
    {
    /**
     * Action on toggle of the 'Show annotations' menu item. This shows or hides
     * the annotations panel as a whole.
 -   *
 +   * 
     * The options to show/hide all annotations should be enabled when the panel
     * is shown, and disabled when the panel is hidden.
 -   *
 +   * 
     * @param e
     */
    @Override
    public void annotationPanelMenuItem_actionPerformed(ActionEvent e)
    {
     * Action on the user checking or unchecking the option to apply the selected
     * colour scheme to all groups. If unchecked, groups may have their own
     * independent colour schemes.
 -   *
 +   * 
     * @param selected
     */
    @Override
    public void applyToAllGroups_actionPerformed(boolean selected)
    {
  
    /**
     * Actions on setting or changing the alignment colour scheme
 -   *
 +   * 
     * @param cs
     */
    @Override
    public void changeColour(ColourSchemeI cs)
    {
      alignPanel.paintAlignment(true, false);
  
    }
    /**
     * DOCUMENT ME!
 -   *
 +   * 
     * @param e
     *          DOCUMENT ME!
     */
     * search the alignment and rebuild the sort by annotation score submenu the
     * last alignment annotation vector hash is stored to minimize cost of
     * rebuilding in subsequence calls.
 -   *
 +   * 
     */
    @Override
    public void buildSortByAnnotationScoresMenu()
    {
    /**
     * Work out whether the whole set of sequences or just the selected set will
     * be submitted for multiple alignment.
 -   *
 +   * 
     */
    public jalview.datamodel.AlignmentView gatherSequencesForAlignment()
    {
      // Now, check we have enough sequences
      }
    }
  
+   private void buildWebServicesMenu(WebServiceDiscoverer discoverer, final JMenu menu)
+   {
+     if (discoverer.hasServices())
+     {
+       var builder = new WebServicesMenuBuilder();
+       for (var service : discoverer.getServices())
+         builder.addAllOperations(service.getOperations());
+       builder.addSelectedHostChangeListener((name, op) -> {
+         menu.removeAll();
+         builder.buildMenu(menu, this);
+       });
+       builder.buildMenu(menu, this);
+     }
+     if (discoverer.isRunning())
+     {
+       JMenuItem item = new JMenuItem("Service discovery in progress.");
+       item.setEnabled(false);
+       menu.add(item);
+     }
+     else if (!discoverer.hasServices())
+     {
+       JMenuItem item = new JMenuItem("No services available.");
+       item.setEnabled(false);
+       menu.add(item);
+     }
+   }
 -
    /**
     * construct any groupURL type service menu entries.
 -   *
 +   * 
     * @param webService
     */
    protected void build_urlServiceMenu(JMenu webService)
    {
      // TODO: remove this code when 2.7 is released
     * Cross-References menu (formerly called Show Products), with database
     * sources for which cross-references are found (protein sources for a
     * nucleotide alignment and vice versa)
 -   *
 +   * 
     * @return true if Show Cross-references menu should be enabled
     */
    public boolean canShowProducts()
    {
      SequenceI[] seqs = viewport.getAlignment().getSequencesArray();
  
    /**
     * Set the file format
 -   *
 +   * 
     * @param format
     */
    public void setFileFormat(FileFormatI format)
    {
      this.currentFileFormat = format;
    /**
     * find the viewport amongst the tabs in this alignment frame and close that
     * tab
 -   *
 +   * 
     * @param av
     */
    public boolean closeView(AlignViewportI av)
    {
      if (viewport == av)
  
    /**
     * make the given alignmentPanel the currently selected tab
 -   *
 +   * 
     * @param alignmentPanel
     */
    public void setDisplayedView(AlignmentPanel alignmentPanel)
    {
      if (!viewport.getSequenceSetId()
    }
  
    /**
 -   *
 +   * 
     * @return alignment panels in this alignment frame
     */
    public List<? extends AlignmentViewPanel> getAlignPanels()
    {
      // alignPanels is never null
    /**
     * Set visibility of dna/protein complement view (available when shown in a
     * split frame).
 -   *
 +   * 
     * @param show
     */
    @Override
    protected void showComplement_actionPerformed(boolean show)
    {
        }
      }
    }
 -
  }
@@@ -194,8 -169,6 +194,7 @@@ public class Desktop extends GDeskto
    @SuppressWarnings("deprecation")
    private JalviewChangeSupport changeSupport = new JalviewChangeSupport();
  
 +  public static boolean nosplash = false;
    /**
     * news reader - null if it was never started.
     */
         */
  
        doConfigureStructurePrefs();
 -      setTitle("Jalview " + Cache.getProperty("VERSION"));
 -      /*
 -      if (!Platform.isAMac())
 -      {
 -      // this.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
 -      }
 -      else
 +    setTitle(ChannelProperties.getProperty("app_name") + " " + Cache.getProperty("VERSION"));
 +
 +    /**
 +     * Set taskbar "grouped windows" name for linux desktops (works in GNOME and
 +     * KDE). This uses sun.awt.X11.XToolkit.awtAppClassName which is not officially
 +     * documented or guaranteed to exist, so we access it via reflection. There
 +     * appear to be unfathomable criteria about what this string can contain, and it
 +     * if doesn't meet those criteria then "java" (KDE) or "jalview-bin-Jalview"
 +     * (GNOME) is used. "Jalview", "Jalview Develop" and "Jalview Test" seem okay,
 +     * but "Jalview non-release" does not. The reflection access may generate a
 +     * warning: WARNING: An illegal reflective access operation has occurred
 +     * WARNING: Illegal reflective access by jalview.gui.Desktop () to field
 +     * sun.awt.X11.XToolkit.awtAppClassName which I don't think can be avoided.
 +     */
 +    if (Platform.isLinux())
 +    {
 +      if (LaunchUtils.getJavaVersion() >= 11)
        {
 -       this.setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE);
 +        jalview.bin.Console.info(
 +                "Linux platform only! You may have the following warning next: \"WARNING: An illegal reflective access operation has occurred\"\nThis is expected and cannot be avoided, sorry about that.");
        }
 -      */
        try
        {
 -        APQHandlers.setAPQHandlers(this);
 -      } catch (Throwable t)
 -      {
 -        System.out.println("Error setting APQHandlers: " + t.toString());
 -        // t.printStackTrace();
 -      }
 +        Toolkit xToolkit = Toolkit.getDefaultToolkit();
 +        Field[] declaredFields = xToolkit.getClass().getDeclaredFields();
 +        Field awtAppClassNameField = null;
  
 -      addWindowListener(new WindowAdapter()
 -      {
 +        if (Arrays.stream(declaredFields).anyMatch(f -> f.getName().equals("awtAppClassName"))) {
 +          awtAppClassNameField = xToolkit.getClass().getDeclaredField("awtAppClassName");
 +        }
  
 -        @Override
 -        public void windowClosing(WindowEvent ev)
 +        String title = ChannelProperties.getProperty("app_name");
 +        if (awtAppClassNameField != null) {
 +          awtAppClassNameField.setAccessible(true);
 +          awtAppClassNameField.set(xToolkit, title);
 +        }
 +        else
          {
 -          quit();
 +          jalview.bin.Console.debug("XToolkit: awtAppClassName not found");
          }
 -      });
 +      } catch (Exception e)
 +      {
 +        jalview.bin.Console.debug("Error setting awtAppClassName");
 +        jalview.bin.Console.trace(Cache.getStackTraceString(e));
 +      }
 +    }
 +
 +    /**
 +     * APQHandlers sets handlers for About, Preferences and Quit actions peculiar to
 +     * macOS's application menu. APQHandlers will check to see if a handler is
 +     * supported before setting it.
 +     */
 +    try {
 +      APQHandlers.setAPQHandlers(this);
 +    } catch (Exception e) {
 +      System.out.println("Cannot set APQHandlers");
 +      // e.printStackTrace();
 +    } catch (Throwable t) {
 +      jalview.bin.Console.warn("Error setting APQHandlers: " + t.toString());
 +      jalview.bin.Console.trace(Cache.getStackTraceString(t));
 +    }
 +
 +    setIconImages(ChannelProperties.getIconList());
 +
 +    addWindowListener(new WindowAdapter() {
 +
 +      @Override
 +      public void windowClosing(WindowEvent ev) {
 +        quit();
 +      }
 +    });
  
        boolean selmemusage = Cache.getDefault("SHOW_MEMUSAGE", false);
  
          setBounds(xPos, yPos, 900, 650);
        }
  
 -      getIdentifiersOrgData();
        if (!Platform.isJS())
        /**
         * Java only
  
          experimentalFeatures.setSelected(showExperimental());
  
 +        getIdentifiersOrgData();
          if (Jalview.isInteractive())
          {
            // disabled for SeqCanvasTest
            checkURLLinks();
  
            // Spawn a thread that shows the splashscreen
 +          if (!nosplash) {
            SwingUtilities.invokeLater(new Runnable()
 -          {
 -            @Override
 -            public void run()
 -            {
 -              new SplashScreen(true);
 -            }
 -          });
 +           {
 +             @Override
 +             public void run()
 +             {
 +               new SplashScreen(true);
 +             }
 +           });
 +          }
  
            // Thread off a new instance of the file chooser - this reduces the
            // time
      }).start();
    }
  
 -  public void getIdentifiersOrgData()
 -  {
 -    // Thread off the identifiers fetcher
 -    new Thread(new Runnable()
 -    {
 -      @Override
 -      public void run()
 -      {
 -        Cache.log.debug("Downloading data from identifiers.org");
 -        try
 -        {
 -          UrlDownloadClient.download(IdOrgSettings.getUrl(),
 -                  IdOrgSettings.getDownloadLocation());
 -        } catch (IOException e)
 +  public void getIdentifiersOrgData() {
 +    if (Cache.getProperty("NOIDENTIFIERSSERVICE") == null) {
 +      // Thread off the identifiers fetcher
 +      new Thread(new Runnable() {
 +        @Override
 +        public void run()
          {
 -          Cache.log.debug("Exception downloading identifiers.org data"
 -                  + e.getMessage());
 +          jalview.bin.Console.debug("Downloading data from identifiers.org");
 +          try
 +          {
 +            UrlDownloadClient.download(IdOrgSettings.getUrl(),
 +                    IdOrgSettings.getDownloadLocation());
 +          } catch (IOException e)
 +          {
 +            jalview.bin.Console.debug("Exception downloading identifiers.org data"
 +                            + e.getMessage());
 +          }
          }
 -      }
 -    }).start();
 +      }).start();
 +    }
    }
  
    @Override
      }
    }
  
 -//  /**
 -//   * Add an internal frame to the Jalview desktop that is allowed to be resized,
 -//   * has a minimum size of 300px and might or might not be visible
 -//   *
 -//   * @param frame
 -//   *          Frame to show
 -//   * @param title
 -//   *          Visible Title
 -//   * @param makeVisible
 -//   *          When true, display frame immediately, otherwise, caller must call
 -//   *          setVisible themselves.
 -//   * @param w
 -//   *          width
 -//   * @param h
 -//   *          height
 -//   */
 -//  @Deprecated
 -//  public static synchronized void addInternalFrame(
 -//          final JInternalFrame frame, String title, boolean makeVisible,
 -//          int w, int h)
 -//  {
 -//    // textbox, web services, sequenceFetcher, featureSettings
 -//    getInstance().addFrame(frame, title, makeVisible, w, h,
 -//            FRAME_ALLOW_RESIZE, FRAME_SET_MIN_SIZE_300);
 -//  }
 -//
 -//  /**
 -//   * Add an internal frame to the Jalview desktop that is visible, has a minimum
 -//   * size of 300px, and may or may not be resizable
 -//   *
 -//   * @param frame
 -//   *          Frame to show
 -//   * @param title
 -//   *          Visible Title
 -//   * @param w
 -//   *          width
 -//   * @param h
 -//   *          height
 -//   * @param resizable
 -//   *          Allow resize
 -//   */
 -//  @Deprecated
 -//  public static synchronized void addInternalFrame(
 -//          final JInternalFrame frame, String title, int w, int h,
 -//          boolean resizable)
 -//  {
 -//    // annotation, font, calculation, user-defined colors
 -//    getInstance().addFrame(frame, title, FRAME_MAKE_VISIBLE, w, h,
 -//            resizable, FRAME_SET_MIN_SIZE_300);
 -//  }
  
    /**
     * Adds and opens the given frame to the desktop that is visible, allowed to
     * resize, and has a 300px minimum width.
            int w, int h, boolean resizable, boolean ignoreMinSize)
    {
      // 15 classes call this method directly.
 -
 +    
      // TODO: allow callers to determine X and Y position of frame (eg. via
      // bounds object).
      // TODO: consider fixing method to update entries in the window submenu with
    {
  
      openFrameCount++;
--
 +    
      boolean isEmbedded = (Platform.getEmbeddedAttribute(frame, "id") != null);
      boolean hasEmbeddedSize = (Platform.getDimIfEmbedded(frame, -1, -1) != null);
      // Web page embedding allows us to ignore minimum size
      ignoreMinSize |= hasEmbeddedSize;
 -
 +    
      if (!ignoreMinSize)
      {
        // Set default dimension for Alignment Frame window.
        // The Alignment Frame window could be added from a number of places,
        // hence,
      message.append(Cache.getDefault("AUTHORFNAMES", DEFAULT_AUTHORS));
      message.append(CITATION);
  
 +    message.append("</div>");
      return message.toString();
    }
  
                    10, getHeight() - fm.getHeight());
          }
        }
 +      // output debug scale message. Important for jalview.bin.HiDPISettingTest2
 +      Desktop.debugScaleMessage(Desktop.getDesktopPane().getGraphics());
      }
    }
  
      startServiceDiscovery(false);
    }
  
 +  /**
 +   * start service discovery threads - blocking or non-blocking
 +   * 
 +   * @param blocking
 +   */
    public void startServiceDiscovery(boolean blocking)
    {
 -    System.out.println("Starting service discovery");
 +    jalview.bin.Console.debug("Starting service discovery");
      var tasks = new ArrayList<Future<?>>();
      // JAL-940 - JALVIEW 1 services are now being EOLed as of JABA 2.1 release
  
Simple merge
@@@ -1,10 -1,10 +1,11 @@@
  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 jalview.ws2.slivka.SlivkaWSDiscoverer;
+ import jalview.ws2.WebServiceDiscoverer;
  
  import java.awt.BorderLayout;
  import java.awt.Color;
Simple merge
@@@ -551,9 -542,10 +559,9 @@@ public class SequenceAnnotationRepor
        sb.append(bitScore);
        maxWidth = Math.max(maxWidth, eValue.length());
        maxWidth = Math.max(maxWidth, bitScore.length());
+       sb.append("<br>");
      }
-     sb.append("<br>");
      sb.append("</i>");
 -
      return maxWidth;
    }
  
@@@ -498,14 -430,12 +498,15 @@@ public class GPreferences extends JPane
       */
      if (!Platform.isJS())
      {
 +      tabbedPane.add(initHMMERTab(), MessageManager.getString("label.hmmer"));
 +      tabbedPane.add(initStartupTab(),
 +              MessageManager.getString("label.startup"));
        wsTab.setLayout(new BorderLayout());
        tabbedPane.add(wsTab, MessageManager.getString("label.web_services"));
-       slivkaTab.setLayout(new BorderLayout());
-       tabbedPane.add(slivkaTab, "Slivka Services");
      }
+     
+     slivkaTab.setLayout(new BorderLayout());
+     tabbedPane.add(slivkaTab, "Slivka Services");
  
      /*
       * Handler to validate a tab before leaving it - currently only for
@@@ -290,6 -288,7 +290,7 @@@ public class MsaWSThread extends AWSThr
  
        } catch (Throwable throwable)
        {
 -        Cache.log.error("failed to send the job to the alignment server", throwable);
++        Console.error("failed to send the job to the alignment server", throwable);
          if (!server.handleSubmitError(throwable, j, wsInfo))
          {
            if (throwable instanceof Exception)
@@@ -82,15 -83,21 +84,21 @@@ public class SlivkaAnnotationServiceIns
      }
      Alignment aln = new Alignment(seqs.toArray(new SequenceI[0]));
      if (annotFile == null
-         || !new AnnotationFile().readAnnotationFile(aln, annotFile.getURL().toString(), DataSourceType.URL))
+         || !new AnnotationFile().readAnnotationFileWithCalcId(aln, service.getId(), annotFile.getContentUrl().toString(), DataSourceType.URL))
      {
 -      Cache.log.debug("No annotation from slivka job\n" + annotFile);
 +      Console.debug("No annotation from slivka job\n" + annotFile);
      }
+     else {
 -      Cache.log.debug("Annotation file loaded " + annotFile);
++      Console.debug("Annotation file loaded " + annotFile);
+     }
      if (featFile == null
-         || !new FeaturesFile(featFile.getURL().toString(), DataSourceType.URL).parse(aln, featureColours, true))
+         || !new FeaturesFile(featFile.getContentUrl().toString(), DataSourceType.URL).parse(aln, featureColours, true))
      {
 -      Cache.log.debug("No features from slivka job\n" + featFile);
 +      Console.debug("No features from slivka job\n" + featFile);
      }
+     else {
 -      Cache.log.debug("Features feil loaded " + featFile);
++      Console.debug("Features feil loaded " + featFile);
+     }
      return Arrays.asList(aln.getAlignmentAnnotation());
    }
  }
@@@ -1,5 -1,6 +1,6 @@@
  package jalview.ws.slivkaws;
  
 -import jalview.bin.Cache;
++
  import jalview.datamodel.AlignmentI;
  import jalview.datamodel.SequenceI;
  import jalview.io.DataSourceType;
@@@ -10,7 -11,7 +11,6 @@@ import jalview.ws.api.MultipleSequenceA
  import jalview.ws.params.ArgumentI;
  import jalview.ws.params.InvalidArgumentException;
  import jalview.ws.params.WsParamSetI;
--
  import java.io.IOError;
  import java.io.IOException;
  import java.rmi.ServerError;
@@@ -216,9 -217,9 +218,9 @@@ public class SlivkaWSDiscoverer impleme
      {
        List<?> services = new SlivkaClient(url).getServices();
        return services.isEmpty() ? STATUS_NO_SERVICES : STATUS_OK;
-     } catch (IOException e)
+     } catch (IOException | org.json.JSONException e)
      {
 -      Cache.log.error("Slivka could not retrieve services list", e);
 +      Console.error("Slivka could not retrieve services list", e);
        return STATUS_INVALID;
      }
    }
index 0000000,3e04c17..92044c6
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,126 +1,127 @@@
+ package jalview.ws2;
+ import java.util.List;
+ import java.util.concurrent.CopyOnWriteArrayList;
+ import java.util.concurrent.Executors;
+ import java.util.concurrent.ScheduledExecutorService;
+ import java.util.concurrent.TimeUnit;
+ import jalview.bin.Cache;
++import jalview.bin.Console;
+ public class PollingTaskExecutor
+ {
+   private ScheduledExecutorService executor = Executors
+           .newSingleThreadScheduledExecutor();
+   public void submit(final PollableTaskI task)
+   {
+     executor.submit(() -> {
+       try
+       {
+         task.start();
+         wsThreadSupport.submitted(task);
+       } catch (Exception e)
+       {
 -        Cache.log.error("Failed to submit web service jobs.", e);
++        Console.error("Failed to submit web service jobs.", e);
+         wsThreadSupport.submissionFailed(task, e);
+         return;
+       }
+       executor.schedule(() -> poll(task), 1, TimeUnit.SECONDS);
+     });
+   }
+   private void poll(PollableTaskI task)
+   {
+     boolean done;
+     try
+     {
+       done = task.poll();
+     } catch (Exception e)
+     {
 -      Cache.log.error("Failed to poll task.", e);
++      Console.error("Failed to poll task.", e);
+       wsThreadSupport.pollFailed(task, e);
+       return;
+     }
+     if (!done)
+     {
+       executor.schedule(() -> poll(task), 1, TimeUnit.SECONDS);
+     }
+     else
+     {
+       task.done();
+       wsThreadSupport.done(task);
+     }
+   }
+   private WebServiceThreadSupport wsThreadSupport = new WebServiceThreadSupport();
+   public void addThreadListener(PollableTaskListenerI listener)
+   {
+     wsThreadSupport.addListener(listener);
+   }
+   public void removeThreadListener(PollableTaskListenerI listener)
+   {
+     wsThreadSupport.removeListener(listener);
+   }
+   public void shutdown()
+   {
+     executor.shutdownNow();
+   }
+ }
+ class WebServiceThreadSupport implements PollableTaskListenerI
+ {
+   List<PollableTaskListenerI> listeners = new CopyOnWriteArrayList<>();
+   @Override
+   public void submitted(PollableTaskI task)
+   {
+     for (var listener : listeners)
+       listener.submitted(task);
+   }
+   @Override
+   public void submissionFailed(PollableTaskI task, Exception e)
+   {
+     for (var listener : listeners)
+       listener.submissionFailed(task, e);
+   }
+   @Override
+   public void pollFailed(PollableTaskI task, Exception e)
+   {
+     for (var listener : listeners)
+       listener.pollFailed(task, e);
+   }
+   @Override
+   public void cancelled(PollableTaskI task)
+   {
+     for (var listener : listeners)
+       listener.cancelled(task);
+   }
+   @Override
+   public void done(PollableTaskI task)
+   {
+     for (var listener : listeners)
+       listener.done(task);
+   }
+   public void addListener(PollableTaskListenerI listener)
+   {
+     if (!listeners.contains(listener))
+     {
+       listeners.add(listener);
+     }
+   }
+   public void removeListener(PollableTaskListenerI listener)
+   {
+     listeners.remove(listener);
+   }
+ }
index 0000000,89aeb79..d85d012
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,834 +1,835 @@@
+ package jalview.ws2.operations;
+ import static java.lang.String.format;
+ import java.awt.event.MouseAdapter;
+ import java.awt.event.MouseEvent;
+ import java.io.IOException;
+ import java.util.ArrayList;
+ import java.util.Collections;
+ import java.util.HashMap;
+ import java.util.Hashtable;
+ import java.util.LinkedHashMap;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.Objects;
+ import java.util.concurrent.CompletionStage;
+ import javax.swing.JMenu;
+ import javax.swing.JMenuItem;
+ import javax.swing.ToolTipManager;
+ import jalview.analysis.AlignSeq;
+ import jalview.analysis.AlignmentSorter;
+ import jalview.analysis.SeqsetUtils;
+ import jalview.bin.Cache;
++import jalview.bin.Console;
+ 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.SequenceI;
+ import jalview.datamodel.Sequence;
+ import jalview.gui.AlignFrame;
+ import jalview.gui.AlignViewport;
+ import jalview.gui.Desktop;
+ import jalview.gui.JvSwingUtils;
+ import jalview.gui.WebserviceInfo;
+ import jalview.gui.WsJobParameters;
+ import jalview.util.MathUtils;
+ import jalview.util.MessageManager;
+ import jalview.ws.params.ArgumentI;
+ import jalview.ws.params.WsParamSetI;
+ import jalview.ws2.MenuEntryProviderI;
+ import jalview.ws2.ResultSupplier;
+ import jalview.ws2.WSJob;
+ import jalview.ws2.WSJobStatus;
+ import jalview.ws2.PollingTaskExecutor;
+ import jalview.ws2.WebServiceI;
+ import jalview.ws2.WebServiceInfoUpdater;
+ import jalview.ws2.WebServiceWorkerI;
+ import jalview.ws2.utils.WSJobList;
+ /**
+  *
+  * @author mmwarowny
+  *
+  */
+ public class AlignmentOperation implements Operation
+ {
+   final WebServiceI service;
+   final ResultSupplier<AlignmentI> supplier;
+   public AlignmentOperation(WebServiceI service,
+           ResultSupplier<AlignmentI> supplier)
+   {
+     this.service = service;
+     this.supplier = supplier;
+   }
+   @Override
+   public String getName()
+   {
+     return service.getName();
+   }
+   @Override
+   public String getTypeName()
+   {
+     return "Multiple Sequence Alignment";
+   }
+   @Override
+   public String getHostName()
+   {
+     return service.getHostName();
+   }
+   @Override
+   public int getMinSequences()
+   {
+     return 2;
+   }
+   @Override
+   public int getMaxSequences()
+   {
+     return Integer.MAX_VALUE;
+   }
+   @Override
+   public boolean isProteinOperation()
+   {
+     return true;
+   }
+   @Override
+   public boolean isNucleotideOperation()
+   {
+     return true;
+   }
+   
+   @Override
+   public boolean isAlignmentAnalysis()
+   {
+     return false;
+   }
+   @Override
+   public boolean canSubmitGaps()
+   {
+     // hack copied from original jabaws code, don't blame me
+     return service.getName().contains("lustal");
+   }
+   @Override
+   public boolean isInteractive()
+   {
+     return false;
+   }
+   
+   @Override
+   public boolean getFilterNonStandardSymbols()
+   {
+     return true;
+   }
+   
+   @Override
+   public boolean getNeedsAlignedSequences()
+   {
+     return false;
+   }
+   @Override
+   public MenuEntryProviderI getMenuBuilder()
+   {
+     return this::buildMenu;
+   }
+   protected void buildMenu(JMenu parent, AlignFrame frame)
+   {
+     if (canSubmitGaps())
+     {
+       var alignSubmenu = new JMenu(service.getName());
+       buildMenu(alignSubmenu, frame, false);
+       parent.add(alignSubmenu);
+       var realignSubmenu = new JMenu(MessageManager.formatMessage(
+               "label.realign_with_params", service.getName()));
+       realignSubmenu.setToolTipText(MessageManager
+               .getString("label.align_sequences_to_existing_alignment"));
+       buildMenu(realignSubmenu, frame, true);
+       parent.add(realignSubmenu);
+     }
+     else
+     {
+       buildMenu(parent, frame, false);
+     }
+   }
+   protected void buildMenu(JMenu parent, AlignFrame frame,
+           boolean submitGaps)
+   {
+     final String action = submitGaps ? "Align" : "Realign";
+     final var calcName = service.getName();
+     String title = frame.getTitle();
+     PollingTaskExecutor executor = frame.getViewport().getWSExecutor();
+     {
+       var item = new JMenuItem(MessageManager.formatMessage(
+               "label.calcname_with_default_settings", calcName));
+       item.setToolTipText(MessageManager
+               .formatMessage("label.action_with_default_settings", action));
+       item.addActionListener((event) -> {
+         final AlignmentView msa = frame.gatherSequencesForAlignment();
+         final AlignViewport viewport = frame.getViewport();
+         final AlignmentI alignment = frame.getViewport().getAlignment();
+         if (msa != null)
+         {
+           WebServiceWorkerI worker = new AlignmentWorker(msa,
+               Collections.emptyList(), title, submitGaps, true,
+               alignment, viewport);
+           executor.submit(worker);
+         }
+       });
+       parent.add(item);
+     }
+     if (service.hasParameters())
+     {
+       var item = new JMenuItem(
+               MessageManager.getString("label.edit_settings_and_run"));
+       item.setToolTipText(MessageManager.getString(
+               "label.view_and_change_parameters_before_alignment"));
+       item.addActionListener((event) -> {
+         final AlignmentView msa = frame.gatherSequencesForAlignment();
+         final AlignViewport viewport = frame.getViewport();
+         final AlignmentI alignment = frame.getViewport().getAlignment();
+         if (msa != null)
+         {
+           openEditParamsDialog(service, null, null)
+               .thenAcceptAsync((arguments) -> {
+                 if (arguments != null)
+                 {
+                   WebServiceWorkerI worker = new AlignmentWorker(msa,
+                       arguments, title, submitGaps, true, alignment,
+                       viewport);
+                   executor.submit(worker);
+                 }
+               });
+         }
+       });
+       parent.add(item);
+     }
+     var presets = service.getParamStore().getPresets();
+     if (presets != null && presets.size() > 0)
+     {
+       final var presetList = new JMenu(MessageManager
+               .formatMessage("label.run_with_preset_params", calcName));
+       final var showToolTipFor = ToolTipManager.sharedInstance()
+               .getDismissDelay();
+       for (final var preset : presets)
+       {
+         var item = new JMenuItem(preset.getName());
+         final int QUICK_TOOLTIP = 1500;
+         item.addMouseListener(new MouseAdapter()
+         {
+           @Override
+           public void mouseEntered(MouseEvent e)
+           {
+             ToolTipManager.sharedInstance().setDismissDelay(QUICK_TOOLTIP);
+           }
+           @Override
+           public void mouseExited(MouseEvent e)
+           {
+             ToolTipManager.sharedInstance().setDismissDelay(showToolTipFor);
+           }
+         });
+         String tooltip = JvSwingUtils.wrapTooltip(true,
+                 format("<strong>%s</strong><br/>%s",
+                         MessageManager.getString(
+                                 preset.isModifiable() ? "label.user_preset"
+                                         : "label.service_preset"),
+                         preset.getDescription()));
+         item.setToolTipText(tooltip);
+         item.addActionListener((event) -> {
+           final AlignmentView msa = frame.gatherSequencesForAlignment();
+           final AlignViewport viewport = frame.getViewport();
+           final AlignmentI alignment = frame.getViewport().getAlignment();
+           if (msa != null)
+           {
+             WebServiceWorkerI worker = new AlignmentWorker(msa,
+                 preset.getArguments(), title, submitGaps, true,
+                 alignment, viewport);
+             executor.submit(worker);
+           }
+         });
+         presetList.add(item);
+       }
+       parent.add(presetList);
+     }
+   }
+   private CompletionStage<List<ArgumentI>> openEditParamsDialog(
+           WebServiceI service, WsParamSetI preset,
+           List<ArgumentI> arguments)
+   {
+     WsJobParameters jobParams;
+     if (preset == null && arguments != null && arguments.size() > 0)
+       jobParams = new WsJobParameters(service.getParamStore(), preset,
+               arguments);
+     else
+       jobParams = new WsJobParameters(service.getParamStore(), preset,
+               null);
+     var stage = jobParams.showRunDialog();
+     return stage.thenApply((startJob) -> {
+       if (startJob)
+       {
+         if (jobParams.getPreset() == null)
+         {
+           return jobParams.getJobParams();
+         }
+         else
+         {
+           return jobParams.getPreset().getArguments();
+         }
+       }
+       else
+       {
+         return null;
+       }
+     });
+   }
+   /**
+    * Implementation of the web service worker performing multiple sequence
+    * alignment.
+    *
+    * @author mmwarowny
+    *
+    */
+   private class AlignmentWorker implements WebServiceWorkerI
+   {
+     private long uid = MathUtils.getUID();
+     private final AlignmentView msa;
+     private final AlignmentI dataset;
+     private final AlignViewport viewport;
+     private final List<AlignedCodonFrame> codonFrame = new ArrayList<>();
+     private List<ArgumentI> args = Collections.emptyList();
+     private String alnTitle = "";
+     private boolean submitGaps = false;
+     private boolean preserveOrder = false;
+     private char gapCharacter;
+     private WSJobList jobs = new WSJobList();
+     private Map<Long, JobInput> inputs = new LinkedHashMap<>();
+     private WebserviceInfo wsInfo;
+     private Map<Long, Integer> exceptionCount = new HashMap<>();
+     private final int MAX_RETRY = 5;
+     AlignmentWorker(AlignmentView msa, List<ArgumentI> args,
+             String alnTitle, boolean submitGaps, boolean preserveOrder,
+             AlignmentI alignment, AlignViewport viewport)
+     {
+       this.msa = msa;
+       this.dataset = alignment.getDataset();
+       List<AlignedCodonFrame> cf = Objects.requireNonNullElse(
+               alignment.getCodonFrames(), Collections.emptyList());
+       this.codonFrame.addAll(cf);
+       this.args = args;
+       this.alnTitle = alnTitle;
+       this.submitGaps = submitGaps;
+       this.preserveOrder = preserveOrder;
+       this.viewport = viewport;
+       this.gapCharacter = viewport.getGapCharacter();
+       String panelInfo = String.format("%s using service hosted at %s%n%s",
+               service.getName(), service.getHostName(),
+               Objects.requireNonNullElse(service.getDescription(), ""));
+       wsInfo = new WebserviceInfo(service.getName(), panelInfo, false);
+     }
+     @Override
+     public long getUID()
+     {
+       return uid;
+     }
+     @Override
+     public WebServiceI getWebService()
+     {
+       return service;
+     }
+     @Override
+     public List<WSJob> getJobs()
+     {
+       return Collections.unmodifiableList(jobs);
+     }
+     @Override
+     public void start() throws IOException
+     {
 -      Cache.log.info(format("Starting new %s job.", service.getName()));
++      Console.info(format("Starting new %s job.", service.getName()));
+       String outputHeader = String.format("%s of %s%nJob details%n",
+               submitGaps ? "Re-alignment" : "Alignment", alnTitle);
+       SequenceI[][] conmsa = msa.getVisibleContigs('-');
+       if (conmsa == null)
+       {
+         return;
+       }
+       WebServiceInfoUpdater updater = new WebServiceInfoUpdater(wsInfo);
+       updater.setOutputHeader(outputHeader);
+       int numValid = 0;
+       for (int i = 0; i < conmsa.length; i++)
+       {
+         JobInput input = JobInput.create(conmsa[i], 2, submitGaps);
+         WSJob job = new WSJob(service.getProviderName(), service.getName(),
+                 service.getHostName());
+         job.setJobNum(wsInfo.addJobPane());
+         if (conmsa.length > 1)
+         {
+           wsInfo.setProgressName(String.format("region %d", i),
+                   job.getJobNum());
+         }
+         wsInfo.setProgressText(job.getJobNum(), outputHeader);
+         job.addPropertyChangeListener(updater);
+         inputs.put(job.getUid(), input);
+         jobs.add(job);
+         if (input.isInputValid())
+         {
+           int count;
+           String jobId = null;
+           do
+           {
+             count = exceptionCount.getOrDefault(job.getUid(), MAX_RETRY);
+             try
+             {
+               jobId = service.submit(input.inputSequences, args);
 -              Cache.log.debug((format("Job %s submitted", job)));
++              Console.debug((format("Job %s submitted", job)));
+               exceptionCount.remove(job.getUid());
+             } catch (IOException e)
+             {
+               exceptionCount.put(job.getUid(), --count);
+             }
+           } while (jobId == null && count > 0);
+           if (jobId != null)
+           {
+             job.setJobId(jobId);
+             job.setStatus(WSJobStatus.SUBMITTED);
+             numValid++;
+           }
+           else
+           {
+             job.setStatus(WSJobStatus.SERVER_ERROR);
+           }
+         }
+         else
+         {
+           job.setStatus(WSJobStatus.INVALID);
+           job.setErrorLog(
+                   MessageManager.getString("label.empty_alignment_job"));
+         }
+       }
+       if (numValid > 0)
+       {
+         // wsInfo.setThisService() should happen here
+         wsInfo.setVisible(true);
+       }
+       else
+       {
+         wsInfo.setVisible(false);
+         // TODO show notification dialog.
+         // JvOptionPane.showMessageDialog(frame,
+         // MessageManager.getString("info.invalid_msa_input_mininfo"),
+         // MessageManager.getString("info.invalid_msa_notenough"),
+         // JvOptionPane.INFORMATION_MESSAGE);
+       }
+     }
+     @Override
+     public boolean poll()
+     {
+       boolean done = true;
+       for (WSJob job : getJobs())
+       {
+         if (!job.getStatus().isDone() && !job.getStatus().isFailed())
+         {
 -          Cache.log.debug(format("Polling job %s.", job));
++          Console.debug(format("Polling job %s.", job));
+           try
+           {
+             service.updateProgress(job);
+             exceptionCount.remove(job.getUid());
+           } catch (IOException e)
+           {
 -            Cache.log.error(format("Polling job %s failed.", job), e);
++            Console.error(format("Polling job %s failed.", job), e);
+             wsInfo.appendProgressText(job.getJobNum(),
+                     MessageManager.formatMessage("info.server_exception",
+                             service.getName(), e.getMessage()));
+             int count = exceptionCount.getOrDefault(job.getUid(),
+                     MAX_RETRY);
+             if (--count <= 0)
+             {
+               job.setStatus(WSJobStatus.SERVER_ERROR);
 -              Cache.log.warn(format(
++              Console.warn(format(
+                       "Attempts limit exceeded. Droping job %s.", job));
+             }
+             exceptionCount.put(job.getUid(), count);
+           } catch (OutOfMemoryError e)
+           {
+             job.setStatus(WSJobStatus.BROKEN);
 -            Cache.log.error(
++            Console.error(
+                     format("Out of memory when retrieving job %s", job), e);
+           }
 -          Cache.log.debug(
++          Console.debug(
+                   format("Job %s status is %s", job, job.getStatus()));
+         }
+         done &= job.getStatus().isDone() || job.getStatus().isFailed();
+       }
+       updateWSInfoGlobalStatus();
+       return done;
+     }
+     private void updateWSInfoGlobalStatus()
+     {
+       if (jobs.countRunning() > 0)
+       {
+         wsInfo.setStatus(WebserviceInfo.STATE_RUNNING);
+       }
+       else if (jobs.countQueuing() > 0
+               || jobs.countSubmitted() < jobs.size())
+       {
+         wsInfo.setStatus(WebserviceInfo.STATE_QUEUING);
+       }
+       else
+       {
+         if (jobs.countSuccessful() > 0)
+         {
+           wsInfo.setStatus(WebserviceInfo.STATE_STOPPED_OK);
+         }
+         else if (jobs.countCancelled() > 0)
+         {
+           wsInfo.setStatus(WebserviceInfo.STATE_CANCELLED_OK);
+         }
+         else if (jobs.countFailed() > 0)
+         {
+           wsInfo.setStatus(WebserviceInfo.STATE_STOPPED_ERROR);
+         }
+       }
+     }
+     @Override
+     public void done()
+     {
+       long progbarId = MathUtils.getUID();
+       wsInfo.setProgressBar(
+               MessageManager.getString("status.collecting_job_results"),
+               progbarId);
+       Map<Long, AlignmentI> results = new LinkedHashMap<>();
+       for (WSJob job : getJobs())
+       {
+         if (job.getStatus().isFailed())
+           continue;
+         try
+         {
+           AlignmentI alignment = supplier.getResult(job, dataset.getSequences(), viewport);
+           if (alignment != null)
+           {
+             results.put(job.getUid(), alignment);
+           }
+         } catch (Exception e)
+         {
+           if (!service.handleCollectionError(job, e))
+           {
 -            Cache.log.error("Couldn't get alignment for job.", e);
++            Console.error("Couldn't get alignment for job.", e);
+             // TODO: Increment exception count and retry.
+             job.setStatus(WSJobStatus.SERVER_ERROR);
+           }
+         }
+       }
+       updateWSInfoGlobalStatus();
+       if (results.size() > 0)
+       {
+         OutputWrapper out = prepareOutput(results);
+         wsInfo.showResultsNewFrame.addActionListener(evt -> displayNewFrame(
+                 new Alignment(out.aln), out.alorders, out.hidden));
+         wsInfo.setResultsReady();
+       }
+       else
+       {
+         wsInfo.setFinishedNoResults();
+       }
+       wsInfo.removeProgressBar(progbarId);
+     }
+     private class OutputWrapper
+     {
+       AlignmentI aln;
+       List<AlignmentOrder> alorders;
+       HiddenColumns hidden;
+       OutputWrapper(AlignmentI aln, List<AlignmentOrder> alorders,
+               HiddenColumns hidden)
+       {
+         this.aln = aln;
+         this.alorders = alorders;
+         this.hidden = hidden;
+       }
+     }
+     private OutputWrapper prepareOutput(Map<Long, AlignmentI> alignments)
+     {
+       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++)
+       {
+         WSJob job = jobs.get(i);
+         AlignmentI aln = alignments.get(job.getUid());
+         if (aln != null) // equivalent of job.hasResults()
+         {
+           /* Get the alignment including any empty sequences in the original
+            * order with original ids. */
+           JobInput input = inputs.get(job.getUid());
+           char gapChar = aln.getGapCharacter();
+           List<SequenceI> emptySeqs = input.emptySequences;
+           List<SequenceI> alnSeqs = aln.getSequences();
+           // find the width of the longest sequence
+           int width = 0;
+           for (var seq : alnSeqs)
+             width = Integer.max(width, seq.getLength());
+           for (var emptySeq : emptySeqs)
+             width = Integer.max(width, emptySeq.getLength());
+           // pad shorter sequences with gaps
+           String gapSeq = String.join("",
+                   Collections.nCopies(width, Character.toString(gapChar)));
+           List<SequenceI> seqs = new ArrayList<>(
+                   alnSeqs.size() + emptySeqs.size());
+           seqs.addAll(alnSeqs);
+           seqs.addAll(emptySeqs);
+           for (var seq : seqs)
+           {
+             if (seq.getLength() < width)
+               seq.setSequence(seq.getSequenceAsString()
+                       + gapSeq.substring(seq.getLength()));
+           }
+           SequenceI[] result = seqs.toArray(new SequenceI[0]);
+           AlignmentOrder msaOrder = new AlignmentOrder(result);
+           AlignmentSorter.recoverOrder(result);
+           // temporary workaround for deuniquify
+           @SuppressWarnings({ "rawtypes", "unchecked" })
+           Hashtable names = new Hashtable(input.sequenceNames);
+           // FIXME first call to deuniquify alters original alignment
+           SeqsetUtils.deuniquify(names, result);
+           alorders.add(msaOrder);
+           results[i] = result;
+           orders[i] = msaOrder;
+         }
+         else
+         {
+           results[i] = null;
+         }
+       }
+       Object[] newView = msa.getUpdatedView(results, orders, gapCharacter);
+       // 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", service.getName());
+       if (dataset != null)
+         aln.setDataset(dataset);
+       propagateDatasetMappings(aln);
+       return new OutputWrapper(aln, alorders, hidden);
+       // displayNewFrame(aln, alorders, hidden);
+     }
+     /*
+      * conserves dataset references to sequence objects returned from web
+      * services. propagate codon frame data to alignment.
+      */
+     private void propagateDatasetMappings(Alignment aln)
+     {
+       if (codonFrame != null)
+       {
+         SequenceI[] alignment = aln.getSequencesArray();
+         for (SequenceI seq : alignment)
+         {
+           for (AlignedCodonFrame acf : codonFrame)
+           {
+             if (acf != null && acf.involvesSequence(seq))
+             {
+               aln.addCodonFrame(acf);
+               break;
+             }
+           }
+         }
+       }
+     }
+     private void displayNewFrame(AlignmentI aln,
+             List<AlignmentOrder> alorders, HiddenColumns hidden)
+     {
+       AlignFrame frame = new AlignFrame(aln, hidden,
+               AlignFrame.DEFAULT_WIDTH, AlignFrame.DEFAULT_HEIGHT);
+       // TODO store feature renderer settings in worker object
+       // frame.getFeatureRenderer().transferSettings(featureSettings);
+       var regions = sortOrders(alorders);
+       if (alorders.size() == 1)
+       {
+         frame.addSortByOrderMenuItem(
+                 format("%s Ordering", service.getName()), alorders.get(0));
+       }
+       else
+       {
+         for (int i = 0; i < alorders.size(); i++)
+         {
+           final int j = i;
+           Iterable<String> iter = () -> regions.get(j).stream()
+                   .map(it -> Integer.toString(it)).iterator();
+           var orderName = format("%s Region %s Ordering", service.getName(),
+                   String.join(",", iter));
+           frame.addSortByOrderMenuItem(orderName, alorders.get(i));
+         }
+       }
+       /* TODO
+        * If alignment was requested from one half of a SplitFrame, show in a
+        * SplitFrame with the other pane similarly aligned.
+        */
+       Desktop.addInternalFrame(frame, alnTitle, AlignFrame.DEFAULT_WIDTH,
+               AlignFrame.DEFAULT_HEIGHT);
+     }
+     private List<List<Integer>> sortOrders(List<?> alorders)
+     {
+       List<List<Integer>> regions = new ArrayList<>();
+       for (int i = 0; i < alorders.size(); i++)
+       {
+         List<Integer> regs = new ArrayList<>();
+         regs.add(i);
+         int j = i + 1;
+         while (j < alorders.size())
+         {
+           if (alorders.get(i).equals(alorders.get(j)))
+           {
+             alorders.remove(j);
+             regs.add(j);
+           }
+           else
+           {
+             j++;
+           }
+         }
+         regions.add(regs);
+       }
+       return regions;
+     }
+   }
+   private static class JobInput
+   {
+     final List<SequenceI> inputSequences;
+     final List<SequenceI> emptySequences;
+     @SuppressWarnings("rawtypes")
+     final Map<String, ? extends Map> sequenceNames;
+     private JobInput(int numSequences, List<SequenceI> inputSequences,
+             List<SequenceI> emptySequences,
+             @SuppressWarnings("rawtypes") Map<String, ? extends Map> names)
+     {
+       this.inputSequences = Collections.unmodifiableList(inputSequences);
+       this.emptySequences = Collections.unmodifiableList(emptySequences);
+       this.sequenceNames = names;
+     }
+     boolean isInputValid()
+     {
+       return inputSequences.size() >= 2;
+     }
+     static JobInput create(SequenceI[] sequences, int minLength,
+             boolean submitGaps)
+     {
+       assert minLength >= 0 : MessageManager.getString(
+               "error.implementation_error_minlen_must_be_greater_zero");
+       int numSeq = 0;
+       for (SequenceI seq : sequences)
+       {
+         if (seq.getEnd() - seq.getStart() >= minLength)
+         {
+           numSeq++;
+         }
+       }
+       List<SequenceI> inputSequences = new ArrayList<>();
+       List<SequenceI> emptySequences = new ArrayList<>();
+       @SuppressWarnings("rawtypes")
+       Map<String, Hashtable> names = new LinkedHashMap<>();
+       for (int i = 0; i < sequences.length; i++)
+       {
+         SequenceI seq = sequences[i];
+         String newName = SeqsetUtils.unique_name(i);
+         @SuppressWarnings("rawtypes")
+         Hashtable hash = SeqsetUtils.SeqCharacterHash(seq);
+         names.put(newName, hash);
+         if (numSeq > 1 && seq.getEnd() - seq.getStart() >= minLength)
+         {
+           String seqString = seq.getSequenceAsString();
+           if (!submitGaps)
+           {
+             seqString = AlignSeq.extractGaps(
+                     jalview.util.Comparison.GapChars, seqString);
+           }
+           inputSequences.add(new Sequence(newName, seqString));
+         }
+         else
+         {
+           String seqString = "";
+           if (seq.getEnd() >= seq.getStart())  // true if gaps only
+           {
+             seqString = seq.getSequenceAsString();
+             if (!submitGaps)
+             {
+               seqString = AlignSeq.extractGaps(
+                       jalview.util.Comparison.GapChars, seqString);
+             }
+           }
+           emptySequences.add(new Sequence(newName, seqString));
+         }
+       }
+       return new JobInput(numSeq, inputSequences, emptySequences, names);
+     }
+   }
+ }
index 0000000,034b581..417b39c
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,592 +1,593 @@@
+ package jalview.ws2.operations;
+ 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 java.util.Objects;
+ import jalview.analysis.AlignSeq;
+ import jalview.analysis.AlignmentAnnotationUtils;
+ import jalview.analysis.SeqsetUtils;
+ import jalview.api.AlignCalcManagerI2;
+ import jalview.api.AlignViewportI;
+ import jalview.api.AlignmentViewPanel;
+ import jalview.api.FeatureColourI;
+ import jalview.api.PollableAlignCalcWorkerI;
+ import jalview.bin.Cache;
++import jalview.bin.Console;
+ import jalview.datamodel.Alignment;
+ 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.Sequence;
+ import jalview.datamodel.SequenceI;
+ import jalview.datamodel.features.FeatureMatcherSetI;
+ import jalview.gui.AlignFrame;
+ import jalview.gui.AlignViewport;
+ import jalview.gui.IProgressIndicator;
+ import jalview.gui.IProgressIndicatorHandler;
+ import jalview.io.FeaturesFile;
+ import jalview.schemes.FeatureSettingsAdapter;
+ import jalview.schemes.ResidueProperties;
+ import jalview.util.MapList;
+ import jalview.workers.AlignCalcManager2;
+ import jalview.ws.params.ArgumentI;
+ import jalview.ws2.WSJob;
+ import jalview.ws2.WSJobStatus;
+ import jalview.ws2.WebServiceI;
+ import jalview.ws2.gui.ProgressBarUpdater;
+ import static java.lang.String.format;
+ public class AnnotationServiceWorker implements PollableAlignCalcWorkerI
+ {
+   private AnnotationOperation operation;
+   private WebServiceI service;
+   private List<ArgumentI> args;
+   private AlignViewport viewport;
+   private AlignmentViewPanel alignPanel;
+   List<SequenceI> sequences;
+   private IProgressIndicator progressIndicator;
+   private AlignFrame frame;
+   private final AlignCalcManagerI2 calcMan;
+   private Map<String, SequenceI> seqNames;
+   /**
+    * indicates columns consisting of gaps only
+    */
+   boolean[] gapMap = new boolean[0];
+   int start, end;
+   boolean transferSequenceFeatures = false;
+   private WSJob job;
+   private List<AlignmentAnnotation> ourAnnots;
+   private int exceptionCount = MAX_RETRY;
+   private static final int MAX_RETRY = 5;
+   AnnotationServiceWorker(AnnotationOperation operation, WebServiceI service,
+       List<ArgumentI> args, AlignViewport viewport, AlignmentViewPanel alignPanel,
+       IProgressIndicator progressIndicator, AlignFrame frame, AlignCalcManagerI2 calcMan)
+   {
+     this.operation = operation;
+     this.service = service;
+     this.args = args;
+     this.viewport = viewport;
+     this.alignPanel = alignPanel;
+     this.progressIndicator = progressIndicator;
+     this.frame = frame;
+     this.calcMan = calcMan;
+   }
+   @Override
+   public String getCalcName()
+   {
+     return service.getName();
+   }
+   @Override
+   public boolean involves(AlignmentAnnotation annot)
+   {
+     return ourAnnots != null && ourAnnots.contains(annot);
+   }
+   @Override
+   public void updateAnnotation()
+   {
+     if (!calcMan.isWorking(this) && job != null && !job.getStatus().isCompleted())
+     {
+       // is it correct to store annotations in a field and use them here?
+       updateResultAnnotation(ourAnnots);
+     }
+   }
+   @Override
+   public void removeAnnotation()
+   {
+     if (ourAnnots != null && viewport != null)
+     {
+       AlignmentI alignment = viewport.getAlignment();
+       synchronized (ourAnnots)
+       {
+         for (AlignmentAnnotation aa : ourAnnots)
+         {
+           alignment.deleteAnnotation(aa, true);
+         }
+       }
+     }
+   }
+   @Override
+   public boolean isDeletable()
+   {
+     return true;
+   }
+   @Override
+   public void startUp() throws IOException
+   {
+     if (viewport.isClosed())
+     {
+       return;
+     }
+     /* What "bySequence" means in this context and
+      * what is the SelectionGroup and why is it only relevant when
+      * not dealing with alignment analysis? */
+     var bySequence = !operation.isAlignmentAnalysis();
+     sequences = prepareInput(viewport.getAlignment(),
+         bySequence ? viewport.getSelectionGroup() : null);
+     if (sequences == null)
+     {
 -      Cache.log.info("Sequences for analysis service were null");
++      Console.info("Sequences for analysis service were null");
+       return;
+     }
+     if (!checkInputSequencesValid(sequences))
+     {
 -      Cache.log.info("Sequences for analysis service were not valid");
++      Console.info("Sequences for analysis service were not valid");
+     }
 -    Cache.log.debug(format("submitting %d sequences to %s", sequences.size(),
++    Console.debug(format("submitting %d sequences to %s", sequences.size(),
+         service.getName()));
+     job = new WSJob(service.getProviderName(), service.getName(),
+         service.getHostName());
+     // Should this part be moved out of this class to one of the gui
+     // classes?
+     if (progressIndicator != null)
+     {
+       job.addPropertyChangeListener("status", new ProgressBarUpdater(progressIndicator));
+       progressIndicator.registerHandler(job.getUid(), new IProgressIndicatorHandler()
+       {
+         @Override
+         public boolean cancelActivity(long id)
+         {
+           calcMan.cancelWorker(AnnotationServiceWorker.this);
+           return true;
+         }
+         @Override
+         public boolean canCancel()
+         {
+           return isDeletable();
+         }
+       });
+     }
+     String jobId = service.submit(sequences, args);
+     job.setJobId(jobId);
 -    Cache.log.debug(format("Service %s: submitted job id %s",
++    Console.debug(format("Service %s: submitted job id %s",
+         service.getHostName(), jobId));
+   }
+   private List<SequenceI> prepareInput(AlignmentI alignment,
+       AnnotatedCollectionI inputSeqs)
+   {
+     if (alignment == null || alignment.getWidth() <= 0 ||
+         alignment.getSequences() == null)
+       return null;
+     if (alignment.isNucleotide() && !operation.isNucleotideOperation())
+       return null;
+     if (!alignment.isNucleotide() && !operation.isProteinOperation())
+       return null;
+     if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
+         inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
+       inputSeqs = alignment;
+     List<SequenceI> seqs = new ArrayList<>();
+     final boolean submitGaps = operation.isAlignmentAnalysis();
+     final int minlen = 10;
+     int ln = -1; // I think this variable is redundant
+     if (!operation.isAlignmentAnalysis())
+       seqNames = new HashMap<>();
+     start = inputSeqs.getStartRes();
+     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;
+       // is it trying to find the length of a sequence excluding gaps?
+       if (!operation.isAlignmentAnalysis())
+         // why starting at positions to the right from the end/start?
+         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());
+         if (seqNames != null)
+         {
+           seqNames.put(newName, sq);
+         }
+         SequenceI seq;
+         if (submitGaps)
+         {
+           seq = new Sequence(newName, sq.getSequenceAsString());
+           seqs.add(seq);
+           if (gapMap == null || gapMap.length < seq.getLength())
+           {
+             boolean[] tg = gapMap;
+             gapMap = new boolean[seq.getLength()];
+             System.arraycopy(tg, 0, gapMap, 0, tg.length);
+             for (int p = tg.length; p < gapMap.length; p++)
+             {
+               gapMap[p] = false; // init as a gap
+             }
+           }
+           for (int apos : sq.gapMap())
+           {
+             char sqc = sq.getCharAt(apos);
+             boolean isStandard = sq.isProtein() ? ResidueProperties.aaIndex[sqc] < 20
+                 : ResidueProperties.nucleotideIndex[sqc] < 5;
+             if (!operation.getFilterNonStandardSymbols() || isStandard)
+             {
+               gapMap[apos] = true;
+             }
+           }
+         }
+         else
+         {
+           // TODO: add ability to exclude hidden regions
+           String sqstring = sq.getSequenceAsString(start, end + 1);
+           seq = new Sequence(newName,
+               AlignSeq.extractGaps(jalview.util.Comparison.GapChars, sqstring));
+           seqs.add(seq);
+           // for annotation need to also record map to sequence start/end
+           // position in range
+           // then transfer back to original sequence on return.
+         }
+         ln = Integer.max(seq.getLength(), ln);
+       }
+     }
+     if (operation.getNeedsAlignedSequences() && submitGaps)
+     {
+       int realw = 0;
+       for (int i = 0; i < gapMap.length; i++)
+       {
+         if (gapMap[i])
+         {
+           realw++;
+         }
+       }
+       // try real hard to return something submittable
+       // TODO: some of AAcon measures need a minimum of two or three amino
+       // acids at each position, and AAcon doesn't gracefully degrade.
+       for (int p = 0; p < seqs.size(); p++)
+       {
+         SequenceI sq = seqs.get(p);
+         // strip gapped columns
+         char[] padded = new char[realw];
+         char[] orig = sq.getSequence();
+         for (int i = 0, pp = 0; i < realw; pp++)
+         {
+           if (gapMap[pp])
+           {
+             if (orig.length > pp)
+             {
+               padded[i++] = orig[pp];
+             }
+             else
+             {
+               padded[i++] = '-';
+             }
+           }
+         }
+         seqs.set(p, new Sequence(sq.getName(), new String(padded)));
+       }
+     }
+     return seqs;
+   }
+   private boolean checkInputSequencesValid(List<SequenceI> sequences)
+   {
+     int nvalid = 0;
+     boolean allowProtein = operation.isProteinOperation(),
+         allowNucleotides = operation.isNucleotideOperation();
+     for (SequenceI sq : sequences)
+     {
+       if (sq.getStart() <= sq.getEnd() &&
+           (sq.isProtein() ? allowProtein : allowNucleotides))
+       {
+         nvalid++;
+       }
+     }
+     return nvalid >= operation.getMinSequences();
+   }
+   @Override
+   public boolean poll() throws IOException
+   {
+     if (!job.getStatus().isDone() && !job.getStatus().isFailed())
+     {
 -      Cache.log.debug(format("Polling job %s", job));
++      Console.debug(format("Polling job %s", job));
+       try
+       {
+         service.updateProgress(job);
+         exceptionCount = MAX_RETRY;
+       } catch (IOException e)
+       {
 -        Cache.log.error(format("Polling job %s failed.", job), e);
++        Console.error(format("Polling job %s failed.", job), e);
+         if (--exceptionCount <= 0)
+         {
+           job.setStatus(WSJobStatus.SERVER_ERROR);
 -          Cache.log.warn(format("Attempts limit exceeded. Dropping job %s.", job));
++          Console.warn(format("Attempts limit exceeded. Dropping job %s.", job));
+         }
+       } catch (OutOfMemoryError e)
+       {
+         job.setStatus(WSJobStatus.BROKEN);
 -        Cache.log.error(format("Out of memory when retrieving job %s", job), e);
++        Console.error(format("Out of memory when retrieving job %s", job), e);
+       }
+     }
+     return job.getStatus().isDone() || job.getStatus().isFailed();
+   }
+   @Override
+   public void cancel()
+   {
+     try
+     {
+       service.cancel(job);
+     } catch (IOException e)
+     {
 -      Cache.log.error(format("Failed to cancel job %s.", job), e);
++      Console.error(format("Failed to cancel job %s.", job), e);
+     }
+   }
+   @Override
+   public void done()
+   {
 -    Cache.log.debug(format("Polling loop exited, job %s is %s", job, job.getStatus()));
++    Console.debug(format("Polling loop exited, job %s is %s", job, job.getStatus()));
+     if (!job.getStatus().isCompleted())
+     {
+       return;
+     }
+     List<AlignmentAnnotation> outputAnnotations = null;
+     try
+     {
+       outputAnnotations = operation.annotationSupplier
+           .getResult(job, sequences, viewport);
+     } catch (IOException e)
+     {
 -      Cache.log.error(format("Couldn't retrieve features for job %s.", job), e);
++      Console.error(format("Couldn't retrieve features for job %s.", job), e);
+     }
+     if (outputAnnotations != null)
 -      Cache.log.debug(format("Obtained %d annotation rows.", outputAnnotations.size()));
++      Console.debug(format("Obtained %d annotation rows.", outputAnnotations.size()));
+     else
 -      Cache.log.debug("Obtained no annotations.");
++      Console.debug("Obtained no annotations.");
+     Map<String, FeatureColourI> featureColours = new HashMap<>();
+     Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
+     FeaturesFile featuresFile;
+     try
+     {
+       // I think there should be a better way for obtaining features
+       // Are the features added to the sequences here?
+       featuresFile = operation.featuresSupplier.getResult(job, sequences, viewport);
+       if (featuresFile != null)
+       {
+         Alignment aln = new Alignment(sequences.toArray(new SequenceI[0]));
+         // I do nothing with the featureFilters object
+         featuresFile.parse(aln, featureColours, true);
+       }
+     } catch (IOException e)
+     {
 -      Cache.log.error(format("Couldn't retrieve features for job %s", job), e);
++      Console.error(format("Couldn't retrieve features for job %s", job), e);
+     }
 -    Cache.log.debug(format("There are %d feature colours and %d filters.",
++    Console.debug(format("There are %d feature colours and %d filters.",
+         featureColours.size(), featureFilters.size()));
+     if (outputAnnotations != null)
+     {
+       for (AlignmentAnnotation aa : outputAnnotations)
+       {
+         if (aa.getCalcId() == null || aa.getCalcId().equals(""))
+         {
+           aa.setCalcId(service.getName());
+         }
+         // Can't services other than alignment analysis be interactive?
+         // What's the point of storing that information in the annotation?
+         aa.autoCalculated = operation.isAlignmentAnalysis() && operation.isInteractive();
+       }
+       updateResultAnnotation(outputAnnotations);
+       if (transferSequenceFeatures)
+       {
 -        Cache.log.debug(format("Updating feature display settings and transferring"
++        Console.debug(format("Updating feature display settings and transferring"
+             + "features fron job %s at %s", job, service.getHostName()));
+         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);
+           }
+         });
+         if (frame.alignPanel == alignPanel)
+         {
+           viewport.setShowSequenceFeatures(true);
+           frame.setMenusForViewport();
+         }
+       }
+     }
 -    Cache.log.debug("Annotation service task finished.");
++    Console.debug("Annotation service task finished.");
+   }
+   // What is the purpose of this method?
+   // When is it called (apart from the above)?
+   private void updateResultAnnotation(List<AlignmentAnnotation> annotations)
+   {
+     var currentAnnotations = Objects.requireNonNullElse(
+         viewport.getAlignment().getAlignmentAnnotation(),
+         new AlignmentAnnotation[0]);
+     List<AlignmentAnnotation> newAnnots = new ArrayList<>();
+     // what is the graph group and why starting from 1?
+     int graphGroup = 1;
+     for (AlignmentAnnotation alna : currentAnnotations)
+     {
+       graphGroup = Integer.max(graphGroup, alna.graphGroup);
+     }
+     for (AlignmentAnnotation ala : annotations)
+     {
+       if (ala.graphGroup > 0)
+       {
+         ala.graphGroup += graphGroup;
+       }
+       // stores original sequence, in what case it ends up as null?
+       SequenceI aseq = null;
+       if (ala.sequenceRef != null)
+       {
+         SequenceI seq = seqNames.get(ala.sequenceRef.getName());
+         aseq = seq;
+         while (seq.getDatasetSequence() != null)
+         {
+           seq = seq.getDatasetSequence();
+         }
+       }
+       Annotation[] resAnnot = ala.annotations;
+       Annotation[] gappedAnnot = new Annotation[Math
+           .max(viewport.getAlignment().getWidth(), gapMap.length)];
+       // is it adding gaps which were previously removed to the annotation?
+       for (int p = 0, ap = start; ap < gappedAnnot.length; ap++)
+       {
+         if (gapMap != null && gapMap.length > ap && !gapMap[ap])
+         {
+           gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
+         }
+         else if (p < resAnnot.length)
+         {
+           gappedAnnot[ap] = resAnnot[p++];
+         }
+       }
+       // replacing sequence with the original one?
+       ala.sequenceRef = aseq;
+       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);
+     }
+     for (SequenceI sq : sequences)
+     {
+       // what are DBRefs? why are they relevant here?
+       if (!sq.getFeatures().hasFeatures() &&
+           (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
+       {
+         continue;
+       }
+       transferSequenceFeatures = true;
+       SequenceI seq = seqNames.get(sq.getName());
+       SequenceI dseq;
+       ContiguousI seqRange = seq.findPositions(start, end);
+       while ((dseq = seq).getDatasetSequence() != null)
+       {
+         seq = seq.getDatasetSequence();
+       }
+       List<ContiguousI> sourceRange = new ArrayList<>();
+       if (gapMap != null && 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);
+     }
+     updateOurAnnots(newAnnots);
+   }
+   protected void updateOurAnnots(List<AlignmentAnnotation> annots)
+   {
+     List<AlignmentAnnotation> our = ourAnnots;
+     ourAnnots = Collections.synchronizedList(annots);
+     AlignmentI alignment = viewport.getAlignment();
+     if (our != null)
+     {
+       if (our.size() > 0)
+       {
+         for (AlignmentAnnotation an : our)
+         {
+           if (!ourAnnots.contains(an))
+           {
+             // remove the old annotation
+             alignment.deleteAnnotation(an);
+           }
+         }
+       }
+       our.clear();
+     }
+     // validate rows and update Alignment state
+     synchronized (ourAnnots)
+     {
+       for (AlignmentAnnotation an : ourAnnots)
+       {
+         viewport.getAlignment().validateAnnotation(an);
+       }
+     }
+     // TODO: may need a menu refresh after this
+     // af.setMenusForViewport();
+     alignPanel.adjustAnnotationHeight();
+   }
+ }
index 0000000,c0c7502..e016fa1
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,209 +1,210 @@@
+ package jalview.ws2.slivka;
+ import java.io.IOException;
+ import java.net.MalformedURLException;
+ import java.net.URL;
+ import java.util.*;
+ import java.util.concurrent.*;
+ import jalview.bin.Cache;
++import jalview.bin.Console;
+ import jalview.ws2.*;
+ import jalview.ws2.operations.AlignmentOperation;
+ import jalview.ws2.operations.AnnotationOperation;
+ import jalview.ws2.operations.Operation;
+ import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
+ import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+ public class SlivkaWSDiscoverer implements WebServiceDiscoverer
+ {
+   private static final String SLIVKA_HOST_URLS = "SLIVKSHOSTURLS";
+   private static final String DEFAULT_URL = "https://www.compbio.dundee.ac.uk/slivka/";
+   private static SlivkaWSDiscoverer instance = null;
+   private List<WebServiceI> services = List.of();
+   private SlivkaWSDiscoverer()
+   {
+   }
+   public static SlivkaWSDiscoverer getInstance()
+   {
+     if (instance == null)
+     {
+       instance = new SlivkaWSDiscoverer();
+     }
+     return instance;
+   }
+   @Override
+   public List<String> getUrls()
+   {
+     String surls = Cache.getDefault(SLIVKA_HOST_URLS, DEFAULT_URL);
+     String urls[] = surls.split(",");
+     ArrayList<String> valid = new ArrayList<>(urls.length);
+     for (String url : urls)
+     {
+       try
+       {
+         new URL(url);
+         valid.add(url);
+       } catch (MalformedURLException e)
+       {
 -        Cache.log.warn("Problem whilst trying to make a URL from '"
++        Console.warn("Problem whilst trying to make a URL from '"
+                 + Objects.toString(url, "<null>") + "'. "
+                 + "This was probably due to malformed comma-separated-list "
+                 + "in the " + SLIVKA_HOST_URLS
+                 + " entry of ${HOME}/.jalview_properties");
 -        Cache.log.debug("Exception occurred while reading url list", e);
++        Console.debug("Exception occurred while reading url list", e);
+       }
+     }
+     return valid;
+   }
+   @Override
+   public void setUrls(List<String> wsUrls)
+   {
+     if (wsUrls != null && !wsUrls.isEmpty())
+     {
+       Cache.setProperty(SLIVKA_HOST_URLS, String.join(",", wsUrls));
+     }
+     else
+     {
+       Cache.removeProperty(SLIVKA_HOST_URLS);
+     }
+   }
+   @Override
+   public boolean testUrl(URL url)
+   {
+     return getStatusForUrl(url.toString()) == STATUS_OK;
+   }
+   @Override
+   public int getStatusForUrl(String url)
+   {
+     try
+     {
+       List<?> services = new SlivkaClient(url).getServices();
+       return services.isEmpty() ? STATUS_NO_SERVICES : STATUS_OK;
+     } catch (IOException e)
+     {
 -      Cache.log.error("Slivka could not retrieve services list from " + url,
++      Console.error("Slivka could not retrieve services list from " + url,
+               e);
+       return STATUS_INVALID;
+     }
+   }
+   public List<WebServiceI> getServices()
+   {
+     return Collections.unmodifiableList(services);
+   }
+   public boolean hasServices()
+   {
+     return !isRunning() && services.size() > 0;
+   }
+   public boolean isRunning()
+   {
+     for (Future<?> task : discoveryTasks)
+     {
+       if (!task.isDone())
+       {
+         return true;
+       }
+     }
+     return false;
+   }
+   public boolean isDone()
+   {
+     return !isRunning() && discoveryTasks.size() > 0;
+   }
+   private Vector<Future<?>> discoveryTasks = new Vector<>();
+   @Override
+   public CompletableFuture<WebServiceDiscoverer> startDiscoverer()
+   {
+     CompletableFuture<WebServiceDiscoverer> task = CompletableFuture
+             .supplyAsync(() -> {
+               reloadServices();
+               return SlivkaWSDiscoverer.this;
+             });
+     task.thenRun(() -> fireServicesChanged(getServices()));
+     discoveryTasks.add(task);
+     return task;
+   }
+   private List<WebServiceI> reloadServices()
+   {
 -    Cache.log.info("Reloading Slivka services");
++    Console.info("Reloading Slivka services");
+     fireServicesChanged(Collections.emptyList());
+     ArrayList<WebServiceI> allServices = new ArrayList<>();
+     for (String url : getUrls())
+     {
+       SlivkaClient client = new SlivkaClient(url);
+       List<SlivkaService> services;
+       try
+       {
+         services = client.getServices();
+       } catch (IOException e)
+       {
 -        Cache.log.error("Unable to fetch services from " + url, e);
++        Console.error("Unable to fetch services from " + url, e);
+         continue;
+       }
+       for (SlivkaService service : services)
+       {
+         SlivkaWebService instance = new SlivkaWebService(client, service);
+         for (String classifier : service.classifiers)
+         {
+           String[] path = classifier.split("\\s*::\\s*");
+           if (path.length >= 3 && path[0].toLowerCase().equals("operation")
+                   && path[1].toLowerCase().equals("analysis"))
+           {
+             Operation op = null;
+             switch (path[path.length - 1].toLowerCase())
+             {
+             case "sequence alignment analysis (conservation)":
+               AnnotationOperation anop;
+               op = anop = new AnnotationOperation(instance,
+                   instance::getAnnotations, instance::getFeaturesFile, "Conservation");
+               anop.setAlignmentAnalysis(true);
+               anop.setInteractive(true);
+               break;
+             case "protein sequence analysis":
+               op = new AnnotationOperation(instance, instance::getAnnotations,
+                   instance::getFeaturesFile, "Protein Disorder");
+               break;
+             case "multiple sequence alignment":
+               op = new AlignmentOperation(instance, instance::getAlignment);
+               break;
+             }
+             if (op != null)
+             {
+               instance.addOperation(op);
+               break;
+             }
+           }
+         }
+         if (instance.operations.size() > 0)
+         {
+           allServices.add(instance);
+         }
+       }
+     }
+     this.services = allServices;
 -    Cache.log.info("Reloading slivka services finished");
++    Console.info("Reloading slivka services finished");
+     return allServices;
+   }
+   @Override
+   public String getErrorMessages()
+   {
+     return "";
+   }
+ }
index 0000000,4c58b61..cf03cbe
mode 000000,100644..100644
--- /dev/null
@@@ -1,0 -1,310 +1,311 @@@
+ package jalview.ws2.slivka;
+ import java.io.ByteArrayInputStream;
+ import java.io.ByteArrayOutputStream;
+ import java.io.IOException;
+ import java.io.InputStream;
+ import java.util.ArrayList;
+ import java.util.Arrays;
+ import java.util.Collection;
+ import java.util.EnumMap;
+ import java.util.HashSet;
+ import java.util.List;
+ import java.util.Set;
+ import jalview.api.AlignViewportI;
+ import jalview.bin.Cache;
++import jalview.bin.Console;
+ import jalview.datamodel.Alignment;
+ import jalview.datamodel.AlignmentAnnotation;
+ import jalview.datamodel.AlignmentI;
+ import jalview.datamodel.SequenceI;
+ import jalview.io.AnnotationFile;
+ import jalview.io.DataSourceType;
+ import jalview.io.FeaturesFile;
+ import jalview.io.FileFormat;
+ import jalview.io.FileFormatI;
+ import jalview.io.FormatAdapter;
+ import jalview.ws.gui.WsJob;
+ import jalview.ws.params.ArgumentI;
+ import jalview.ws.params.ParamDatastoreI;
+ import jalview.ws.params.WsParamSetI;
+ import jalview.ws.slivkaws.SlivkaDatastore;
+ import jalview.ws2.WebServiceI;
+ import jalview.ws2.operations.Operation;
+ import jalview.ws2.ResultSupplier;
+ import jalview.ws2.WSJob;
+ import jalview.ws2.WSJobStatus;
+ import javajs.http.ClientProtocolException;
+ import uk.ac.dundee.compbio.slivkaclient.Job;
+ import uk.ac.dundee.compbio.slivkaclient.Parameter;
+ import uk.ac.dundee.compbio.slivkaclient.RemoteFile;
+ import uk.ac.dundee.compbio.slivkaclient.SlivkaClient;
+ import uk.ac.dundee.compbio.slivkaclient.SlivkaService;
+ public class SlivkaWebService implements WebServiceI
+ {
+   protected final SlivkaClient client;
+   protected final SlivkaService service;
+   protected SlivkaDatastore store = null;
+   protected final ArrayList<Operation> operations = new ArrayList<>();
+   protected int typeFlags = 0;
+   protected static final EnumMap<Job.Status, WSJobStatus> statusMap = new EnumMap<>(
+           Job.Status.class);
+   {
+     statusMap.put(Job.Status.PENDING, WSJobStatus.SUBMITTED);
+     statusMap.put(Job.Status.REJECTED, WSJobStatus.INVALID);
+     statusMap.put(Job.Status.ACCEPTED, WSJobStatus.QUEUED);
+     statusMap.put(Job.Status.QUEUED, WSJobStatus.QUEUED);
+     statusMap.put(Job.Status.RUNNING, WSJobStatus.RUNNING);
+     statusMap.put(Job.Status.COMPLETED, WSJobStatus.FINISHED);
+     statusMap.put(Job.Status.INTERRUPTED, WSJobStatus.CANCELLED);
+     statusMap.put(Job.Status.DELETED, WSJobStatus.CANCELLED);
+     statusMap.put(Job.Status.FAILED, WSJobStatus.FAILED);
+     statusMap.put(Job.Status.ERROR, WSJobStatus.SERVER_ERROR);
+     statusMap.put(Job.Status.UNKNOWN, WSJobStatus.UNKNOWN);
+   }
+   public SlivkaWebService(SlivkaClient client, SlivkaService service)
+   {
+     this.client = client;
+     this.service = service;
+   }
+   @Override
+   public String getHostName()
+   {
+     return client.getUrl().toString();
+   }
+   @Override
+   public String getProviderName()
+   {
+     return "slivka";
+   }
+   @Override
+   public String getName()
+   {
+     return service.getName();
+   }
+   @Override
+   public String getDescription()
+   {
+     return service.getDescription();
+   }
+   @Override
+   public List<Operation> getOperations()
+   {
+     return operations;
+   }
+   void addOperation(Operation operation)
+   {
+     operations.add(operation);
+   }
+   void removeOperation(Operation operation)
+   {
+     operations.remove(operation);
+   }
+   @Override
+   public boolean hasParameters()
+   {
+     return getParamStore().getServiceParameters().size() > 0;
+   }
+   @Override
+   public ParamDatastoreI getParamStore()
+   {
+     if (store == null)
+     {
+       store = new SlivkaDatastore(service);
+     }
+     return store;
+   }
+   @Override
+   public String submit(List<SequenceI> sequences, List<ArgumentI> args)
+           throws IOException
+   {
+     var request = new uk.ac.dundee.compbio.slivkaclient.JobRequest();
+     for (Parameter param : service.getParameters())
+     {
+       if (param instanceof Parameter.FileParameter)
+       {
+         // if finds a file input, gives it sequences stream
+         Parameter.FileParameter fileParam = (Parameter.FileParameter) param;
+         FileFormat format;
+         switch (fileParam.getMediaType())
+         {
+         case "application/pfam":
+           format = FileFormat.Pfam;
+           break;
+         case "application/stockholm":
+           format = FileFormat.Stockholm;
+           break;
+         case "application/clustal":
+           format = FileFormat.Clustal;
+           break;
+         case "application/fasta":
+         default:
+           format = FileFormat.Fasta;
+           break;
+         }
+         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
+         {
+           request.addData(paramId, arg.getValue());
+         }
+       }
+     }
+     var job = service.submitJob(request);
+     return job.getId();
+   }
+   @Override
+   public void updateProgress(WSJob job) throws IOException
+   {
+     var slivkaJob = client.getJob(job.getJobId());
+     job.setStatus(statusMap.get(slivkaJob.getStatus()));
+     Collection<RemoteFile> files = slivkaJob.getResults();
+     for (RemoteFile f : files)
+     {
+       if (f.getLabel().equals("log"))
+       {
+         ByteArrayOutputStream stream = new ByteArrayOutputStream();
+         f.writeTo(stream);
+         job.setLog(stream.toString("UTF-8"));
+       }
+       else if (f.getLabel().equals("error-log"))
+       {
+         ByteArrayOutputStream stream = new ByteArrayOutputStream();
+         f.writeTo(stream);
+         job.setErrorLog(stream.toString("UTF-8"));
+       }
+     }
+   }
+   @Override
+   public void cancel(WSJob job) throws IOException
+   {
+     job.setStatus(WSJobStatus.CANCELLED);
 -    Cache.log.warn("Slivka does not support job cancellation yet.");
++    Console.warn("Slivka does not support job cancellation yet.");
+   }
+   @Override
+   public boolean handleSubmissionError(WSJob job, Exception ex)
+   {
+     if (ex instanceof ClientProtocolException)
+     {
 -      Cache.log.error("Job submission failed due to exception.", ex);
++      Console.error("Job submission failed due to exception.", ex);
+       return true;
+     }
+     return false;
+   }
+   @Override
+   public boolean handleCollectionError(WSJob job, Exception ex)
+   {
+     // TODO Auto-generated method stub
+     return false;
+   }
+   public AlignmentI getAlignment(WSJob job, List<SequenceI> dataset,
+       AlignViewportI viewport) throws IOException
+   {
+     Collection<RemoteFile> files;
+     var slivkaJob = client.getJob(job.getJobId());
+     files = slivkaJob.getResults();
+     for (RemoteFile f : files)
+     {
+       if (f.getMediaType().equals("application/clustal"))
+       {
+         return new FormatAdapter().readFile(f.getContentUrl().toString(),
+                 DataSourceType.URL, FileFormat.Clustal);
+       }
+       else if (f.getMediaType().equals("application/fasta"))
+       {
+         return new FormatAdapter().readFile(f.getContentUrl().toString(),
+                 DataSourceType.URL, FileFormat.Fasta);
+       }
+     }
+     return null;
+   }
+   public FeaturesFile getFeaturesFile(WSJob job,
+       List<SequenceI> dataset, AlignViewportI viewport) throws IOException
+   {
+     var slivkaJob = client.getJob(job.getJobId());
+     Collection<RemoteFile> files = slivkaJob.getResults();
+     for (RemoteFile f : files)
+     {
+       if (f.getMediaType().equals("application/jalview-features"))
+       {
+         return new FeaturesFile(f.getContentUrl().toString(), DataSourceType.URL);
+       }
+     }
+     return null;
+   }
+   public List<AlignmentAnnotation> getAnnotations(WSJob job,
+       List<SequenceI> dataset, AlignViewportI viewport) throws IOException
+   {
+     var slivkaJob = client.getJob(job.getJobId());
+     Collection<RemoteFile> files = slivkaJob.getResults();
+     for (RemoteFile f : files)
+     {
+       if (f.getMediaType().equals("application/jalview-annotations"))
+       {
+         Alignment aln = new Alignment(dataset.toArray(new SequenceI[0]));
+         AnnotationFile af = new AnnotationFile();
+         boolean valid = af.readAnnotationFileWithCalcId(aln, service.getId(),
+             f.getContentUrl().toString(), DataSourceType.URL);
+         if (valid)
+         {
+           return Arrays.asList(aln.getAlignmentAnnotation());
+         }
+         else
+         {
+           throw new IOException("Unable to read annotations from file " +
+               f.getContentUrl().toString());
+         }
+       }
+     }
+     return null;
+   }
+   @Override
+   public String toString()
+   {
+     return String.format("SlivkaWebService[%s]", getName());
+   }
+ }