JAL-3878 Add action and task for annotation services
authorMateusz Warowny <mmzwarowny@dundee.ac.uk>
Thu, 7 Apr 2022 12:30:04 +0000 (14:30 +0200)
committerMateusz Warowny <mmzwarowny@dundee.ac.uk>
Thu, 7 Apr 2022 12:30:04 +0000 (14:30 +0200)
src/jalview/util/Pair.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationAction.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationJob.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationProviderI.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationResult.java [new file with mode: 0644]
src/jalview/ws2/actions/annotation/AnnotationTask.java [new file with mode: 0644]
src/jalview/ws2/client/api/AnnotationWebServiceClientI.java [new file with mode: 0644]

diff --git a/src/jalview/util/Pair.java b/src/jalview/util/Pair.java
new file mode 100644 (file)
index 0000000..63cf7e9
--- /dev/null
@@ -0,0 +1,88 @@
+package jalview.util;
+
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A generic immutable pair of values.
+ * 
+ * @author mmwarowny
+ *
+ * @param <T>
+ *          first value type
+ * @param <U>
+ *          second value type
+ */
+public class Pair<T, U> implements Iterable<Object>
+{
+  final T val0;
+
+  final U val1;
+
+  public Pair(T val0, U val1)
+  {
+    this.val0 = val0;
+    this.val1 = val1;
+  }
+  
+  /**
+   * Return value at the specified index cast to type R
+   * @param <R> return type
+   * @param index item index
+   * @return value at given index
+   * @throws ClassCastException value cannot be cast to R
+   * @throws IndexOutOfBoundsException index outside tuple size
+   */
+  @SuppressWarnings("unchecked")
+  public <R> R get(int index) throws ClassCastException, IndexOutOfBoundsException
+  {
+    if (index == 0)
+      return (R) val0;
+    if (index == 1)
+      return (R) val1;
+    throw new IndexOutOfBoundsException(index);
+  }
+
+  /**
+   * @return 0th value of the pair
+   */
+  public T get0()
+  {
+    return val0;
+  }
+
+  /**
+   * @return 1st value of the pair
+   */
+  public U get1()
+  {
+    return val1;
+  }
+  
+  /**
+   * @return tuple size
+   */
+  public int size()
+  {
+    return 2;
+  }
+
+  @Override
+  public boolean equals(Object obj)
+  {
+    if (obj instanceof Pair)
+    {
+      Pair<?, ?> other = (Pair<?, ?>) obj;
+      return Objects.equals(val0, other.val0) &&
+          Objects.equals(val1, other.val1);
+    }
+    return false;
+  }
+
+  @Override
+  public Iterator<Object> iterator()
+  {
+    return List.of(val0, val1).iterator();
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationAction.java b/src/jalview/ws2/actions/annotation/AnnotationAction.java
new file mode 100644 (file)
index 0000000..02829fd
--- /dev/null
@@ -0,0 +1,128 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.List;
+import java.util.Objects;
+
+import jalview.viewmodel.AlignmentViewport;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.BaseAction;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+
+public class AnnotationAction extends BaseAction<AnnotationResult>
+{
+  /**
+   * A builder of {@link AnnotationAction} instances.
+   */
+  public static class Builder extends BaseAction.Builder<AnnotationAction>
+  {
+    protected AnnotationWebServiceClientI client;
+    
+    protected boolean alignmentAnalysis = false;
+    
+    protected boolean requireAlignedSequences = false;
+    
+    protected boolean filterSymbols = true;
+
+    public Builder(AnnotationWebServiceClientI client)
+    {
+      super();
+      Objects.requireNonNull(client);
+      this.client = client;
+    }
+    
+    /**
+     * Set if action is an alignment analysis action.
+     */
+    public void alignmentAnalysis(boolean val)
+    {
+      alignmentAnalysis = val;
+    }
+    
+    /**
+     * Set if action require aligned sequences.
+     */
+    public void requireAlignedSequences(boolean val)
+    {
+      requireAlignedSequences = val;
+    }
+
+    /**
+     * Set if action requires non-standard residues to be filtered out 
+     */
+    public void filterSymbols(boolean val)
+    {
+      filterSymbols = val;
+    }
+
+    public AnnotationAction build()
+    {
+      return new AnnotationAction(this);
+    }
+  }
+
+  public static Builder newBuilder(AnnotationWebServiceClientI client)
+  {
+    return new Builder(client);
+  }
+
+  protected final AnnotationWebServiceClientI client;
+  
+  protected final boolean alignmentAnalysis;
+  
+  protected final boolean requireAlignedSequences;
+  
+  protected final boolean filterSymbols;
+
+  protected AnnotationAction(Builder builder)
+  {
+    super(builder);
+    client = builder.client;
+    alignmentAnalysis = builder.alignmentAnalysis;
+    requireAlignedSequences = builder.requireAlignedSequences;
+    filterSymbols = builder.filterSymbols;
+  }
+
+  @Override
+  public TaskI<AnnotationResult> perform(AlignmentViewport viewport,
+      List<ArgumentI> args, Credentials credentials,
+      TaskEventListener<AnnotationResult> handler)
+  {
+    var task = new AnnotationTask(client, this, args, credentials, viewport,
+        handler);
+    task.start(viewport.getCalcManager());
+    return task;
+  }
+
+  /**
+   * Return if this action is an alignment analysis service.
+   */
+  public boolean isAlignmentAnalysis()
+  {
+    return alignmentAnalysis;
+  }
+
+  /**
+   * Return if this action require sequences to be aligned.
+   */
+  public boolean getRequireAlignedSequences()
+  {
+    return requireAlignedSequences;
+  }
+  
+  /**
+   * Return if this action require non-standard symbols to be filtered out.
+   */
+  public boolean getFilterSymbols()
+  {
+    return filterSymbols;
+  }
+  
+  @Override
+  public boolean isActive(AlignmentViewport viewport)
+  {
+    return false;
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationJob.java b/src/jalview/ws2/actions/annotation/AnnotationJob.java
new file mode 100644 (file)
index 0000000..23e462b
--- /dev/null
@@ -0,0 +1,144 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.ArrayList;
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignSeq;
+import jalview.analysis.SeqsetUtils;
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AnnotatedCollectionI;
+import jalview.datamodel.Sequence;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.schemes.ResidueProperties;
+import jalview.util.Comparison;
+import jalview.ws2.actions.BaseJob;
+
+public class AnnotationJob extends BaseJob
+{
+  final boolean[] gapMap;
+
+  final Map<String, SequenceI> seqNames;
+
+  final int start, end;
+  
+  final int minSize;
+
+  List<AlignmentAnnotation> returnedAnnotations = Collections.emptyList();
+  
+  Map<String, FeatureColourI> featureColours = Collections.emptyMap();
+  
+  Map<String, FeatureMatcherSetI> featureFilters = Collections.emptyMap();
+  
+
+  public AnnotationJob(List<SequenceI> inputSeqs, boolean[] gapMap,
+      Map<String, SequenceI> seqNames, int start, int end, int minSize)
+  {
+    super(inputSeqs);
+    this.gapMap = gapMap;
+    this.seqNames = seqNames;
+    this.start = start;
+    this.end = end;
+    this.minSize = minSize;
+  }
+
+  @Override
+  public boolean isInputValid()
+  {
+    int nvalid = 0;
+    for (SequenceI sq : getInputSequences())
+      if (sq.getStart() <= sq.getEnd())
+        nvalid++;
+    return nvalid >= minSize;
+  }
+
+  public static AnnotationJob create(AnnotatedCollectionI inputSeqs, 
+      boolean bySequence, boolean submitGaps, boolean requireAligned, 
+      boolean filterNonStandardResidues, int minSize)
+  {
+    List<SequenceI> seqs = new ArrayList<>();
+    int minlen = 10;
+    int ln = -1;
+    Map<String, SequenceI> seqNames = bySequence ? new HashMap<>() : null;
+    BitSet gapMap = new BitSet();
+    int gapMapSize = 0;
+    int start = inputSeqs.getStartRes();
+    int end = inputSeqs.getEndRes();
+    // TODO: URGENT! unify with JPred / MSA code to handle hidden regions
+    // correctly
+    // TODO: push attributes into WsJob instance (so they can be safely
+    // persisted/restored
+    for (SequenceI sq : inputSeqs.getSequences())
+    {
+      int sqlen;
+      if (bySequence)
+        sqlen = sq.findPosition(end + 1) - sq.findPosition(start + 1);
+      else
+        sqlen = sq.getEnd() - sq.getStart();
+      if (sqlen >= minlen)
+      {
+        String newName = SeqsetUtils.unique_name(seqs.size() + 1);
+        if (seqNames != null)
+          seqNames.put(newName, sq);
+        Sequence seq;
+        if (submitGaps)
+        {
+          seq = new Sequence(newName, sq.getSequenceAsString());
+          gapMapSize = Math.max(gapMapSize, seq.getLength());
+          for (int pos : sq.gapMap())
+          {
+            char sqchr = sq.getCharAt(pos);
+            boolean include = !filterNonStandardResidues;
+            include |= sq.isProtein() ? ResidueProperties.aaIndex[sqchr] < 20
+                : ResidueProperties.nucleotideIndex[sqchr] < 5;
+            if (include)
+              gapMap.set(pos);
+          }
+        }
+        else
+        {
+          // TODO: add ability to exclude hidden regions
+          seq = new Sequence(newName, AlignSeq.extractGaps(Comparison.GapChars,
+              sq.getSequenceAsString(start, end + 1)));
+          // for annotation need to also record map to sequence start/end
+          // position in range
+          // then transfer back to original sequence on return.
+        }
+        seqs.add(seq);
+        ln = Math.max(ln, seq.getLength());
+      }
+    }
+
+    if (requireAligned && submitGaps)
+    {
+      int realWidth = gapMap.cardinality();
+      for (int i = 0; i < seqs.size(); i++)
+      {
+        SequenceI sq = seqs.get(i);
+        char[] padded = new char[realWidth];
+        char[] original = sq.getSequence();
+        for (int op = 0, pp = 0; pp < realWidth; op++)
+        {
+          if (gapMap.get(op))
+          {
+            if (original.length > op)
+              padded[pp++] = original[op];
+            else
+              padded[pp++] = '-';
+          }
+        }
+        seqs.set(i, new Sequence(sq.getName(), padded));
+      }
+    }
+    boolean[] gapMapArray = new boolean[gapMapSize];
+    for (int i = 0; i < gapMapSize; i++)
+      gapMapArray[i] = gapMap.get(i);
+    return new AnnotationJob(seqs, gapMapArray, seqNames, start, end, minSize);
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationProviderI.java b/src/jalview/ws2/actions/annotation/AnnotationProviderI.java
new file mode 100644 (file)
index 0000000..3a836a0
--- /dev/null
@@ -0,0 +1,48 @@
+package jalview.ws2.actions.annotation;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+import jalview.ws2.client.api.WebServiceClientI;
+
+/**
+ * An interface for providing annotation results to the annotation services. It
+ * declares a method to attach annotations fetched from the server to sequences.
+ * Web service clients wanting to support annotation acitons must implement this
+ * interface in addition to {@link WebServiceClientI}
+ * 
+ * @author mmwarowny
+ *
+ * @see AnnotationWebServiceClientI
+ */
+public interface AnnotationProviderI
+{
+  /**
+   * Retrieves annotations from the job result on the server and attaches them
+   * to provided sequences. Additionally, adds feature colours and filters to
+   * provided containers.
+   * 
+   * @param job
+   *          web service job
+   * @param sequences
+   *          sequences the annotations will be added to
+   * @param colours
+   *          container for feature colours
+   * @param filters
+   *          container for feature filters
+   * @return sequence and alignment annotation rows that should be made
+   *         visible/updated on alignment
+   * @throws IOException
+   *           annotation retrieval failed
+   */
+  public List<AlignmentAnnotation> attachAnnotations(WebServiceJobHandle job,
+      List<SequenceI> sequences, Map<String, FeatureColourI> colours,
+      Map<String, FeatureMatcherSetI> filters) throws IOException;
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationResult.java b/src/jalview/ws2/actions/annotation/AnnotationResult.java
new file mode 100644 (file)
index 0000000..373ecbb
--- /dev/null
@@ -0,0 +1,56 @@
+package jalview.ws2.actions.annotation;
+
+import java.util.List;
+import java.util.Map;
+
+import jalview.api.FeatureColourI;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.features.FeatureMatcherSetI;
+
+/**
+ * A simple data container storing the output of annotation tasks. The object is
+ * constructed on {@link AnnotationTask} completion and passed to an appropriate
+ * handler.
+ * 
+ * @author mmwarowny
+ *
+ */
+public class AnnotationResult
+{
+  final List<AlignmentAnnotation> annotations;
+
+  final boolean transferFeatures;
+
+  final Map<String, FeatureColourI> featureColours;
+
+  final Map<String, FeatureMatcherSetI> featureFilters;
+
+  public AnnotationResult(List<AlignmentAnnotation> annotations, boolean transferFeatures,
+      Map<String, FeatureColourI> featureColours, Map<String, FeatureMatcherSetI> featureFilters)
+  {
+    this.annotations = annotations;
+    this.transferFeatures = transferFeatures;
+    this.featureColours = featureColours;
+    this.featureFilters = featureFilters;
+  }
+
+  public List<AlignmentAnnotation> getAnnotations()
+  {
+    return annotations;
+  }
+  
+  public boolean getTransferFeatures()
+  {
+    return transferFeatures;
+  }
+
+  public Map<String, FeatureColourI> getFeatureColours()
+  {
+    return featureColours;
+  }
+
+  public Map<String, FeatureMatcherSetI> getFeatureFilters()
+  {
+    return featureFilters;
+  }
+}
diff --git a/src/jalview/ws2/actions/annotation/AnnotationTask.java b/src/jalview/ws2/actions/annotation/AnnotationTask.java
new file mode 100644 (file)
index 0000000..86657c4
--- /dev/null
@@ -0,0 +1,573 @@
+package jalview.ws2.actions.annotation;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jalview.analysis.AlignmentAnnotationUtils;
+import jalview.api.AlignCalcManagerI2;
+import jalview.api.AlignCalcWorkerI;
+import jalview.api.AlignViewportI;
+import jalview.api.FeatureColourI;
+import jalview.api.PollableAlignCalcWorkerI;
+import jalview.bin.Cache;
+import jalview.datamodel.AlignmentAnnotation;
+import jalview.datamodel.AlignmentI;
+import jalview.datamodel.AnnotatedCollectionI;
+import jalview.datamodel.Annotation;
+import jalview.datamodel.ContiguousI;
+import jalview.datamodel.Mapping;
+import jalview.datamodel.SequenceI;
+import jalview.datamodel.features.FeatureMatcherSetI;
+import jalview.schemes.FeatureSettingsAdapter;
+import jalview.util.ArrayUtils;
+import jalview.util.MapList;
+import jalview.util.MathUtils;
+import jalview.util.Pair;
+import jalview.workers.AlignCalcWorker;
+import jalview.ws.params.ArgumentI;
+import jalview.ws2.actions.AbstractPollableTask;
+import jalview.ws2.actions.BaseJob;
+import jalview.ws2.actions.ServiceInputInvalidException;
+import jalview.ws2.actions.api.JobI;
+import jalview.ws2.actions.api.TaskEventListener;
+import jalview.ws2.actions.api.TaskI;
+import jalview.ws2.api.Credentials;
+import jalview.ws2.api.JobStatus;
+import jalview.ws2.api.WebServiceJobHandle;
+import jalview.ws2.client.api.AnnotationWebServiceClientI;
+import jalview.ws2.helpers.DelegateJobEventListener;
+import jalview.ws2.helpers.TaskEventSupport;
+
+import static java.util.Objects.requireNonNullElse;
+
+public class AnnotationTask implements TaskI<AnnotationResult>
+{
+  private final long uid = MathUtils.getUID();
+
+  private AnnotationWebServiceClientI client;
+
+  private final AnnotationAction action;
+
+  private final List<ArgumentI> args;
+
+  private final Credentials credentials;
+
+  private final AlignViewportI viewport;
+
+  private final TaskEventSupport<AnnotationResult> eventHandler;
+
+  private JobStatus taskStatus = null;
+
+  private AlignCalcWorkerAdapter worker = null;
+
+  private List<AnnotationJob> jobs = Collections.emptyList();
+
+  private AnnotationResult result = null;
+
+  private DelegateJobEventListener<AnnotationResult> jobEventHandler;
+
+  private class AlignCalcWorkerAdapter extends AlignCalcWorker
+      implements PollableAlignCalcWorkerI
+  {
+    private boolean restarting = false;
+
+    AlignCalcWorkerAdapter(AlignCalcManagerI2 calcMan)
+    {
+      super(viewport, null);
+      this.calcMan = calcMan;
+    }
+
+    String getServiceName()
+    {
+      return action.getWebService().getName();
+    }
+
+    @Override
+    public void startUp() throws Throwable
+    {
+      if (alignViewport.isClosed())
+      {
+        stop();
+        throw new IllegalStateException("Starting annotation for closed viewport");
+      }
+      if (restarting)
+        eventHandler.fireTaskRestarted();
+      else
+        restarting = true;
+      jobs = Collections.emptyList();
+      try
+      {
+        jobs = prepare();
+      } catch (ServiceInputInvalidException e)
+      {
+        setStatus(JobStatus.INVALID);
+        eventHandler.fireTaskException(e);
+        throw e;
+      }
+      setStatus(JobStatus.READY);
+      eventHandler.fireTaskStarted(jobs);
+      for (var job : jobs)
+      {
+        job.addPropertyChagneListener(jobEventHandler);
+      }
+      try
+      {
+        startJobs();
+      } catch (IOException e)
+      {
+        eventHandler.fireTaskException(e);
+        cancelJobs();
+        setStatus(JobStatus.SERVER_ERROR);
+        throw e;
+      }
+      setStatus(JobStatus.SUBMITTED);
+    }
+
+    @Override
+    public boolean poll() throws Throwable
+    {
+      boolean done = AnnotationTask.this.poll();
+      updateGlobalStatus();
+      if (done)
+      {
+        retrieveAndProcessResult();
+        eventHandler.fireTaskCompleted(result);
+      }
+      return done;
+    }
+
+    private void retrieveAndProcessResult() throws IOException
+    {
+      result = retrieveResult();
+      updateOurAnnots(result.annotations);
+      if (result.transferFeatures)
+      {
+        final var featureColours = result.featureColours;
+        final var featureFilters = result.featureFilters;
+        viewport.applyFeaturesStyle(new FeatureSettingsAdapter()
+        {
+          @Override
+          public FeatureColourI getFeatureColour(String type)
+          {
+            return featureColours.get(type);
+          }
+
+          @Override
+          public FeatureMatcherSetI getFeatureFilters(String type)
+          {
+            return featureFilters.get(type);
+          }
+
+          @Override
+          public boolean isFeatureDisplayed(String type)
+          {
+            return featureColours.containsKey(type);
+          }
+        });
+      }
+    }
+
+    @Override
+    public void updateAnnotation()
+    {
+      var job = jobs.size() > 0 ? jobs.get(0) : null;
+      if (!calcMan.isWorking(this) && job != null)
+      {
+        var ret = updateResultAnnotation(job, job.returnedAnnotations);
+        updateOurAnnots(ret.get0());
+      }
+    }
+
+    private void updateOurAnnots(List<AlignmentAnnotation> newAnnots)
+    {
+      List<AlignmentAnnotation> oldAnnots = requireNonNullElse(ourAnnots, Collections.emptyList());
+      ourAnnots = newAnnots;
+      AlignmentI alignment = viewport.getAlignment();
+      for (AlignmentAnnotation an : oldAnnots)
+      {
+        if (!newAnnots.contains(an))
+        {
+          alignment.deleteAnnotation(an);
+        }
+      }
+      oldAnnots.clear();
+      for (AlignmentAnnotation an : ourAnnots)
+      {
+        viewport.getAlignment().validateAnnotation(an);
+      }
+    }
+
+    @Override
+    public void cancel()
+    {
+      cancelJobs();
+    }
+
+    void stop()
+    {
+      super.abortAndDestroy();
+    }
+
+    @Override
+    public void done()
+    {
+      for (var job : jobs)
+      {
+        if (job.isInputValid() && !job.isCompleted())
+        {
+          /* if done was called but job is not completed then it
+           * must have been stopped by an exception */
+          job.setStatus(JobStatus.SERVER_ERROR);
+        }
+      }
+      updateGlobalStatus();
+      // dispose of unfinished jobs just in case
+      cancelJobs();
+    }
+  }
+
+  public AnnotationTask(AnnotationWebServiceClientI client,
+      AnnotationAction action, List<ArgumentI> args, Credentials credentials,
+      AlignViewportI viewport,
+      TaskEventListener<AnnotationResult> eventListener)
+  {
+    this.client = client;
+    this.action = action;
+    this.args = args;
+    this.credentials = credentials;
+    this.viewport = viewport;
+    this.eventHandler = new TaskEventSupport<>(this, eventListener);
+    this.jobEventHandler = new DelegateJobEventListener<>(this.eventHandler);
+  }
+
+  @Override
+  public long getUid()
+  {
+    return uid;
+  }
+
+  public void start(AlignCalcManagerI2 calcManager)
+  {
+    if (this.worker != null)
+      throw new IllegalStateException("task already started");
+    this.worker = new AlignCalcWorkerAdapter(calcManager);
+    if (taskStatus != JobStatus.CANCELLED)
+    {
+      List<AlignCalcWorkerI> oldWorkers = calcManager.getWorkersOfClass(
+          AlignCalcWorkerAdapter.class);
+      for (var worker : oldWorkers)
+      {
+        if (action.getWebService().getName().equalsIgnoreCase(
+            ((AlignCalcWorkerAdapter) worker).getServiceName()))
+        {
+          // remove interactive workers for the same service.
+          calcManager.removeWorker(worker);
+          calcManager.cancelWorker(worker);
+        }
+      }
+      if (action.getWebService().isInteractive())
+        calcManager.registerWorker(worker);
+      else
+        calcManager.startWorker(worker);
+    }
+  }
+
+  /*
+   * The following methods are mostly copied from the {@link AbstractPollableTask}
+   * TODO: move common functionality to a base class
+   */
+  @Override
+  public JobStatus getStatus()
+  {
+    return taskStatus;
+  }
+
+  private void setStatus(JobStatus status)
+  {
+    if (this.taskStatus != status)
+    {
+      this.taskStatus = status;
+      eventHandler.fireTaskStatusChanged(status);
+    }
+  }
+
+  private void updateGlobalStatus()
+  {
+    int precedence = -1;
+    for (BaseJob job : jobs)
+    {
+      JobStatus status = job.getStatus();
+      int jobPrecedence = ArrayUtils.indexOf(JobStatus.statusPrecedence, status);
+      if (precedence < jobPrecedence)
+        precedence = jobPrecedence;
+    }
+    if (precedence >= 0)
+    {
+      setStatus(JobStatus.statusPrecedence[precedence]);
+    }
+  }
+
+  @Override
+  public List<? extends JobI> getSubJobs()
+  {
+    return jobs;
+  }
+
+  /**
+   * Create and return a list of annotation jobs from the current state of the
+   * viewport. Returned job are not started by this method and should be stored
+   * in a field and started separately.
+   * 
+   * @return list of annotation jobs
+   * @throws ServiceInputInvalidException
+   *           input data is not valid
+   */
+  private List<AnnotationJob> prepare() throws ServiceInputInvalidException
+  {
+    AlignmentI alignment = viewport.getAlignment();
+    if (alignment == null || alignment.getWidth() <= 0 ||
+        alignment.getSequences() == null)
+      throw new ServiceInputInvalidException("Alignment does not contain sequences");
+    if (alignment.isNucleotide() && !action.doAllowNucleotide())
+      throw new ServiceInputInvalidException(
+          action.getFullName() + " does not allow nucleotide sequences");
+    if (!alignment.isNucleotide() && !action.doAllowProtein())
+      throw new ServiceInputInvalidException(
+          action.getFullName() + " does not allow protein sequences");
+    boolean bySequence = !action.isAlignmentAnalysis();
+    AnnotatedCollectionI inputSeqs = bySequence ? viewport.getSelectionGroup() : null;
+    if (inputSeqs == null || inputSeqs.getWidth() <= 0 ||
+        inputSeqs.getSequences() == null || inputSeqs.getSequences().size() < 1)
+      inputSeqs = alignment;
+    boolean submitGaps = action.isAlignmentAnalysis();
+    boolean requireAligned = action.getRequireAlignedSequences();
+    boolean filterSymbols = action.getFilterSymbols();
+    int minSize = action.getMinSequences();
+    AnnotationJob job = AnnotationJob.create(inputSeqs, bySequence,
+        submitGaps, requireAligned, filterSymbols, minSize);
+    if (!job.isInputValid())
+    {
+      job.setStatus(JobStatus.INVALID);
+      throw new ServiceInputInvalidException("Annotation job has invalid input");
+    }
+    job.setStatus(JobStatus.READY);
+    return List.of(job);
+  }
+
+  private void startJobs() throws IOException
+  {
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && job.getStatus() == JobStatus.READY)
+      {
+        var serverJob = client.submit(job.getInputSequences(),
+            args, credentials);
+        job.setServerJob(serverJob);
+        job.setStatus(JobStatus.SUBMITTED);
+      }
+    }
+  }
+
+  private boolean poll() throws IOException
+  {
+    boolean allDone = true;
+    for (BaseJob job : jobs)
+    {
+      if (job.isInputValid() && !job.getStatus().isDone())
+      {
+        WebServiceJobHandle serverJob = job.getServerJob();
+        job.setStatus(client.getStatus(serverJob));
+        job.setLog(client.getLog(serverJob));
+        job.setErrorLog(client.getErrorLog(serverJob));
+      }
+      allDone &= job.isCompleted();
+    }
+    return allDone;
+  }
+
+  private AnnotationResult retrieveResult() throws IOException
+  {
+    final Map<String, FeatureColourI> featureColours = new HashMap<>();
+    final Map<String, FeatureMatcherSetI> featureFilters = new HashMap<>();
+    var job = jobs.get(0);
+    List<AlignmentAnnotation> returnedAnnot = client.attachAnnotations(
+        job.getServerJob(), job.getInputSequences(), featureColours,
+        featureFilters);
+    /* TODO
+     * copy over each annotation row returned and also defined on each
+     * sequence, excluding regions not annotated due to gapMap/column
+     * visibility */
+
+    // update calcId if it is not already set on returned annotation
+    for (AlignmentAnnotation annot : returnedAnnot)
+    {
+      if (annot.getCalcId() == null || annot.getCalcId().isEmpty())
+      {
+        annot.setCalcId(action.getFullName());
+      }
+      annot.autoCalculated = action.isAlignmentAnalysis() &&
+          action.getWebService().isInteractive();
+    }
+    job.returnedAnnotations = returnedAnnot;
+    job.featureColours = featureColours;
+    job.featureFilters = featureFilters;
+    var ret = updateResultAnnotation(job, returnedAnnot);
+    var annotations = ret.get0();
+    var transferFeatures = ret.get1();
+    return new AnnotationResult(annotations, transferFeatures, featureColours,
+        featureFilters);
+  }
+
+  private Pair<List<AlignmentAnnotation>, Boolean> updateResultAnnotation(
+      AnnotationJob job, List<AlignmentAnnotation> annotations)
+  {
+    List<AlignmentAnnotation> newAnnots = new ArrayList<>();
+    // update graphGroup for all annotation
+    /* find a graphGroup greater than any existing one, could be moved
+     * to Alignment#getNewGraphGroup() - returns next unused graph group */
+    int graphGroup = 1;
+    if (viewport.getAlignment().getAlignmentAnnotation() != null)
+    {
+      for (var ala : viewport.getAlignment().getAlignmentAnnotation())
+      {
+        graphGroup = Math.max(graphGroup, ala.graphGroup);
+      }
+    }
+    // update graphGroup in the annotation rows returned form service'
+    /* TODO: look at sequence annotation rows and update graph groups in the
+     * case of reference annotation */
+    for (AlignmentAnnotation ala : annotations)
+    {
+      if (ala.graphGroup > 0)
+        ala.graphGroup += graphGroup;
+      SequenceI aseq = null;
+      // transfer sequence refs and adjust gapMap
+      if (ala.sequenceRef != null)
+      {
+        aseq = job.seqNames.get(ala.sequenceRef.getName());
+      }
+      ala.sequenceRef = aseq;
+
+      Annotation[] resAnnot = ala.annotations;
+      boolean[] gapMap = job.gapMap;
+      Annotation[] gappedAnnot = new Annotation[Math.max(
+          viewport.getAlignment().getWidth(), gapMap.length)];
+      for (int p = 0, ap = job.start; ap < gappedAnnot.length; ap++)
+      {
+        if (gapMap.length > ap && !gapMap[ap])
+          gappedAnnot[ap] = new Annotation("", "", ' ', Float.NaN);
+        else if (p < resAnnot.length)
+          gappedAnnot[ap] = resAnnot[p++];
+        // is this loop exhaustive of resAnnot?
+      }
+      ala.annotations = gappedAnnot;
+
+      AlignmentAnnotation newAnnot = viewport.getAlignment()
+          .updateFromOrCopyAnnotation(ala);
+      if (aseq != null)
+      {
+        aseq.addAlignmentAnnotation(newAnnot);
+        newAnnot.adjustForAlignment();
+        AlignmentAnnotationUtils.replaceAnnotationOnAlignmentWith(
+            newAnnot, newAnnot.label, newAnnot.getCalcId());
+      }
+      newAnnots.add(newAnnot);
+    }
+
+    boolean transferFeatures = false;
+    for (SequenceI sq : job.getInputSequences())
+    {
+      if (!sq.getFeatures().hasFeatures() &&
+          (sq.getDBRefs() == null || sq.getDBRefs().size() == 0))
+        continue;
+      transferFeatures = true;
+      SequenceI seq = job.seqNames.get(sq.getName());
+      SequenceI dseq;
+      int start = job.start, end = job.end;
+      boolean[] gapMap = job.gapMap;
+      ContiguousI seqRange = seq.findPositions(start, end);
+      while ((dseq = seq).getDatasetSequence() != null)
+      {
+        seq = seq.getDatasetSequence();
+      }
+      List<ContiguousI> sourceRange = new ArrayList<>();
+      if (gapMap.length >= end)
+      {
+        int lastcol = start, col = start;
+        do
+        {
+          if (col == end || !gapMap[col])
+          {
+            if (lastcol <= (col - 1))
+            {
+              seqRange = seq.findPositions(lastcol, col);
+              sourceRange.add(seqRange);
+            }
+            lastcol = col + 1;
+          }
+        } while (col++ < end);
+      }
+      else
+      {
+        sourceRange.add(seq.findPositions(start, end));
+      }
+
+      int i = 0;
+      int sourceStartEnd[] = new int[sourceRange.size() * 2];
+      for (ContiguousI range : sourceRange)
+      {
+        sourceStartEnd[i++] = range.getBegin();
+        sourceStartEnd[i++] = range.getEnd();
+      }
+      Mapping mp = new Mapping(new MapList(
+          sourceStartEnd, new int[]
+          { seq.getStart(), seq.getEnd() }, 1, 1));
+      dseq.transferAnnotation(sq, mp);
+    }
+
+    return new Pair<>(newAnnots, transferFeatures);
+  }
+
+  @Override
+  public AnnotationResult getResult()
+  {
+    return result;
+  }
+
+  @Override
+  public void cancel()
+  {
+    setStatus(JobStatus.CANCELLED);
+    if (worker != null)
+    {
+      worker.stop();
+    }
+    cancelJobs();
+  }
+
+  public void cancelJobs()
+  {
+    for (BaseJob job : jobs)
+    {
+      if (!job.isCompleted())
+      {
+        try
+        {
+          if (job.getServerJob() != null)
+          {
+            client.cancel(job.getServerJob());
+          }
+          job.setStatus(JobStatus.CANCELLED);
+        } catch (IOException e)
+        {
+          Cache.log.error(String.format(
+              "failed to cancel job %s", job.getServerJob()), e);
+        }
+      }
+    }
+  }
+}
diff --git a/src/jalview/ws2/client/api/AnnotationWebServiceClientI.java b/src/jalview/ws2/client/api/AnnotationWebServiceClientI.java
new file mode 100644 (file)
index 0000000..a6370ea
--- /dev/null
@@ -0,0 +1,16 @@
+package jalview.ws2.client.api;
+
+import jalview.ws2.actions.annotation.AnnotationProviderI;
+
+/**
+ * A mixin interface used by annotation services combining
+ * {@link WebServiceClientI} and {@link AnnotationProviderI} functionality into
+ * one interface. Annotation services use this interface to issue queries to the
+ * server.
+ * 
+ * @author mmwarowny
+ */
+public interface AnnotationWebServiceClientI extends WebServiceClientI, AnnotationProviderI
+{
+
+}