junit
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialog.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialog.java
new file mode 100644
index 00000000..2dc24a14
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialog.java
@@ -0,0 +1,60 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.awt.KeyboardFocusManager;
+
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+
+import net.miginfocom.swing.MigLayout;
+
+/**
+ * A dialog that asks the user to enter a commit message.
+ * The dialog can be closed by using the mouse to clock OK or Cancel,
+ * by pressing CTRL-ENTER or ESCAPE, or by pressing TAB and ENTER.
+ */
+public class CommitMessageDialog
+{
+
+ /**
+ * Show a dialog that asks the user to enter a commit message.
+ *
+ * @return The commit message as a String, or null if the dialog was cancelled.
+ */
+ public static String showDialog()
+ {
+ JTextArea textArea = new JTextArea( 5, 40 );
+
+ // Make the tab key move focus to the next component and shift-tab to the previous.
+ textArea.setFocusTraversalKeys( KeyboardFocusManager.FORWARD_TRAVERSAL_KEYS, null );
+ textArea.setFocusTraversalKeys( KeyboardFocusManager.BACKWARD_TRAVERSAL_KEYS, null );
+
+ JPanel panel = new JPanel();
+ panel.setLayout( new MigLayout( "insets dialog" ) );
+ panel.add( new JLabel( "Save point message:" ), "wrap" );
+ panel.add( new JScrollPane( textArea ), "wrap" );
+ panel.add( new JLabel( "Please describe briefly the changes since the last save point!" ) );
+
+ // Show a JOptionPane, where the TextArea has focus when the dialog is shown.
+ JOptionPane optionPane = new JOptionPane( panel, JOptionPane.PLAIN_MESSAGE, JOptionPane.OK_CANCEL_OPTION )
+ {
+ @Override
+ public void selectInitialValue()
+ {
+ super.selectInitialValue();
+ textArea.requestFocusInWindow();
+ }
+ };
+ JDialog dialog = optionPane.createDialog( "Add Save Point (commit)" );
+ dialog.setVisible( true );
+ dialog.dispose();
+ Object result = optionPane.getValue();
+ if ( result instanceof Integer && ( int ) result == JOptionPane.OK_OPTION )
+ return textArea.getText();
+ else
+ return null;
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialog.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialog.java
new file mode 100644
index 00000000..8ded6e72
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialog.java
@@ -0,0 +1,71 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.Frame;
+import java.awt.event.ItemEvent;
+
+import javax.swing.JCheckBox;
+import javax.swing.JDialog;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+import javax.swing.JTextArea;
+import javax.swing.SwingUtilities;
+
+import org.apache.commons.lang3.exception.ExceptionUtils;
+
+/**
+ * A dialog that shows an exception message and stack trace.
+ * The stack trace is hidden by default and can be shown by
+ * clicking on "details".
+ */
+public class ErrorDialog
+{
+
+ public static void showErrorMessage( String title, Exception exception )
+ {
+ SwingUtilities.invokeLater( () -> showDialog( null, title + " (Error)", exception ) );
+ }
+
+ private static void showDialog( Frame parent, String title, Exception exception )
+ {
+ String message = "\nThere was a problem:\n\n " + exception.getMessage() + "\n\n";
+ final JScrollPane scrollPane = initScrollPane( exception );
+ final JCheckBox checkBox = new JCheckBox( "show details" );
+ checkBox.setForeground( Color.BLUE );
+ Object[] objects = { message, checkBox, scrollPane };
+ JOptionPane pane = new JOptionPane( objects, JOptionPane.ERROR_MESSAGE );
+ JDialog dialog = pane.createDialog( parent, title );
+ dialog.setResizable( true );
+ checkBox.addItemListener( event -> {
+ boolean visible = event.getStateChange() == ItemEvent.SELECTED;
+ scrollPane.setVisible( visible );
+ scrollPane.setPreferredSize( visible ? null : new Dimension( 0, 0 ) );
+ dialog.pack();
+ } );
+ dialog.setModal( true );
+ dialog.pack();
+ dialog.setVisible( true );
+ dialog.dispose();
+ }
+
+ private static JScrollPane initScrollPane( Exception exception )
+ {
+ String stackTrace = ExceptionUtils.getStackTrace( exception );
+ int lines = Math.min( 20, countLines( stackTrace ) );
+ JTextArea textArea = new JTextArea( stackTrace, lines, 70 );
+ textArea.setForeground( new Color( 0x880000 ) );
+ textArea.setEditable( false );
+ textArea.setFont( new Font( Font.MONOSPACED, Font.PLAIN, textArea.getFont().getSize() ) );
+ final JScrollPane scrollPane = new JScrollPane( textArea );
+ scrollPane.setVisible( false );
+ return scrollPane;
+ }
+
+ private static int countLines( String str )
+ {
+ String[] lines = str.split( "\r\n|\r|\n" );
+ return lines.length;
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/FixGraphInconsistenciesPlugin.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/FixGraphInconsistenciesPlugin.java
new file mode 100644
index 00000000..e96023d5
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/FixGraphInconsistenciesPlugin.java
@@ -0,0 +1,144 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+import org.mastodon.collection.RefCollection;
+import org.mastodon.collection.RefCollections;
+import org.mastodon.collection.RefList;
+import org.mastodon.collection.RefSet;
+import org.mastodon.collection.ref.RefArrayList;
+import org.mastodon.collection.ref.RefSetImp;
+import org.mastodon.mamut.model.Link;
+import org.mastodon.mamut.model.Model;
+import org.mastodon.mamut.model.ModelGraph;
+import org.mastodon.mamut.model.Spot;
+import org.mastodon.mamut.plugin.MamutPlugin;
+import org.mastodon.mamut.tomancak.collaboration.utils.ActionDescriptions;
+import org.mastodon.mamut.tomancak.collaboration.utils.BasicDescriptionProvider;
+import org.mastodon.mamut.tomancak.collaboration.utils.BasicMamutPlugin;
+import org.mastodon.ui.keymap.CommandDescriptionProvider;
+import org.mastodon.ui.keymap.KeyConfigContexts;
+import org.scijava.plugin.Plugin;
+
+@Plugin( type = MamutPlugin.class )
+public class FixGraphInconsistenciesPlugin extends BasicMamutPlugin
+{
+
+ private static final ActionDescriptions< FixGraphInconsistenciesPlugin > actionDescriptions = new ActionDescriptions<>( FixGraphInconsistenciesPlugin.class );
+
+ static
+ {
+ actionDescriptions.addActionDescription(
+ "[mastodon-tomancak] fix graph inconsistencies",
+ "Plugins > Trees Management > Fix Graph Inconsistencies",
+ "Flip reversed edges",
+ FixGraphInconsistenciesPlugin::fixGraphInconsistencies );
+ }
+
+ public < T > FixGraphInconsistenciesPlugin()
+ {
+ super( actionDescriptions );
+ }
+
+ private void fixGraphInconsistencies()
+ {
+ Model model = getAppModel().getModel();
+ ModelGraph graph = model.getGraph();
+ flipBackwardsEdges( graph );
+ removeDoubleEdges( graph );
+ removeSameTimepointEdge( graph );
+ printWarnings( graph );
+ }
+
+ private static void printWarnings( ModelGraph graph )
+ {
+ for ( Spot spot : graph.vertices() )
+ {
+ if ( spot.incomingEdges().size() > 1 )
+ System.out.println( "More than one parent: " + spot.getLabel() );
+
+ if ( spot.outgoingEdges().size() > 2 )
+ System.out.println( "More than two children: " + spot.getLabel() );
+ }
+ }
+
+ private static void removeSameTimepointEdge( ModelGraph graph )
+ {
+ RefCollection< Link > backwardEdge = RefCollections.createRefList( graph.edges() );
+ Spot sourceRef = graph.vertexRef();
+ Spot targetRef = graph.vertexRef();
+ for ( Link edge : graph.edges() )
+ {
+ Spot source = edge.getSource( sourceRef );
+ Spot target = edge.getTarget( targetRef );
+ if ( source.getTimepoint() == target.getTimepoint() )
+ backwardEdge.add( edge );
+ }
+ for ( Link edge : backwardEdge )
+ {
+ Spot source = edge.getSource( sourceRef );
+ Spot target = edge.getTarget( targetRef );
+ System.out.println( "Remove same timepoint edge " + source.getLabel() + " -> " + target.getLabel() );
+ graph.remove( edge );
+ }
+ }
+
+ private static void removeDoubleEdges( ModelGraph graph )
+ {
+ Spot ref1 = graph.vertexRef();
+ Spot ref2 = graph.vertexRef();
+ RefSet< Spot > sources = new RefSetImp<>( graph.vertices().getRefPool() );
+ RefList< Link > doubleEdge = new RefArrayList<>( graph.edges().getRefPool() );
+ for ( Spot spot : graph.vertices() )
+ {
+ if ( spot.incomingEdges().size() > 1 )
+ {
+ for ( Link link : spot.incomingEdges() )
+ {
+ Spot source = link.getSource( ref1 );
+ if ( sources.contains( source ) )
+ doubleEdge.add( link );
+ else
+ sources.add( source );
+ }
+ sources.clear();
+ }
+ }
+ for ( Link link : doubleEdge )
+ {
+ Spot source = link.getSource( ref1 );
+ Spot target = link.getTarget( ref2 );
+ System.out.println( "Remove duplicated edge: " + source.getLabel() + " -> " + target.getLabel() );
+ graph.remove( link );
+ }
+ }
+
+ private static void flipBackwardsEdges( ModelGraph graph )
+ {
+ RefCollection< Link > backwardEdge = RefCollections.createRefList( graph.edges() );
+ Spot sourceRef = graph.vertexRef();
+ Spot targetRef = graph.vertexRef();
+ for ( Link edge : graph.edges() )
+ {
+ Spot source = edge.getSource( sourceRef );
+ Spot target = edge.getTarget( targetRef );
+ if ( source.getTimepoint() > target.getTimepoint() )
+ backwardEdge.add( edge );
+ }
+ for ( Link edge : backwardEdge )
+ {
+ Spot source = edge.getSource( sourceRef );
+ Spot target = edge.getTarget( targetRef );
+ System.out.println( "Flip backwards edge " + source.getLabel() + " -> " + target.getLabel() );
+ graph.remove( edge );
+ graph.addEdge( source, target ).init();
+ }
+ }
+
+ @Plugin( type = CommandDescriptionProvider.class )
+ public static class DescriptionProvider extends BasicDescriptionProvider
+ {
+ public DescriptionProvider()
+ {
+ super( actionDescriptions, KeyConfigContexts.MASTODON, KeyConfigContexts.TRACKSCHEME );
+ }
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitController.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitController.java
new file mode 100644
index 00000000..f4845518
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitController.java
@@ -0,0 +1,406 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+
+import javax.swing.JOptionPane;
+import javax.swing.SwingUtilities;
+
+import org.mastodon.mamut.plugin.MamutPlugin;
+import org.mastodon.mamut.tomancak.collaboration.commands.MastodonGitCloneRepository;
+import org.mastodon.mamut.tomancak.collaboration.commands.MastodonGitCreateRepository;
+import org.mastodon.mamut.tomancak.collaboration.commands.MastodonGitNewBranch;
+import org.mastodon.mamut.tomancak.collaboration.commands.MastodonGitSetAuthorCommand;
+import org.mastodon.mamut.tomancak.collaboration.exceptions.GraphMergeConflictException;
+import org.mastodon.mamut.tomancak.collaboration.exceptions.GraphMergeException;
+import org.mastodon.mamut.tomancak.collaboration.utils.ActionDescriptions;
+import org.mastodon.mamut.tomancak.collaboration.utils.BasicDescriptionProvider;
+import org.mastodon.mamut.tomancak.collaboration.utils.BasicMamutPlugin;
+import org.mastodon.mamut.tomancak.collaboration.settings.MastodonGitSettingsService;
+import org.mastodon.ui.keymap.CommandDescriptionProvider;
+import org.mastodon.ui.keymap.KeyConfigContexts;
+import org.scijava.command.CommandService;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+
+@Plugin( type = MamutPlugin.class )
+public class MastodonGitController extends BasicMamutPlugin
+{
+ @Parameter
+ private CommandService commandService;
+
+ @Parameter
+ private MastodonGitSettingsService settingsService;
+
+ public static final ActionDescriptions< MastodonGitController > actionDescriptions = new ActionDescriptions<>( MastodonGitController.class );
+
+ private static final String SHARE_PROJECT_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] share project",
+ "Plugins > Collaborative (Git) > Initialize > Share Project",
+ "Upload Mastodon project to a newly created git repository.",
+ MastodonGitController::shareProject );
+
+ private static final String CLONE_REPOSITORY_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] download shared project (clone)",
+ "Plugins > Collaborative (Git) > Initialize > Download Shared Project (clone)",
+ "Download a shared project, save a copy on the local disc and open it with Mastodon.",
+ MastodonGitController::cloneGitRepository );
+
+ private static final String SET_AUTHOR_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] set author name",
+ "Plugins > Collaborative (Git) > Initialize > Set Author Name",
+ "Set the author name that is used for your commits.",
+ MastodonGitController::setAuthor );
+
+ private static final String SYNCHRONIZE_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] synchronize (commit, pull, push)",
+ "Plugins > Collaborative (Git) > Synchronize (commit, pull, push)",
+ "Download remote changes and upload local changes.",
+ MastodonGitController::synchronize );
+
+ private static final String COMMIT_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] add save point (commit)",
+ "Plugins > Collaborative (Git) > Add Save Point (commit)",
+ "Commit changes to the git repository.",
+ MastodonGitController::commit );
+
+ private static final String PUSH_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] upload changes (push)",
+ "Plugins > Collaborative (Git) > Upload Changes (push)",
+ "Push local changed to the remote server.",
+ MastodonGitController::push );
+
+ private static final String PULL_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] download changes (pull)",
+ "Plugins > Collaborative (Git) > Download Changes (pull)",
+ "Download changes from the remote server and merge them with my changes.",
+ MastodonGitController::pull );
+
+ private static final String RESET_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] go back to latest save point (reset)",
+ "Plugins > Collaborative (Git) > Go Back To Latest Save Point (reset)",
+ "Discard all changes made since the last save point.",
+ MastodonGitController::reset );
+
+ private static final String NEW_BRANCH_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] new branch",
+ "Plugins > Collaborative (Git) > Branches > Create New Branch",
+ "Create a new branch in the git repository.",
+ MastodonGitController::newBranch );
+
+ private static final String SHOW_BRANCH_NAME_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] show branch name",
+ "Plugins > Collaborative (Git) > Branches > Show Branch Name",
+ "Show the name of the current git branch",
+ MastodonGitController::showBranchName );
+
+ private static final String SWITCH_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] switch branch",
+ "Plugins > Collaborative (Git) > Branches > Switch Branch",
+ "Switch to a different branch in the git repository.",
+ MastodonGitController::switchBranch );
+
+ private static final String MERGE_ACTION_KEY = actionDescriptions.addActionDescription(
+ "[mastodon git] merge branch",
+ "Plugins > Collaborative (Git) > Branches > Merge Branch",
+ "Merge a branch into the current branch.",
+ MastodonGitController::mergeBranch );
+
+ private static final List< String > IN_REPOSITORY_ACTIONS = Arrays.asList(
+ COMMIT_ACTION_KEY,
+ PUSH_ACTION_KEY,
+ PULL_ACTION_KEY,
+ RESET_ACTION_KEY,
+ NEW_BRANCH_ACTION_KEY,
+ SWITCH_ACTION_KEY,
+ MERGE_ACTION_KEY );
+
+ private MastodonGitRepository repository;
+
+ public MastodonGitController()
+ {
+ super( actionDescriptions );
+ }
+
+ @Override
+ protected void initialize()
+ {
+ super.initialize();
+ repository = new MastodonGitRepository( getWindowManager() );
+ updateEnableCommands();
+ }
+
+ private void setAuthor()
+ {
+ commandService.run( MastodonGitSetAuthorCommand.class, true );
+ }
+
+ private void shareProject()
+ {
+ if ( !settingsService.isAuthorSpecified() )
+ {
+ askForAuthorName( "Please set your author name before sharing a project." );
+ return;
+ }
+ MastodonGitCreateRepository.Callback callback = ( File directory, String url ) -> {
+ this.repository = MastodonGitRepository.shareProject( getWindowManager(), directory, url );
+ updateEnableCommands();
+ };
+ commandService.run( MastodonGitCreateRepository.class, true, "callback", callback );
+ }
+
+ private void updateEnableCommands()
+ {
+ boolean isRepository = repository.isRepository();
+ IN_REPOSITORY_ACTIONS.forEach( action -> setActionEnabled( action, isRepository ) );
+ }
+
+ private void cloneGitRepository()
+ {
+ commandService.run( MastodonGitCloneRepository.class, true );
+ }
+
+ private void commit()
+ {
+ if ( !settingsService.isAuthorSpecified() )
+ {
+ askForAuthorName( "Please set your author name before adding a save point (commit)." );
+ return;
+ }
+ run( "Add Save Point (Commit)", () -> {
+ if ( repository.isClean() )
+ NotificationDialog.show( "Add Save Point (Commit)",
+ "✓ No changes to commit." );
+ else
+ {
+ String commitMessage = CommitMessageDialog.showDialog();
+ if ( commitMessage == null )
+ return;
+ repository.commitWithoutSave( commitMessage );
+ }
+ } );
+ }
+
+ private void push()
+ {
+ run( "Upload Changes (Push)", () -> {
+ repository.push();
+ NotificationDialog.show( "Upload Changes (Push)",
+ "✓ Completed successfully." );
+ } );
+ }
+
+ private void newBranch()
+ {
+ commandService.run( MastodonGitNewBranch.class, true, "repository", repository );
+ }
+
+ private void switchBranch()
+ {
+ try
+ {
+ String message = "Select a branch";
+ try
+ {
+ repository.fetchAll();
+ }
+ catch ( Exception e )
+ {
+ message += " \n(There was a failure downloading the latest branch changes.)";
+ }
+ List< String > branches = repository.getBranches();
+ String currentBranch = repository.getCurrentBranch();
+ // show JOptionPane that allows to select a branch
+ String selectedBranch = ( String ) JOptionPane.showInputDialog( null, message, "Switch Git Branch", JOptionPane.PLAIN_MESSAGE, null, branches.toArray(), currentBranch );
+ if ( selectedBranch == null )
+ return;
+ // switch to selected branch
+ run( "Switch To Branch", () -> repository.switchBranch( selectedBranch ) );
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Select Branch", e );
+ }
+ }
+
+ private void mergeBranch()
+ {
+ if ( !settingsService.isAuthorSpecified() )
+ {
+ askForAuthorName( "You need to set your author name before you can merge branches." );
+ return;
+ }
+ try
+ {
+ List< String > branches = repository.getBranches();
+ String currentBranch = repository.getCurrentBranch();
+ branches.remove( currentBranch );
+ // show JOptionPane that allows to select a branch
+ String selectedBranch = ( String ) JOptionPane.showInputDialog( null, "Select a branch", "Switch Git Branch", JOptionPane.PLAIN_MESSAGE, null, branches.toArray(), null );
+ if ( selectedBranch == null )
+ return;
+ repository.mergeBranch( selectedBranch );
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Merge Branch", e );
+ }
+ }
+
+ private void pull()
+ {
+ run( "Download Changes (Pull)", () -> {
+ try
+ {
+ repository.pull();
+ }
+ catch ( GraphMergeException e )
+ {
+ if ( !( e instanceof GraphMergeConflictException ) )
+ e.printStackTrace();
+ SwingUtilities.invokeLater( () -> suggestPullAlternative( e.getMessage() ) );
+ }
+ } );
+ }
+
+ private void suggestPullAlternative( String errorMessage )
+ {
+ String title = "Conflict During Download Of Changes (Pull)";
+ String message = "There was a merge conflict during the pull. Details:\n"
+ + " " + errorMessage + "\n\n"
+ + "You made changes on your computer that could not be automatically\n"
+ + "merged with the changes on the server.\n\n"
+ + "You can either:\n"
+ + " 1. Throw away your local changes & local save points.\n"
+ + " 2. Or cancel (And maybe save your local changes to a new branch,\n"
+ + " which you can then be merged into the remote branch.)\n";
+
+ String[] options = { "Discard Local Changes", "Cancel" };
+ int result = JOptionPane.showOptionDialog( null, message, title, JOptionPane.YES_NO_OPTION,
+ JOptionPane.PLAIN_MESSAGE, null, options, options[ 0 ] );
+ if ( result == JOptionPane.YES_OPTION )
+ resetToRemoteBranch();
+ }
+
+ private void resetToRemoteBranch()
+ {
+ run( "Throw Away All Local Changes (Reset To Remote)", () -> repository.resetToRemoteBranch() );
+ }
+
+ private void reset()
+ {
+ run( "Go Back To Last Save Point (Reset)", () -> repository.reset() );
+ }
+
+ private void askForAuthorName( String message )
+ {
+ String title = "Set Author Name";
+ String[] options = { "Set Author Name", "Cancel" };
+ int result = JOptionPane.showOptionDialog( null, message, title, JOptionPane.YES_NO_OPTION,
+ JOptionPane.PLAIN_MESSAGE, null, options, options[ 0 ] );
+ if ( result == JOptionPane.YES_OPTION )
+ setAuthor();
+ }
+
+ private void showBranchName()
+ {
+ run( "Show Branch Name", () -> {
+ String longBranchName = repository.getCurrentBranch();
+ String shortBranchName = longBranchName.replaceAll( "^refs/heads/", "" );
+ String title = "Current Branch Name";
+ String message = "The current branch is:
" + shortBranchName;
+ SwingUtilities.invokeLater( () ->
+ JOptionPane.showMessageDialog( null, message, title, JOptionPane.PLAIN_MESSAGE ) );
+ } );
+ }
+
+ private void synchronize()
+ {
+ run( "Synchronize Changes", () -> {
+ boolean clean = repository.isClean();
+ if ( !clean )
+ {
+ String commitMessage = CommitMessageDialog.showDialog();
+ if ( commitMessage == null )
+ return;
+ repository.commitWithoutSave( commitMessage );
+ }
+ try
+ {
+ repository.pull();
+ }
+ catch ( GraphMergeException e )
+ {
+ if ( !( e instanceof GraphMergeConflictException ) )
+ e.printStackTrace();
+ SwingUtilities.invokeLater( () -> suggestPullAlternative( e.getMessage() ) );
+ return;
+ }
+ repository.push();
+ NotificationDialog.show( "Synchronize Changes (Commit, Pull, Push)",
+ "✓ Completed successfully." );
+ } );
+ }
+
+ private void run( String title, RunnableWithException action )
+ {
+ new Thread( () -> {
+ try
+ {
+ action.run();
+ }
+ catch ( CancellationException e )
+ {
+ // ignore
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( title, e );
+ }
+ } ).start();
+ }
+
+ interface RunnableWithException
+ {
+ void run() throws Exception;
+ }
+
+ @Plugin( type = CommandDescriptionProvider.class )
+ public static class DescriptionProvider extends BasicDescriptionProvider
+ {
+ public DescriptionProvider()
+ {
+ super( actionDescriptions, KeyConfigContexts.MASTODON, KeyConfigContexts.TRACKSCHEME );
+ }
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepository.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepository.java
new file mode 100644
index 00000000..4ecabba5
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepository.java
@@ -0,0 +1,440 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.eclipse.jgit.api.CheckoutCommand;
+import org.eclipse.jgit.api.CommitCommand;
+import org.eclipse.jgit.api.CreateBranchCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.ListBranchCommand;
+import org.eclipse.jgit.api.ResetCommand;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.BranchConfig;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.mastodon.graph.io.RawGraphIO;
+import org.mastodon.mamut.MainWindow;
+import org.mastodon.mamut.WindowManager;
+import org.mastodon.mamut.feature.MamutRawFeatureModelIO;
+import org.mastodon.mamut.model.Link;
+import org.mastodon.mamut.model.Model;
+import org.mastodon.mamut.model.Spot;
+import org.mastodon.mamut.project.MamutProject;
+import org.mastodon.mamut.project.MamutProjectIO;
+import org.mastodon.mamut.tomancak.collaboration.credentials.PersistentCredentials;
+import org.mastodon.mamut.tomancak.collaboration.exceptions.GraphMergeConflictException;
+import org.mastodon.mamut.tomancak.collaboration.exceptions.GraphMergeException;
+import org.mastodon.mamut.tomancak.collaboration.exceptions.MastodonGitException;
+import org.mastodon.mamut.tomancak.collaboration.utils.ConflictUtils;
+import org.mastodon.mamut.tomancak.collaboration.settings.MastodonGitSettingsService;
+import org.mastodon.mamut.tomancak.merging.Dataset;
+import org.mastodon.mamut.tomancak.merging.MergeDatasets;
+import org.scijava.Context;
+
+import mpicbg.spim.data.SpimDataException;
+
+// make it one synchronized class per repository
+// don't allow to open a repository twice (maybe read only)
+public class MastodonGitRepository
+{
+
+ private static final PersistentCredentials credentials = new PersistentCredentials();
+
+ private final WindowManager windowManager;
+
+ private final MastodonGitSettingsService settingsService;
+
+ public MastodonGitRepository( WindowManager windowManager )
+ {
+ this.windowManager = windowManager;
+ settingsService = windowManager.getContext().service( MastodonGitSettingsService.class );
+ }
+
+ public static MastodonGitRepository shareProject(
+ WindowManager windowManager,
+ File directory,
+ String repositoryURL )
+ throws Exception
+ {
+ if ( !directory.isDirectory() )
+ throw new IllegalArgumentException( "Not a directory: " + directory );
+ if ( !isEmpty( directory ) )
+ throw new IllegalArgumentException( "Directory not empty: " + directory );
+ Git git = Git.cloneRepository()
+ .setURI( repositoryURL )
+ .setCredentialsProvider( credentials.getSingleUseCredentialsProvider() )
+ .setDirectory( directory )
+ .call();
+ Path mastodonProjectPath = directory.toPath().resolve( "mastodon.project" );
+ if ( Files.exists( mastodonProjectPath ) )
+ throw new MastodonGitException( "The repository already contains a shared mastodon project: " + repositoryURL );
+ Files.createDirectory( mastodonProjectPath );
+ windowManager.getProjectManager().saveProject( mastodonProjectPath.toFile() );
+ Files.copy( mastodonProjectPath.resolve( "gui.xml" ), mastodonProjectPath.resolve( "gui.xml_remote" ) );
+ Files.copy( mastodonProjectPath.resolve( "project.xml" ), mastodonProjectPath.resolve( "project.xml_remote" ) );
+ Files.copy( mastodonProjectPath.resolve( "dataset.xml.backup" ), mastodonProjectPath.resolve( "dataset.xml.backup_remote" ) );
+ Path gitignore = directory.toPath().resolve( ".gitignore" );
+ Files.write( gitignore, "/mastodon.project/gui.xml\n".getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND );
+ Files.write( gitignore, "/mastodon.project/project.xml\n".getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND );
+ Files.write( gitignore, "/mastodon.project/dataset.xml.backup\n".getBytes(), StandardOpenOption.CREATE, StandardOpenOption.APPEND );
+ git.add().addFilepattern( ".gitignore" ).call();
+ git.commit().setMessage( "Add .gitignore file" ).call();
+ git.add().addFilepattern( "mastodon.project" ).call();
+ git.commit().setMessage( "Share mastodon project" ).call();
+ git.push().setCredentialsProvider( credentials.getSingleUseCredentialsProvider() ).setRemote( "origin" ).call();
+ git.close();
+ return new MastodonGitRepository( windowManager );
+ }
+
+ private static boolean isEmpty( File directory )
+ {
+ String[] containedFiles = directory.list();
+ return containedFiles == null || containedFiles.length == 0;
+ }
+
+ public static void cloneRepository( String repositoryURL, File directory ) throws Exception
+ {
+ try (Git git = Git.cloneRepository()
+ .setURI( repositoryURL )
+ .setCredentialsProvider( credentials.getSingleUseCredentialsProvider() )
+ .setDirectory( directory )
+ .call())
+ {
+ Path mastodonProjectPath = directory.toPath().resolve( "mastodon.project" );
+ Files.copy( mastodonProjectPath.resolve( "gui.xml_remote" ), mastodonProjectPath.resolve( "gui.xml" ) );
+ Files.copy( mastodonProjectPath.resolve( "project.xml_remote" ), mastodonProjectPath.resolve( "project.xml" ) );
+ Files.copy( mastodonProjectPath.resolve( "dataset.xml.backup_remote" ), mastodonProjectPath.resolve( "dataset.xml.backup" ) );
+ }
+ }
+
+ public static void openProjectInRepository( Context context, File directory ) throws Exception
+ {
+ WindowManager windowManager = new WindowManager( context );
+ Path path = directory.toPath().resolve( "mastodon.project" );
+ windowManager.getProjectManager().openWithDialog( new MamutProjectIO().load( path.toAbsolutePath().toString() ) );
+ new MainWindow( windowManager ).setVisible( true );
+ }
+
+ public synchronized void commit( String message ) throws Exception
+ {
+ windowManager.getProjectManager().saveProject();
+ commitWithoutSave( message );
+ }
+
+ public void commitWithoutSave( String message ) throws Exception
+ {
+ try (Git git = initGit())
+ {
+ git.add().addFilepattern( "mastodon.project" ).call();
+ CommitCommand commit = git.commit();
+ commit.setMessage( message );
+ commit.setAuthor( settingsService.getPersonIdent() );
+ commit.call();
+ }
+ }
+
+ public synchronized void push() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ Iterable< PushResult > results = git.push().setCredentialsProvider( credentials.getSingleUseCredentialsProvider() ).setRemote( "origin" ).call();
+ raiseExceptionOnUnsuccessfulPush( results );
+ }
+ }
+
+ private static void raiseExceptionOnUnsuccessfulPush( Iterable< PushResult > results )
+ {
+ for ( PushResult result : results )
+ {
+ for ( RemoteRefUpdate update : result.getRemoteUpdates() )
+ {
+ if ( update.getStatus() == RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD )
+ throw new MastodonGitException( "The remote server has changes, that you didn't download yet.\n"
+ + "Please download changes first. (pull)\n"
+ + "You can upload your changes afterwards.\n" );
+ if ( update.getStatus() != RemoteRefUpdate.Status.OK &&
+ update.getStatus() != RemoteRefUpdate.Status.UP_TO_DATE )
+ throw new MastodonGitException( "Push failed: " + update.getMessage() + " " + update.getStatus() );
+ }
+ }
+ }
+
+ public synchronized void createNewBranch( String branchName ) throws Exception
+ {
+ try (Git git = initGit())
+ {
+ git.checkout().setCreateBranch( true ).setName( branchName ).call();
+ }
+ }
+
+ public synchronized void switchBranch( String branchName ) throws Exception
+ {
+ File projectRoot = windowManager.getProjectManager().getProject().getProjectRoot();
+ try (Git git = initGit( projectRoot ))
+ {
+ ensureClean( git, "switching the branch" );
+ boolean isRemoteBranch = branchName.startsWith( "refs/remotes/" );
+ if ( isRemoteBranch )
+ {
+ String simpleName = getSimpleName( branchName );
+ boolean conflict = git.branchList().call().stream().map( Ref::getName ).anyMatch( localName -> simpleName.equals( getSimpleName( localName ) ) );
+ if ( conflict )
+ throw new MastodonGitException( "There's already a local branch with the same name." );
+ git.checkout()
+ .setCreateBranch( true )
+ .setName( simpleName )
+ .setUpstreamMode( CreateBranchCommand.SetupUpstreamMode.TRACK )
+ .setStartPoint( branchName )
+ .call();
+ }
+ else
+ git.checkout().setName( branchName ).call();
+ }
+ windowManager.getProjectManager().openWithDialog( new MamutProjectIO().load( projectRoot.getAbsolutePath() ) );
+ }
+
+ private synchronized String getSimpleName( String branchName )
+ {
+ String[] parts = branchName.split( "/" );
+ return parts[ parts.length - 1 ];
+ }
+
+ public synchronized List< String > getBranches() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ return git.branchList().setListMode( ListBranchCommand.ListMode.ALL ).call().stream().map( Ref::getName ).collect( Collectors.toList() );
+ }
+ }
+
+ public synchronized void fetchAll() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ git.fetch().setCredentialsProvider( credentials.getSingleUseCredentialsProvider() ).call();
+ }
+ }
+
+ public synchronized String getCurrentBranch() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ return git.getRepository().getFullBranch();
+ }
+ }
+
+ public synchronized void mergeBranch( String selectedBranch ) throws Exception
+ {
+ Context context = windowManager.getContext();
+ MamutProject project = windowManager.getProjectManager().getProject();
+ File projectRoot = project.getProjectRoot();
+ try (Git git = initGit())
+ {
+ ensureClean( git, "merging" );
+ String currentBranch = getCurrentBranch();
+ Dataset dsA = new Dataset( projectRoot.getAbsolutePath() );
+ git.checkout().setName( selectedBranch ).call();
+ Dataset dsB = new Dataset( projectRoot.getAbsolutePath() );
+ git.checkout().setName( currentBranch ).call();
+ git.merge().setCommit( false ).include( git.getRepository().exactRef( selectedBranch ) ).call(); // TODO selected branch, should not be a string but a ref instead
+ Model mergedModel = merge( dsA, dsB );
+ saveModel( context, mergedModel, project );
+ commitWithoutSave( "Merge commit generated with Mastodon" );
+ reloadFromDisc();
+ }
+ }
+
+ public synchronized void pull() throws Exception
+ {
+ Context context = windowManager.getContext();
+ MamutProject project = windowManager.getProjectManager().getProject();
+ File projectRoot = project.getProjectRoot();
+ try (Git git = initGit())
+ {
+ ensureClean( git, "pulling" );
+ try
+ {
+ boolean conflict = !git.pull()
+ .setCredentialsProvider( credentials.getSingleUseCredentialsProvider() )
+ .setRemote( "origin" )
+ .setRebase( false )
+ .call().isSuccessful();
+ if ( conflict )
+ automaticMerge( context, project, projectRoot, git );
+ }
+ finally
+ {
+ abortMerge( git );
+ }
+ reloadFromDisc();
+ }
+ }
+
+ private void automaticMerge( Context context, MamutProject project, File projectRoot, Git git ) throws Exception
+ {
+ try
+ {
+ git.checkout().setAllPaths( true ).setStage( CheckoutCommand.Stage.OURS ).call();
+ Dataset dsA = new Dataset( projectRoot.getAbsolutePath() );
+ git.checkout().setAllPaths( true ).setStage( CheckoutCommand.Stage.THEIRS ).call();
+ Dataset dsB = new Dataset( projectRoot.getAbsolutePath() );
+ git.checkout().setAllPaths( true ).setStage( CheckoutCommand.Stage.OURS ).call();
+ Model mergedModel = merge( dsA, dsB );
+ if ( ConflictUtils.hasConflict( mergedModel ) )
+ throw new GraphMergeConflictException();
+ ConflictUtils.removeMergeConflictTagSets( mergedModel );
+ saveModel( context, mergedModel, project );
+ commitWithoutSave( "Automatic merge by Mastodon during pull" );
+ }
+ catch ( GraphMergeException e )
+ {
+ throw e;
+ }
+ catch ( Throwable t )
+ {
+ throw new GraphMergeException( "There was a failure, when merging changes to the Model.", t );
+ }
+ }
+
+ private static void saveModel( Context context, Model model, MamutProject project ) throws IOException
+ {
+ project.setProjectRoot( project.getProjectRoot() );
+ try (final MamutProject.ProjectWriter writer = project.openForWriting())
+ {
+ new MamutProjectIO().save( project, writer );
+ final RawGraphIO.GraphToFileIdMap< Spot, Link > idmap = model.saveRaw( writer );
+ MamutRawFeatureModelIO.serialize( context, model, idmap, writer );
+ }
+ }
+
+ private static Model merge( Dataset dsA, Dataset dsB )
+ {
+ final MergeDatasets.OutputDataSet output = new MergeDatasets.OutputDataSet();
+ double distCutoff = 1000;
+ double mahalanobisDistCutoff = 1;
+ double ratioThreshold = 2;
+ MergeDatasets.merge( dsA, dsB, output, distCutoff, mahalanobisDistCutoff, ratioThreshold );
+ return output.getModel();
+ }
+
+ private synchronized void reloadFromDisc() throws IOException, SpimDataException
+ {
+ MamutProject project = windowManager.getProjectManager().getProject();
+ windowManager.getProjectManager().openWithDialog( project );
+ }
+
+ public synchronized void reset() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ git.reset().setMode( ResetCommand.ResetType.HARD ).call();
+ reloadFromDisc();
+ }
+ }
+
+ private synchronized Git initGit() throws IOException
+ {
+ File projectRoot = windowManager.getProjectManager().getProject().getProjectRoot();
+ return initGit( projectRoot );
+ }
+
+ private synchronized Git initGit( File projectRoot ) throws IOException
+ {
+ boolean correctFolder = projectRoot.getName().equals( "mastodon.project" );
+ if ( !correctFolder )
+ throw new MastodonGitException( "The current project does not appear to be in a git repo." );
+ File gitRoot = projectRoot.getParentFile();
+ if ( !new File( gitRoot, ".git" ).exists() )
+ throw new MastodonGitException( "The current project does not appear to be in a git repo." );
+ return Git.open( gitRoot );
+ }
+
+ private synchronized boolean isClean( Git git ) throws GitAPIException
+ {
+ return git.status().call().isClean();
+ }
+
+ public boolean isRepository()
+ {
+ try (Git ignored = initGit())
+ {
+ return true;
+ }
+ catch ( Exception e )
+ {
+ return false;
+ }
+ }
+
+ private static void abortMerge( Git git ) throws Exception
+ {
+ Repository repository = git.getRepository();
+ repository.writeMergeCommitMsg( null );
+ repository.writeMergeHeads( null );
+ git.reset().setMode( ResetCommand.ResetType.HARD ).call();
+ }
+
+ public void resetToRemoteBranch() throws Exception
+ {
+ try (Git git = initGit())
+ {
+ Repository repository = git.getRepository();
+ String remoteTrackingBranch = new BranchConfig( repository.getConfig(), repository.getBranch() ).getRemoteTrackingBranch();
+ git.reset().setMode( ResetCommand.ResetType.HARD ).setRef( remoteTrackingBranch ).call();
+ reloadFromDisc();
+ }
+ }
+
+ private void ensureClean( Git git, String title ) throws GitAPIException
+ {
+ windowManager.getProjectManager().saveProject();
+ boolean clean = isClean( git );
+ if ( !clean )
+ throw new MastodonGitException( "There are uncommitted changes. Please add a save point before " + title + "." );
+ }
+
+ public boolean isClean() throws Exception
+ {
+ windowManager.getProjectManager().saveProject();
+ try (Git git = initGit())
+ {
+ return isClean( git );
+ }
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialog.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialog.java
new file mode 100644
index 00000000..187a9a1d
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialog.java
@@ -0,0 +1,21 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+import javax.swing.JDialog;
+import javax.swing.JOptionPane;
+import javax.swing.Timer;
+
+/**
+ * A dialog that shows a message for a short time and then disappears.
+ */
+public class NotificationDialog
+{
+
+ public static void show( String title, String message )
+ {
+ JOptionPane pane = new JOptionPane( message, JOptionPane.PLAIN_MESSAGE );
+ JDialog dialog = pane.createDialog( null, title );
+ dialog.setModal( false );
+ dialog.setVisible( true );
+ new Timer( 1500, ignore -> dialog.dispose() ).start();
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/AbstractCancellable.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/AbstractCancellable.java
new file mode 100644
index 00000000..92af522a
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/AbstractCancellable.java
@@ -0,0 +1,30 @@
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import org.scijava.Cancelable;
+
+public class AbstractCancellable implements Cancelable
+{
+
+ private boolean canceled = false;
+
+ private String reason = null;
+
+ @Override
+ public boolean isCanceled()
+ {
+ return canceled;
+ }
+
+ @Override
+ public void cancel( String reason )
+ {
+ this.canceled = true;
+ this.reason = reason;
+ }
+
+ @Override
+ public String getCancelReason()
+ {
+ return reason;
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCloneRepository.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCloneRepository.java
new file mode 100644
index 00000000..ad58da66
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCloneRepository.java
@@ -0,0 +1,67 @@
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import java.io.File;
+
+import org.mastodon.mamut.tomancak.collaboration.ErrorDialog;
+import org.mastodon.mamut.tomancak.collaboration.MastodonGitRepository;
+import org.scijava.Context;
+import org.scijava.command.Command;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+
+// TODOs:
+// - warn if parentDirectory already exists
+// - warn if repositoryName already exists and the corresponding directory is not empty
+// - fill repositoryName with a default value based on the repositoryURL
+@Plugin( type = Command.class,
+ label = "Mastodon Git - Download Shared Project (clone)",
+ menuPath = "Plugins > Mastodon Collaborative (Git) > Download Shared Project" )
+public class MastodonGitCloneRepository extends AbstractCancellable implements Command
+{
+ @Parameter
+ private Context context;
+
+ @Parameter( label = "URL on github or gitlab", description = URL_DESCRIPTION )
+ private String repositoryURL;
+
+ private static final String URL_DESCRIPTION = ""
+ + "Here are two examples of valid URLs:
"
+ + ""
+ + "- https://github.com/username/repositoryname.git
"
+ + "- git@github.com:username/repositoryname.git (if you use SSH to authenticate)
"
+ + "
"
+ + "";
+
+ @Parameter( label = "Directory, to store the project:", style = "directory", description = DIRECTORY_DESCRIPTION )
+ private File directory;
+
+ private static final String DIRECTORY_DESCRIPTION = ""
+ + "A copy of the shared project will be downloaded to your computer.
"
+ + "Please select a directory where to store it.
"
+ + "The directory should be empty, or select \"Create new subdirectory\"."
+ + "";
+
+ @Parameter( label = "Create new subdirectory", required = false, description = CREATE_SUBDIRECTORY_DESCRIPTION )
+ private boolean createSubdirectory = false;
+
+ private static final String CREATE_SUBDIRECTORY_DESCRIPTION = ""
+ + "If selected, a new subdirectory will be created in the selected directory.
"
+ + "The name of the subdirectory will be the name of the repository."
+ + "";
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ directory = NewDirectoryUtils.createRepositoryDirectory( createSubdirectory, directory, repositoryURL );
+ MastodonGitRepository.cloneRepository( repositoryURL, directory );
+ MastodonGitRepository.openProjectInRepository( context, directory );
+ }
+ catch ( final Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Download Shared Project (Clone)", e );
+ }
+ }
+
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCreateRepository.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCreateRepository.java
new file mode 100644
index 00000000..c8014269
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitCreateRepository.java
@@ -0,0 +1,82 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import java.io.File;
+
+import org.mastodon.mamut.tomancak.collaboration.ErrorDialog;
+import org.scijava.ItemVisibility;
+import org.scijava.command.Command;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+
+@Plugin( type = Command.class,
+ label = "Share Current Project via GitHub or GitLab",
+ visible = false )
+public class MastodonGitCreateRepository extends AbstractCancellable implements Command
+{
+ @Parameter( visibility = ItemVisibility.MESSAGE )
+ private final String text = ""
+ + "Share Project
"
+ + "Share the current project on github or gitlab.
"
+ + "Go to github.com or to ure institute's gitlab and create a new repository.
"
+ + "Then copy the URL of the repository and paste it below.
"
+ + "A copy of will be created in the directory you specify, and then uploaded to the specified URL.
";
+
+ @Parameter
+ private Callback callback;
+
+ @Parameter( label = "URL on github or gitlab" )
+ private String repositoryURL;
+
+ @Parameter( label = "Directory to contain the repository", style = "directory" )
+ private File directory;
+
+ @Parameter( label = "Create new subdirectory", required = false )
+ private boolean createSubdirectory = false;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ directory = NewDirectoryUtils.createRepositoryDirectory( createSubdirectory, directory, repositoryURL );
+ callback.run( directory, repositoryURL );
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Share Project", e );
+ }
+ }
+
+ public interface Callback
+ {
+ void run( File directory, String repositoryURL ) throws Exception;
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitNewBranch.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitNewBranch.java
new file mode 100644
index 00000000..c1a9d0aa
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitNewBranch.java
@@ -0,0 +1,31 @@
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import org.mastodon.mamut.tomancak.collaboration.ErrorDialog;
+import org.mastodon.mamut.tomancak.collaboration.MastodonGitRepository;
+import org.scijava.command.Command;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+
+@Plugin( type = Command.class, label = "Create New Branch", visible = false )
+public class MastodonGitNewBranch extends AbstractCancellable implements Command
+{
+
+ @Parameter
+ private MastodonGitRepository repository;
+
+ @Parameter( label = "Branch name", persist = false )
+ private String branchName;
+
+ @Override
+ public void run()
+ {
+ try
+ {
+ repository.createNewBranch( branchName );
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Create New Branch", e );
+ }
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitSetAuthorCommand.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitSetAuthorCommand.java
new file mode 100644
index 00000000..de3e178c
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/MastodonGitSetAuthorCommand.java
@@ -0,0 +1,43 @@
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import org.mastodon.mamut.tomancak.collaboration.settings.MastodonGitSettingsService;
+import org.scijava.Initializable;
+import org.scijava.ItemVisibility;
+import org.scijava.command.Command;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+
+@Plugin( type = Command.class, label = "Set Author Name", visible = false )
+public class MastodonGitSetAuthorCommand extends AbstractCancellable implements Command, Initializable
+{
+ @Parameter
+ private MastodonGitSettingsService settings;
+
+ @Parameter( visibility = ItemVisibility.MESSAGE )
+ private final String description = ""
+ + "The name and email that you specify below
"
+ + "are used to identify you as the author of the
"
+ + "changes you make to the shared project.
"
+ + "Name and email are likely to become publicly visible on the internet.
"
+ + "You may use a nickname and dummy email address if you wish.";
+
+ @Parameter( label = "Author name", persist = false )
+ private String authorName;
+
+ @Parameter( label = "Author email", persist = false )
+ private String authorEmail = "noreply@example.com";
+
+ @Override
+ public void initialize()
+ {
+ authorName = settings.getAuthorName();
+ authorEmail = settings.getAuthorEmail();
+ }
+
+ @Override
+ public void run()
+ {
+ settings.setAuthorName( authorName );
+ settings.setAuthorEmail( authorEmail );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtils.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtils.java
new file mode 100644
index 00000000..4489c4af
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtils.java
@@ -0,0 +1,86 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class NewDirectoryUtils
+{
+
+ /**
+ * If {@code newSubdirectory} is true, create a new subdirectory in the
+ * given {@code directory}. The name of the subdirectory is extracted from
+ * the {@code repositoryURL}.
+ *
+ * If {@code newSubdirectory} is false, return the {@code directory} as it is.
+ */
+ static File createRepositoryDirectory( boolean newSubdirectory, File directory, String repositoryURL ) throws IOException
+ {
+ if ( !newSubdirectory )
+ return directory;
+
+ return createSubdirectory( directory, extractRepositoryName( repositoryURL ) );
+ }
+
+ static File createSubdirectory( File parentDirectory, String repositoryName ) throws IOException
+ {
+ File directory = new File( parentDirectory, repositoryName );
+ if ( directory.isDirectory() )
+ {
+ if ( isEmptyDirectory( directory ) )
+ return directory;
+ else
+ throw new IOException( "Directory already exists but is not empty: " + directory
+ + "\nPlease move or delete the directory and try again." );
+ }
+ Files.createDirectory( directory.toPath() );
+ return directory;
+ }
+
+ private static boolean isEmptyDirectory( File directory )
+ {
+ String[] list = directory.list();
+ if ( list == null ) // not a directory
+ return false;
+ return list.length == 0;
+ }
+
+ static String extractRepositoryName( String repositoryURL )
+ {
+ Pattern pattern = Pattern.compile( "/([\\w-]+)(\\.git|/)?$" );
+ Matcher matcher = pattern.matcher( repositoryURL );
+ if ( matcher.find() )
+ return matcher.group( 1 );
+ throw new IllegalArgumentException( "Could not extract repository name from URL:" + repositoryURL );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/PersistentCredentials.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/PersistentCredentials.java
new file mode 100644
index 00000000..33e14096
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/PersistentCredentials.java
@@ -0,0 +1,81 @@
+package org.mastodon.mamut.tomancak.collaboration.credentials;
+
+import java.util.concurrent.CancellationException;
+
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JPasswordField;
+import javax.swing.JTextField;
+
+import net.miginfocom.swing.MigLayout;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * This class is meant to be used with JGIT for a comfortable way to ask the
+ * user for credentials. The user is only asked once for username and password.
+ * The credentials are stored in memory and reused for all subsequent requests.
+ * It also tries to detect if the user entered wrong credentials,
+ * and asks for new credentials.
+ *
+ * WARNING: JGIT expects a {@link CredentialsProvider} to return false if the
+ * user cancels for example a username/password dialog. (see {@link CredentialsProvider#get})
+ * This class behaves differently: It throws a {@link CancellationException}
+ * instead. This difference is on purpose. The CancellationException
+ * better describes the situation and is easier to handle, than the
+ * {@link org.eclipse.jgit.errors.TransportException} that JGIT throws.
+ */
+public class PersistentCredentials
+{
+
+ private String username = null;
+
+ private String password = null;
+
+ /**
+ * This method simply returns the username and password if they are already
+ * known. It asks the user for credentials if they are not known yet, or if
+ * the previous attempt to use the credentials failed.
+ */
+ private synchronized Pair< String, String > getUsernameAndPassword( URIish uri, boolean authenticationFailure )
+ {
+ boolean missingCredentials = password == null || username == null;
+ if ( missingCredentials || authenticationFailure )
+ queryPassword( uri.toString(), authenticationFailure );
+ return Pair.of( username, password );
+ }
+
+ private void queryPassword( String url, boolean previousAuthenticationFailed )
+ {
+ JTextField usernameField = new JTextField( 20 );
+ JPasswordField passwordField = new JPasswordField( 20 );
+
+ final JPanel panel = new JPanel();
+ panel.setLayout( new MigLayout( "insets dialog" ) );
+ panel.add( new JLabel( "Please enter your credentials for the Git repository:" ), "span, wrap" );
+ panel.add( new JLabel( url ), "span, wrap, gapbottom unrelated" );
+ panel.add( new JLabel( "username" ) );
+ panel.add( usernameField, "wrap" );
+ panel.add( new JLabel( "password" ) );
+ panel.add( passwordField, "wrap" );
+ if ( previousAuthenticationFailed )
+ panel.add( new JLabel( "(Authentication failed. Please try again!)" ), "span, wrap" );
+ boolean ok = JOptionPane.OK_OPTION == JOptionPane.showConfirmDialog( null,
+ panel, "Authentication for Git Repository",
+ JOptionPane.OK_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE );
+ if ( !ok )
+ throw new CancellationException( "User cancelled username & password dialog." );
+
+ username = usernameField.getText();
+ password = new String( passwordField.getPassword() );
+ }
+
+ public CredentialsProvider getSingleUseCredentialsProvider()
+ {
+ return new SingleUseCredentialsProvider( this::getUsernameAndPassword );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/SingleUseCredentialsProvider.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/SingleUseCredentialsProvider.java
new file mode 100644
index 00000000..754a9ae5
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/credentials/SingleUseCredentialsProvider.java
@@ -0,0 +1,97 @@
+package org.mastodon.mamut.tomancak.collaboration.credentials;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.eclipse.jgit.errors.UnsupportedCredentialItem;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * The JGIT api does not tell a CredentialsProvider if the credentials
+ * where correct. It simply asks for them again if they were wrong.
+ *
+ * We can exploit this behavior by counting the number of times the
+ * CredentialsProvider was asked for credentials. If it was asked more than
+ * once, we assume that the credentials were wrong.
+ *
+ * This only works if the CredentialsProvider is only used once.
+ */
+class SingleUseCredentialsProvider extends CredentialsProvider
+{
+ private final Callback callback;
+
+ private int counter = 0;
+
+ public SingleUseCredentialsProvider( Callback callback )
+ {
+ this.callback = callback;
+ }
+
+ @Override
+ public boolean isInteractive()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean supports( CredentialItem... items )
+ {
+ for ( CredentialItem item : items )
+ if ( !isUsernameOrPassword( item ) )
+ return false;
+ return true;
+ }
+
+ private boolean isUsernameOrPassword( CredentialItem item )
+ {
+ return ( item instanceof CredentialItem.Username ) || ( item instanceof CredentialItem.Password );
+ }
+
+ @Override
+ public boolean get( URIish uri, CredentialItem... items ) throws UnsupportedCredentialItem
+ {
+ if ( !supports( items ) )
+ throw new UnsupportedCredentialItem( uri, "" );
+ counter++;
+ boolean previousAuthenticationFailed = counter > 1;
+ Pair< String, String > usernameAndPassword = callback.getUsernameAndPassword( uri, previousAuthenticationFailed );
+ if ( usernameAndPassword == null )
+ return false;
+ fillUsernameAndPassword( items, usernameAndPassword.getLeft(), usernameAndPassword.getRight() );
+ return true;
+ }
+
+ private void fillUsernameAndPassword( CredentialItem[] items, String username, String password )
+ {
+ for ( CredentialItem item : items )
+ fillItem( item, username, password );
+ }
+
+ private void fillItem( CredentialItem item, String username, String password )
+ {
+ if ( item instanceof CredentialItem.Username )
+ ( ( CredentialItem.Username ) item ).setValue( username );
+ else if ( item instanceof CredentialItem.Password )
+ ( ( CredentialItem.Password ) item ).setValue( password.toCharArray() );
+ }
+
+ /**
+ * Callback interface for {@link SingleUseCredentialsProvider}.
+ */
+ interface Callback
+ {
+
+ /**
+ * The {@link SingleUseCredentialsProvider} calls this method to get
+ * username and password for the given {@link URIish}.
+ *
+ * @param uri the URI for which credentials are requested.
+ * @param authenticationFailure true if the SingleUseCredentialsProvider
+ * thinks that the credentials were wrong.
+ * @return username and password or null if the user canceled the
+ * request.
+ * @see SingleUseCredentialsProvider
+ */
+ Pair< String, String > getUsernameAndPassword( URIish uri, boolean authenticationFailure );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeConflictException.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeConflictException.java
new file mode 100644
index 00000000..efd81f50
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeConflictException.java
@@ -0,0 +1,9 @@
+package org.mastodon.mamut.tomancak.collaboration.exceptions;
+
+public class GraphMergeConflictException extends GraphMergeException
+{
+ public GraphMergeConflictException()
+ {
+ super( "There are conflicting changes in the two versions of the ModelGraph." );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeException.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeException.java
new file mode 100644
index 00000000..524c1319
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/GraphMergeException.java
@@ -0,0 +1,15 @@
+package org.mastodon.mamut.tomancak.collaboration.exceptions;
+
+public class GraphMergeException extends RuntimeException
+{
+
+ public GraphMergeException( final String message )
+ {
+ super( message );
+ }
+
+ public GraphMergeException( String message, final Throwable cause )
+ {
+ super( message, cause );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/MastodonGitException.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/MastodonGitException.java
new file mode 100644
index 00000000..6cc64617
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/exceptions/MastodonGitException.java
@@ -0,0 +1,20 @@
+package org.mastodon.mamut.tomancak.collaboration.exceptions;
+
+/**
+ * Exception class that is used in
+ * {@link org.mastodon.mamut.tomancak.collaboration.MastodonGitRepository}.
+ * The exception messages should be user-friendly. Such that they can be
+ * printed to the user.
+ */
+public class MastodonGitException extends RuntimeException
+{
+ public MastodonGitException( final String message )
+ {
+ super( message );
+ }
+
+ public MastodonGitException( final String message, final Throwable cause )
+ {
+ super( message, cause );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/DefaultMastodonGitSettingsService.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/DefaultMastodonGitSettingsService.java
new file mode 100644
index 00000000..777f46ae
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/DefaultMastodonGitSettingsService.java
@@ -0,0 +1,66 @@
+package org.mastodon.mamut.tomancak.collaboration.settings;
+
+import org.eclipse.jgit.lib.PersonIdent;
+import org.scijava.plugin.Parameter;
+import org.scijava.plugin.Plugin;
+import org.scijava.prefs.PrefService;
+import org.scijava.service.AbstractService;
+import org.scijava.service.Service;
+
+@Plugin( type = Service.class )
+public class DefaultMastodonGitSettingsService extends AbstractService implements MastodonGitSettingsService
+{
+
+ @Parameter
+ private PrefService prefService;
+
+ private String authorName;
+
+ private String authorEmail;
+
+ @Override
+ public void initialize()
+ {
+ super.initialize();
+ authorName = prefService.get( DefaultMastodonGitSettingsService.class, "author.name", null );
+ authorEmail = prefService.get( DefaultMastodonGitSettingsService.class, "author.email", null );
+ }
+
+ @Override
+ public boolean isAuthorSpecified()
+ {
+ return authorName != null && authorEmail != null;
+ }
+
+ @Override
+ public void setAuthorName( String name )
+ {
+ this.authorName = name;
+ prefService.put( DefaultMastodonGitSettingsService.class, "author.name", name );
+ }
+
+ @Override
+ public void setAuthorEmail( String email )
+ {
+ this.authorEmail = email;
+ prefService.put( DefaultMastodonGitSettingsService.class, "author.email", email );
+ }
+
+ @Override
+ public String getAuthorName()
+ {
+ return authorName;
+ }
+
+ @Override
+ public String getAuthorEmail()
+ {
+ return authorEmail;
+ }
+
+ @Override
+ public PersonIdent getPersonIdent()
+ {
+ return new PersonIdent( authorName, authorEmail );
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/MastodonGitSettingsService.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/MastodonGitSettingsService.java
new file mode 100644
index 00000000..f77a7b99
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/settings/MastodonGitSettingsService.java
@@ -0,0 +1,21 @@
+package org.mastodon.mamut.tomancak.collaboration.settings;
+
+import net.imagej.ImageJService;
+
+import org.eclipse.jgit.lib.PersonIdent;
+import org.scijava.service.Service;
+
+public interface MastodonGitSettingsService extends ImageJService
+{
+ boolean isAuthorSpecified();
+
+ void setAuthorName( String name );
+
+ void setAuthorEmail( String email );
+
+ String getAuthorName();
+
+ String getAuthorEmail();
+
+ PersonIdent getPersonIdent();
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ActionDescriptions.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ActionDescriptions.java
new file mode 100644
index 00000000..9f46652c
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ActionDescriptions.java
@@ -0,0 +1,94 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.utils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * This class contains a list of details about actions that a plugin can
+ * perform. It should be used together with {@link BasicMamutPlugin} and
+ * {@link BasicDescriptionProvider}.
+ */
+public class ActionDescriptions< T >
+{
+
+ private final Class< T > pluginClass;
+
+ private final List< Entry< ? > > entries = new ArrayList<>();
+
+ public ActionDescriptions( Class< T > pluginClass )
+ {
+ this.pluginClass = pluginClass;
+ }
+
+ public final String addActionDescription( String key, String menuText, String description, Consumer< T > action )
+ {
+ return addActionDescription( key, menuText, description, action, "not mapped" );
+ }
+
+ public final String addActionDescription( String key, String menuText, String description, Consumer< T > action, String... keyStrokes )
+ {
+ entries.add( new Entry<>( key, menuText, keyStrokes, description, action ) );
+ return key;
+ }
+
+ public Class< T > getPluginClass()
+ {
+ return pluginClass;
+ }
+
+ public List< Entry< ? > > getEntries()
+ {
+ return entries;
+ }
+
+ public static class Entry< T >
+ {
+ public final String key;
+
+ public final String menuEntry;
+
+ public final String[] shortCuts;
+
+ public final String description;
+
+ public final Consumer< T > action;
+
+ public Entry( String key, String menuEntry, String[] shortCuts, String description, Consumer< T > action )
+ {
+ this.key = key;
+ this.menuEntry = menuEntry;
+ this.shortCuts = shortCuts;
+ this.description = description;
+ this.action = action;
+ }
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicDescriptionProvider.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicDescriptionProvider.java
new file mode 100644
index 00000000..325aea79
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicDescriptionProvider.java
@@ -0,0 +1,57 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.utils;
+
+import org.mastodon.ui.keymap.CommandDescriptionProvider;
+import org.mastodon.ui.keymap.CommandDescriptions;
+
+/**
+ * This class simplifies the task of implementing a {@link CommandDescriptionProvider}.
+ *
+ * It provides the description of the actions listed in the {@link ActionDescriptions} object.
+ */
+public abstract class BasicDescriptionProvider extends CommandDescriptionProvider
+{
+
+ private final ActionDescriptions< ? > actionDescriptions;
+
+ protected BasicDescriptionProvider( ActionDescriptions< ? > actionDescriptions, String... contexts )
+ {
+ super( contexts );
+ this.actionDescriptions = actionDescriptions;
+ }
+
+ @Override
+ public void getCommandDescriptions( CommandDescriptions descriptions )
+ {
+ for ( ActionDescriptions.Entry< ? > entry : actionDescriptions.getEntries() )
+ descriptions.add( entry.key, entry.shortCuts, entry.description );
+ }
+
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicMamutPlugin.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicMamutPlugin.java
new file mode 100644
index 00000000..749215cf
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/BasicMamutPlugin.java
@@ -0,0 +1,141 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.utils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import net.imglib2.util.Cast;
+
+import org.mastodon.app.ui.ViewMenuBuilder;
+import org.mastodon.mamut.MamutAppModel;
+import org.mastodon.mamut.WindowManager;
+import org.mastodon.mamut.plugin.MamutPlugin;
+import org.mastodon.mamut.plugin.MamutPluginAppModel;
+import org.mastodon.mamut.tomancak.collaboration.MastodonGitController;
+import org.scijava.ui.behaviour.util.AbstractNamedAction;
+import org.scijava.ui.behaviour.util.Actions;
+import org.scijava.ui.behaviour.util.RunnableAction;
+
+/**
+ * A class that simplifies the creation of a {@link MamutPlugin}.
+ * See {@link MastodonGitController} for
+ * usage example.
+ */
+public abstract class BasicMamutPlugin implements MamutPlugin
+{
+
+ private final List< ActionDescriptions.Entry< ? > > actionDescriptions;
+
+ private final Map< String, String > menuTexts = new HashMap<>();
+
+ private final Map< String, AbstractNamedAction > actions = new HashMap<>();
+
+ private final List< ViewMenuBuilder.MenuItem > menuItems = new ArrayList<>();
+
+ private MamutAppModel appModel;
+
+ private WindowManager windowManager;
+
+ protected < T > BasicMamutPlugin( ActionDescriptions< T > description )
+ {
+ if ( !this.getClass().equals( description.getPluginClass() ) )
+ throw new IllegalArgumentException( "Plugin class mismatch." );
+ actionDescriptions = description.getEntries();
+ for ( ActionDescriptions.Entry< ? > entry : actionDescriptions )
+ {
+ menuTexts.put( entry.key, extractMenuText( entry.menuEntry ) );
+ menuItems.add( initMenuItem( entry.key, entry.menuEntry ) );
+ actions.put( entry.key, new RunnableAction( entry.key, () -> entry.action.accept( Cast.unchecked( this ) ) ) );
+ }
+ }
+
+ public void setActionEnabled( String key, boolean enabled )
+ {
+ actions.get( key ).setEnabled( enabled );
+ }
+
+ private String extractMenuText( String menuEntry )
+ {
+ // From menuEntry, extract the last part, which is the menu item name.
+ final String[] parts = menuEntry.split( ">" );
+ return parts[ parts.length - 1 ].trim();
+ }
+
+ private ViewMenuBuilder.MenuItem initMenuItem( String key, String menuEntry )
+ {
+ final String[] parts = menuEntry.split( ">" );
+ ViewMenuBuilder.MenuItem item = ViewMenuBuilder.item( key );
+ for ( int i = parts.length - 2; i >= 0; i-- )
+ item = ViewMenuBuilder.menu( parts[ i ].trim(), item );
+ return item;
+ }
+
+ @Override
+ public void setAppPluginModel( MamutPluginAppModel appPluginModel )
+ {
+ appModel = appPluginModel.getAppModel();
+ windowManager = appPluginModel.getWindowManager();
+ actions.forEach( ( key, action ) -> action.setEnabled( appModel != null ) );
+ initialize();
+ }
+
+ protected void initialize() {}
+
+ @Override
+ public Map< String, String > getMenuTexts()
+ {
+ return menuTexts;
+ }
+
+ @Override
+ public List< ViewMenuBuilder.MenuItem > getMenuItems()
+ {
+ return menuItems;
+ }
+
+ @Override
+ public void installGlobalActions( Actions pluginActions )
+ {
+ for ( ActionDescriptions.Entry< ? > entry : actionDescriptions )
+ pluginActions.namedAction( actions.get( entry.key ), entry.shortCuts );
+ }
+
+ protected MamutAppModel getAppModel()
+ {
+ return appModel;
+ }
+
+ protected WindowManager getWindowManager()
+ {
+ return windowManager;
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ConflictUtils.java b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ConflictUtils.java
new file mode 100644
index 00000000..e2fe1492
--- /dev/null
+++ b/src/main/java/org/mastodon/mamut/tomancak/collaboration/utils/ConflictUtils.java
@@ -0,0 +1,64 @@
+package org.mastodon.mamut.tomancak.collaboration.utils;
+
+import java.util.List;
+
+import org.mastodon.mamut.model.Link;
+import org.mastodon.mamut.model.Model;
+import org.mastodon.mamut.model.Spot;
+import org.mastodon.model.tag.TagSetModel;
+import org.mastodon.model.tag.TagSetStructure;
+
+public class ConflictUtils
+{
+ private ConflictUtils()
+ {
+ // prevent from instantiation
+ }
+
+ public static boolean hasConflict( Model model )
+ {
+ TagSetModel< Spot, Link > tagSetModel = model.getTagSetModel();
+ return !isTagSetEmpty( tagSetModel, "Merge Conflict", "Conflict" ) ||
+ !isTagSetEmpty( tagSetModel, "Merge Conflict (Tags)", "Tag Conflict" ) ||
+ !isTagSetEmpty( tagSetModel, "Merge Conflict (Labels)", "Label Conflict" );
+ }
+
+ public static void removeMergeConflictTagSets( Model model )
+ {
+ TagSetModel< Spot, Link > tagSetModel = model.getTagSetModel();
+ TagSetStructure original = tagSetModel.getTagSetStructure();
+ TagSetStructure replacement = new TagSetStructure();
+ replacement.set( original );
+ for ( TagSetStructure.TagSet tagSet : original.getTagSets() )
+ if ( isConflictTagSetName( tagSet.getName() ) )
+ replacement.remove( tagSet );
+ tagSetModel.setTagSetStructure( replacement );
+ }
+
+ private static boolean isConflictTagSetName( String name )
+ {
+ return name.equals( "Merge Conflict" ) ||
+ name.equals( "Merge Conflict (Tags)" ) ||
+ name.equals( "Merge Conflict (Labels)" ) ||
+ name.equals( "Merge Source A" ) ||
+ name.equals( "Merge Source B" ) ||
+ name.startsWith( "((A)) " ) ||
+ name.startsWith( "((B)) " );
+ }
+
+ /**
+ * Returns true if the given tag set is empty or if it does not exist.
+ */
+ private static boolean isTagSetEmpty( TagSetModel< Spot, Link > tagSetModel, String tagSetName, String tagLabel )
+ {
+ TagSetStructure tagSetStructure = tagSetModel.getTagSetStructure();
+ List< TagSetStructure.TagSet > tagSets = tagSetStructure.getTagSets();
+ TagSetStructure.TagSet tagSet = tagSets.stream().filter( ts -> tagSetName.equals( ts.getName() ) ).findFirst().orElse( null );
+ if ( tagSet == null )
+ return true;
+ TagSetStructure.Tag tag = tagSet.getTags().stream().filter( t -> tagLabel.equals( t.label() ) ).findFirst().orElse( null );
+ if ( tag == null )
+ return true;
+ return tagSetModel.getVertexTags().getTaggedWith( tag ).isEmpty() && tagSetModel.getEdgeTags().getTaggedWith( tag ).isEmpty();
+ }
+}
diff --git a/src/main/java/org/mastodon/mamut/tomancak/merging/Dataset.java b/src/main/java/org/mastodon/mamut/tomancak/merging/Dataset.java
index 0155e8b0..cc08792e 100644
--- a/src/main/java/org/mastodon/mamut/tomancak/merging/Dataset.java
+++ b/src/main/java/org/mastodon/mamut/tomancak/merging/Dataset.java
@@ -28,9 +28,6 @@
*/
package org.mastodon.mamut.tomancak.merging;
-import static org.mastodon.mamut.tomancak.merging.MergingUtil.getMaxNonEmptyTimepoint;
-import static org.mastodon.mamut.tomancak.merging.MergingUtil.getNumTimepoints;
-
import java.io.IOException;
import org.mastodon.mamut.model.Model;
@@ -47,8 +44,6 @@ public class Dataset
{
private final MamutProject project;
- private final int numTimepoints;
-
private final Model model;
private final int maxNonEmptyTimepoint;
@@ -56,17 +51,23 @@ public class Dataset
public Dataset( final String path ) throws IOException
{
project = new MamutProjectIO().load( path );
- numTimepoints = getNumTimepoints( project );
model = new Model();
try (final MamutProject.ProjectReader reader = project.openForReading())
{
model.loadRaw( reader );
}
- maxNonEmptyTimepoint = getMaxNonEmptyTimepoint( model, numTimepoints );
-
+ maxNonEmptyTimepoint = maxTimepoint( model );
verify();
}
+ private int maxTimepoint( Model model )
+ {
+ int max = 0;
+ for ( Spot spot : model.getGraph().vertices() )
+ max = Math.max( max, spot.getTimepoint() );
+ return max;
+ }
+
public Model model()
{
return model;
diff --git a/src/main/java/org/mastodon/mamut/tomancak/merging/MergingUtil.java b/src/main/java/org/mastodon/mamut/tomancak/merging/MergingUtil.java
index 9025434a..0653896b 100644
--- a/src/main/java/org/mastodon/mamut/tomancak/merging/MergingUtil.java
+++ b/src/main/java/org/mastodon/mamut/tomancak/merging/MergingUtil.java
@@ -28,18 +28,9 @@
*/
package org.mastodon.mamut.tomancak.merging;
-import org.mastodon.mamut.model.Model;
import org.mastodon.mamut.model.Spot;
import org.mastodon.mamut.model.SpotPool;
-import org.mastodon.mamut.project.MamutProject;
import org.mastodon.properties.ObjPropertyMap;
-import org.mastodon.spatial.SpatialIndex;
-import org.mastodon.spatial.SpatioTemporalIndex;
-import org.mastodon.util.DummySpimData;
-
-import bdv.spimdata.SpimDataMinimal;
-import bdv.spimdata.XmlIoSpimDataMinimal;
-import mpicbg.spim.data.SpimDataException;
public class MergingUtil
{
@@ -58,60 +49,4 @@ public static boolean hasLabel( final Spot spot )
final ObjPropertyMap< Spot, String > labels = ( ObjPropertyMap< Spot, String > ) pool.labelProperty();
return labels.isSet( spot );
}
-
- /**
- * Returns number of time-points in {@code project}. To to that, loads
- * {@code spimdata} for {@code project}.
- *
- * @param project
- * the project.
- * @return the number of time-points in the project.
- */
- public static int getNumTimepoints( final MamutProject project )
- {
- try
- {
- final String spimDataXmlFilename = project.getDatasetXmlFile().getAbsolutePath();
- SpimDataMinimal spimData = DummySpimData.tryCreate( project.getDatasetXmlFile().getName() );
- if ( spimData == null )
- spimData = new XmlIoSpimDataMinimal().load( spimDataXmlFilename );
- return spimData.getSequenceDescription().getTimePoints().size();
- }
- catch ( final SpimDataException e )
- {
- throw new RuntimeException( e );
- }
- }
-
- // Helper: max timepoint that has at least one spot
-
- /**
- * Returns the largest timepoint (index) where model has a least one spot.
- *
- * @param model
- * the model.
- * @param numTimepoints
- * the number of time-points in the model.
- * @return the largest timepoint (index) where model has a least one spot.
- */
- public static int getMaxNonEmptyTimepoint( final Model model, final int numTimepoints )
- {
- int maxNonEmptyTimepoint = 0;
- final SpatioTemporalIndex< Spot > spatioTemporalIndex = model.getSpatioTemporalIndex();
- spatioTemporalIndex.readLock().lock();
- try
- {
- for ( int t = 0; t < numTimepoints; ++t )
- {
- final SpatialIndex< Spot > index = spatioTemporalIndex.getSpatialIndex( t );
- if ( index.size() > 0 )
- maxNonEmptyTimepoint = t;
- }
- }
- finally
- {
- spatioTemporalIndex.readLock().unlock();
- }
- return maxNonEmptyTimepoint;
- }
}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitGraphExample.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitGraphExample.java
new file mode 100644
index 00000000..12e09935
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitGraphExample.java
@@ -0,0 +1,68 @@
+/*-
+ * #%L
+ * mastodon-tomancak
+ * %%
+ * Copyright (C) 2018 - 2022 Tobias Pietzsch
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.awt.BorderLayout;
+import java.io.File;
+import java.io.IOException;
+
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JScrollPane;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.awtui.CommitGraphPane;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revplot.PlotWalk;
+
+// Inspired by https://git.eclipse.org/r/plugins/gitiles/jgit/jgit/+/84022ac9de54ded6f04ca196264a8f2370e9da9a/org.eclipse.jgit.pgm/src/org/eclipse/jgit/pgm/Glog.java
+public class CommitGraphExample
+{
+ public static void main( String... args ) throws IOException, GitAPIException
+ {
+ Git git = Git.open( new File( "/home/arzt/tmp/2/mgit-test" ) );
+ Repository repository = git.getRepository();
+ PlotWalk plotWalk = new PlotWalk( repository );
+ plotWalk.markStart( plotWalk.parseCommit( repository.resolve( "HEAD" ) ) );
+ JFrame frame = new JFrame( "Commit Graph!" );
+ CommitGraphPane comp = new CommitGraphPane();
+ comp.getCommitList().source( plotWalk );
+ comp.getCommitList().fillTo( Integer.MAX_VALUE );
+ JLabel label = new JLabel();
+ comp.getSelectionModel().addListSelectionListener( e -> {
+ int rowIndex = comp.getSelectedRow();
+ label.setText( comp.getCommitList().get( rowIndex ).getId().toString() );
+ } );
+ frame.add( new JScrollPane( comp ) );
+ frame.add( label, BorderLayout.PAGE_END );
+ frame.setSize( 600, 600 );
+ frame.setVisible( true );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialogDemo.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialogDemo.java
new file mode 100644
index 00000000..fe4303be
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/CommitMessageDialogDemo.java
@@ -0,0 +1,9 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+public class CommitMessageDialogDemo
+{
+ public static void main( String... args )
+ {
+ System.out.println( CommitMessageDialog.showDialog() );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialogDemo.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialogDemo.java
new file mode 100644
index 00000000..56f702fa
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/ErrorDialogDemo.java
@@ -0,0 +1,16 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+public class ErrorDialogDemo
+{
+ public static void main( String... args )
+ {
+ try
+ {
+ throw new IllegalArgumentException( "Test exception" );
+ }
+ catch ( Exception e )
+ {
+ ErrorDialog.showErrorMessage( "Title", e );
+ }
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepositoryDemo.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepositoryDemo.java
new file mode 100644
index 00000000..ddfd773c
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/MastodonGitRepositoryDemo.java
@@ -0,0 +1,25 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+import java.io.File;
+
+import org.scijava.Context;
+
+public class MastodonGitRepositoryDemo
+{
+ public static void main( String... args ) throws Exception
+ {
+ String projectPath = "/home/arzt/devel/mastodon/mastodon/src/test/resources/org/mastodon/mamut/examples/tiny/tiny-project.mastodon";
+ String repositoryName = "mgit-test";
+ String repositoryURL = "git@github.com:maarzt/mgit-test.git";
+ File parentDirectory = new File( "/home/arzt/tmp/" );
+
+// Context context = new Context();
+// WindowManager windowManager = new WindowManager( context );
+// windowManager.getProjectManager().open( new MamutProjectIO().load( projectPath ) );
+// MastodonGitUtils.createRepositoryAndUpload( windowManager, parentDirectory, repositoryName, repositoryURL );
+
+// MastodonGitUtils.cloneRepository( repositoryURL, new File( parentDirectory, "2/" ) );
+
+ MastodonGitRepository.openProjectInRepository( new Context(), new File( parentDirectory, "2/" ) );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialogDemo.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialogDemo.java
new file mode 100644
index 00000000..5356bb4e
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/NotificationDialogDemo.java
@@ -0,0 +1,9 @@
+package org.mastodon.mamut.tomancak.collaboration;
+
+public class NotificationDialogDemo
+{
+ public static void main( String... args )
+ {
+ NotificationDialog.show( "Upload Changes", "
✓ Changes were uploaded successfully." );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtilsTest.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtilsTest.java
new file mode 100644
index 00000000..8aa12321
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/commands/NewDirectoryUtilsTest.java
@@ -0,0 +1,52 @@
+/*-
+ * #%L
+ * Mastodon
+ * %%
+ * Copyright (C) 2014 - 2022 Tobias Pietzsch, Jean-Yves Tinevez
+ * %%
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ * #L%
+ */
+package org.mastodon.mamut.tomancak.collaboration.commands;
+
+import static org.junit.Assert.assertEquals;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.Test;
+
+public class NewDirectoryUtilsTest
+{
+ @Test
+ public void testExtractRepositoryName()
+ {
+ final String expected = "mastodon-tomancak";
+ List< String > urls = Arrays.asList(
+ "https://github.com/mastodon-sc/mastodon-tomancak.git",
+ "https://github.com/mastodon-sc/mastodon-tomancak/",
+ "git@github.com:mastodon-sc/mastodon-tomancak.git"
+ );
+ for ( String url : urls )
+ assertEquals( expected, NewDirectoryUtils.extractRepositoryName( url ) );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/CustomCredentialsProvider.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/CustomCredentialsProvider.java
new file mode 100644
index 00000000..5889a6b6
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/CustomCredentialsProvider.java
@@ -0,0 +1,63 @@
+package org.mastodon.mamut.tomancak.collaboration.sshauthentication;
+
+import java.util.Scanner;
+
+import org.eclipse.jgit.errors.UnsupportedCredentialItem;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * A {@link CredentialsProvider} that allows to answer yes/no questions
+ * interactively on the command line.
+ */
+public class CustomCredentialsProvider extends CredentialsProvider
+{
+
+ @Override
+ public boolean isInteractive()
+ {
+ return true;
+ }
+
+ @Override
+ public boolean supports( CredentialItem... items )
+ {
+ return true;
+ }
+
+ @Override
+ public boolean get( URIish uri, CredentialItem... items ) throws UnsupportedCredentialItem
+ {
+ boolean ok = true;
+ for ( CredentialItem item : items )
+ ok &= processItem( item );
+ if ( !ok )
+ throw new UnsupportedOperationException();
+ return ok;
+ }
+
+ private boolean processItem( CredentialItem item )
+ {
+ if ( item instanceof CredentialItem.InformationalMessage )
+ return processInformalMessage( ( CredentialItem.InformationalMessage ) item );
+ if ( item instanceof CredentialItem.YesNoType )
+ return processYesNo( ( CredentialItem.YesNoType ) item );
+ return false;
+ }
+
+ private boolean processInformalMessage( CredentialItem.InformationalMessage item )
+ {
+ System.out.println( item.getPromptText() );
+ return true;
+ }
+
+ private boolean processYesNo( CredentialItem.YesNoType item )
+ {
+ System.out.println( item.getPromptText() + " (yes/no)" );
+ String line = new Scanner( System.in ).nextLine();
+ item.setValue( "yes".equals( line ) );
+ return true;
+ }
+}
+
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/GenerateKeysExample.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/GenerateKeysExample.java
new file mode 100644
index 00000000..a6e31cea
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/GenerateKeysExample.java
@@ -0,0 +1,26 @@
+package org.mastodon.mamut.tomancak.collaboration.sshauthentication;
+
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyEncryptionContext;
+import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter;
+
+/**
+ * Example of how to generate a OpenSSH key pair and write it to stdout
+ * with the apache sshd library.
+ */
+public class GenerateKeysExample
+{
+
+ public static void main( String[] args ) throws GeneralSecurityException, IOException
+ {
+ KeyPairGenerator keyGen = KeyPairGenerator.getInstance( "RSA" );
+ keyGen.initialize( 4 * 1024 );
+ KeyPair keyPair = keyGen.genKeyPair();
+ OpenSSHKeyPairResourceWriter.INSTANCE.writePrivateKey( keyPair, "user@example", new OpenSSHKeyEncryptionContext(), System.out );
+ OpenSSHKeyPairResourceWriter.INSTANCE.writePublicKey( keyPair, "user@example", System.out );
+ }
+}
diff --git a/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/JGitSshAuthenticationExample.java b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/JGitSshAuthenticationExample.java
new file mode 100644
index 00000000..ef4e652f
--- /dev/null
+++ b/src/test/java/org/mastodon/mamut/tomancak/collaboration/sshauthentication/JGitSshAuthenticationExample.java
@@ -0,0 +1,41 @@
+package org.mastodon.mamut.tomancak.collaboration.sshauthentication;
+
+import java.io.File;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.transport.SshSessionFactory;
+import org.eclipse.jgit.transport.SshTransport;
+import org.eclipse.jgit.transport.sshd.JGitKeyCache;
+import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder;
+
+/**
+ * Example of how to use JGit with a custom SSH key.
+ */
+public class JGitSshAuthenticationExample
+{
+ public static void main( String... args ) throws Exception
+ {
+ String projectPath = "/home/arzt/devel/mastodon/mastodon/src/test/resources/org/mastodon/mamut/examples/tiny/tiny-project.mastodon";
+ String repositoryName = "mgit-test";
+ String repositoryURL = "git@github.com:masgitoff/mastodon-test-dataset.git";
+ File parentDirectory = new File( "/home/arzt/tmp/" );
+
+ SshSessionFactory sshSessionFactory = new SshdSessionFactoryBuilder()
+ .setPreferredAuthentications( "publickey" )
+ .setHomeDirectory( new File( "/home/arzt/" ) )
+ .setSshDirectory( new File( "/home/arzt/ssh-experiment" ) )
+ .build( new JGitKeyCache() );
+ try (Git git = Git.cloneRepository()
+ .setURI( repositoryURL )
+ .setDirectory( new File( parentDirectory, "xyz" ) )
+ .setCredentialsProvider( new CustomCredentialsProvider() )
+ .setTransportConfigCallback( transport -> ( ( SshTransport ) transport ).setSshSessionFactory( sshSessionFactory ) )
+ .call())
+ {
+ git.push()
+ .setTransportConfigCallback( transport -> ( ( SshTransport ) transport ).setSshSessionFactory( sshSessionFactory ) )
+ .setCredentialsProvider( new CustomCredentialsProvider() )
+ .call();
+ }
+ }
+}