Introduce multi-commands command line utilities.
authorMathieu Baudier <mbaudier@argeo.org>
Sat, 11 Jan 2020 12:16:47 +0000 (13:16 +0100)
committerMathieu Baudier <mbaudier@argeo.org>
Sat, 11 Jan 2020 12:16:47 +0000 (13:16 +0100)
21 files changed:
org.argeo.cms/src/org/argeo/cms/cli/ArgeoCli.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/CommandArgsException.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/CommandsCli.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/DescribedCommand.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/HelpCommand.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/fs/FileSync.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/fs/FsCommands.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/fs/PathSync.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/fs/SshSync.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/fs/SyncFileVisitor.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/posix/Echo.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/cli/posix/PosixCommands.java [new file with mode: 0644]
org.argeo.core/src/org/argeo/sync/SyncException.java [deleted file]
org.argeo.core/src/org/argeo/sync/cli/Sync.java [deleted file]
org.argeo.core/src/org/argeo/sync/fs/PathSync.java [deleted file]
org.argeo.core/src/org/argeo/sync/fs/SshSync.java [deleted file]
org.argeo.core/src/org/argeo/sync/fs/SyncFileVisitor.java [deleted file]
org.argeo.jcr/src/org/argeo/fs/BasicSyncFileVisitor.java
org.argeo.jcr/src/org/argeo/fs/FsUtils.java
org.argeo.jcr/src/org/argeo/sync/SyncException.java [new file with mode: 0644]
org.argeo.jcr/src/org/argeo/sync/SyncResult.java [new file with mode: 0644]

diff --git a/org.argeo.cms/src/org/argeo/cms/cli/ArgeoCli.java b/org.argeo.cms/src/org/argeo/cms/cli/ArgeoCli.java
new file mode 100644 (file)
index 0000000..5e58922
--- /dev/null
@@ -0,0 +1,31 @@
+package org.argeo.cms.cli;
+
+import org.apache.commons.cli.Option;
+import org.argeo.cli.CommandsCli;
+import org.argeo.cli.fs.FsCommands;
+import org.argeo.cli.posix.PosixCommands;
+
+/** Argeo command line tools. */
+public class ArgeoCli extends CommandsCli {
+
+       public ArgeoCli(String commandName) {
+               super(commandName);
+               // Common options
+               options.addOption(Option.builder("v").hasArg().argName("verbose").desc("verbosity").build());
+               options.addOption(
+                               Option.builder("D").hasArgs().argName("property=value").desc("use value for given property").build());
+
+               addCommandsCli(new PosixCommands("posix"));
+               addCommandsCli(new FsCommands("fs"));
+       }
+
+       @Override
+       public String getDescription() {
+               return "Argeo command line utilities";
+       }
+
+       public static void main(String[] args) {
+               mainImpl(new ArgeoCli("argeo"), args);
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/CommandArgsException.java b/org.argeo.core/src/org/argeo/cli/CommandArgsException.java
new file mode 100644 (file)
index 0000000..d7a615a
--- /dev/null
@@ -0,0 +1,32 @@
+package org.argeo.cli;
+
+public class CommandArgsException extends IllegalArgumentException {
+       private static final long serialVersionUID = -7271050747105253935L;
+       private String commandName;
+       private volatile CommandsCli commandsCli;
+
+       public CommandArgsException(Exception cause) {
+               super(cause.getMessage(), cause);
+       }
+
+       public CommandArgsException(String message) {
+               super(message);
+       }
+
+       public String getCommandName() {
+               return commandName;
+       }
+
+       public void setCommandName(String commandName) {
+               this.commandName = commandName;
+       }
+
+       public CommandsCli getCommandsCli() {
+               return commandsCli;
+       }
+
+       public void setCommandsCli(CommandsCli commandsCli) {
+               this.commandsCli = commandsCli;
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/CommandsCli.java b/org.argeo.core/src/org/argeo/cli/CommandsCli.java
new file mode 100644 (file)
index 0000000..b0879f0
--- /dev/null
@@ -0,0 +1,131 @@
+package org.argeo.cli;
+
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.Function;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.CommandLineParser;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+
+/** Base class for a CLI managing sub commands. */
+public abstract class CommandsCli implements DescribedCommand<Object> {
+       public final static String HELP = "help";
+
+       private final String commandName;
+       private Map<String, Function<List<String>, ?>> commands = new TreeMap<>();
+
+       protected final Options options = new Options();
+
+       public CommandsCli(String commandName) {
+               this.commandName = commandName;
+       }
+
+       @Override
+       public Object apply(List<String> args) {
+               String cmd = null;
+               List<String> newArgs = new ArrayList<>();
+               try {
+                       CommandLineParser clParser = new DefaultParser();
+                       CommandLine commonCl = clParser.parse(getOptions(), args.toArray(new String[args.size()]), true);
+                       List<String> leftOvers = commonCl.getArgList();
+                       for (String arg : leftOvers) {
+                               if (!arg.startsWith("-") && cmd == null) {
+                                       cmd = arg;
+                               } else {
+                                       newArgs.add(arg);
+                               }
+                       }
+               } catch (ParseException e) {
+                       CommandArgsException cae = new CommandArgsException(e);
+                       throw cae;
+               }
+
+               Function<List<String>, ?> function = cmd != null ? getCommand(cmd) : getDefaultCommand();
+               if (function == null)
+                       throw new IllegalArgumentException("Uknown command " + cmd);
+               try {
+                       return function.apply(newArgs).toString();
+               } catch (CommandArgsException e) {
+                       if (e.getCommandName() == null) {
+                               e.setCommandName(cmd);
+                               e.setCommandsCli(this);
+                       }
+                       throw e;
+               } catch (IllegalArgumentException e) {
+                       CommandArgsException cae = new CommandArgsException(e);
+                       cae.setCommandName(cmd);
+                       throw cae;
+               }
+       }
+
+       @Override
+       public Options getOptions() {
+               return options;
+       }
+
+       protected void addCommand(String cmd, Function<List<String>, ?> function) {
+               commands.put(cmd, function);
+
+       }
+
+       @Override
+       public String getUsage() {
+               return "[command]";
+       }
+
+       protected void addCommandsCli(CommandsCli commandsCli) {
+               addCommand(commandsCli.getCommandName(), commandsCli);
+               commandsCli.addCommand(HELP, new HelpCommand(this, commandsCli));
+       }
+
+       public String getCommandName() {
+               return commandName;
+       }
+
+       public Set<String> getSubCommands() {
+               return commands.keySet();
+       }
+
+       public Function<List<String>, ?> getCommand(String command) {
+               return commands.get(command);
+       }
+
+       public HelpCommand getHelpCommand() {
+               return (HelpCommand) getCommand(HELP);
+       }
+
+       public Function<List<String>, String> getDefaultCommand() {
+               return getHelpCommand();
+       }
+
+       /** In order to implement quickly a main method. */
+       public static void mainImpl(CommandsCli cli, String[] args) {
+               try {
+                       cli.addCommand(CommandsCli.HELP, new HelpCommand(null, cli));
+                       Object output = cli.apply(Arrays.asList(args));
+                       System.out.println(output);
+                       System.exit(0);
+               } catch (CommandArgsException e) {
+                       System.err.println("Wrong arguments " + Arrays.toString(args) + ": " + e.getMessage());
+                       if (e.getCommandName() != null) {
+                               StringWriter out = new StringWriter();
+                               HelpCommand.printHelp(e.getCommandsCli(), e.getCommandName(), out);
+                               System.err.println(out.toString());
+                       } else {
+                               e.printStackTrace();
+                       }
+                       System.exit(1);
+               } catch (Exception e) {
+                       e.printStackTrace();
+                       System.exit(1);
+               }
+       }
+}
diff --git a/org.argeo.core/src/org/argeo/cli/DescribedCommand.java b/org.argeo.core/src/org/argeo/cli/DescribedCommand.java
new file mode 100644 (file)
index 0000000..9587206
--- /dev/null
@@ -0,0 +1,55 @@
+package org.argeo.cli;
+
+import java.io.StringWriter;
+import java.util.Arrays;
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.DefaultParser;
+import org.apache.commons.cli.Options;
+import org.apache.commons.cli.ParseException;
+
+/** A command that can be described. */
+public interface DescribedCommand<T> extends Function<List<String>, T> {
+       default Options getOptions() {
+               return new Options();
+       }
+
+       String getDescription();
+
+       default String getUsage() {
+               return null;
+       }
+
+       default String getExamples() {
+               return null;
+       }
+
+       default CommandLine toCommandLine(List<String> args) {
+               try {
+                       DefaultParser parser = new DefaultParser();
+                       return parser.parse(getOptions(), args.toArray(new String[args.size()]));
+               } catch (ParseException e) {
+                       throw new CommandArgsException(e);
+               }
+       }
+
+       /** In order to implement quickly a main method. */
+       public static void mainImpl(DescribedCommand<?> command, String[] args) {
+               try {
+                       Object output = command.apply(Arrays.asList(args));
+                       System.out.println(output);
+                       System.exit(0);
+               } catch (IllegalArgumentException e) {
+                       StringWriter out = new StringWriter();
+                       HelpCommand.printHelp(command, out);
+                       System.err.println(out.toString());
+                       System.exit(1);
+               } catch (Exception e) {
+                       e.printStackTrace();
+                       System.exit(1);
+               }
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/HelpCommand.java b/org.argeo.core/src/org/argeo/cli/HelpCommand.java
new file mode 100644 (file)
index 0000000..067aee8
--- /dev/null
@@ -0,0 +1,129 @@
+package org.argeo.cli;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.cli.HelpFormatter;
+import org.apache.commons.cli.Options;
+
+/** A special command that can describe {@link DescribedCommand}. */
+public class HelpCommand implements DescribedCommand<String> {
+       private CommandsCli commandsCli;
+       private CommandsCli parentCommandsCli;
+
+       // Help formatting
+       private static int helpWidth = 80;
+       private static int helpLeftPad = 4;
+       private static int helpDescPad = 20;
+
+       public HelpCommand(CommandsCli parentCommandsCli, CommandsCli commandsCli) {
+               super();
+               this.parentCommandsCli = parentCommandsCli;
+               this.commandsCli = commandsCli;
+       }
+
+       @Override
+       public String apply(List<String> args) {
+               StringWriter out = new StringWriter();
+
+               if (args.size() == 0) {// overview
+                       printHelp(commandsCli, out);
+               } else {
+                       String cmd = args.get(0);
+                       Function<List<String>, ?> function = commandsCli.getCommand(cmd);
+                       if (function == null)
+                               return "Command " + cmd + " not found.";
+                       Options options;
+                       String examples;
+                       DescribedCommand<?> command = null;
+                       if (function instanceof DescribedCommand) {
+                               command = (DescribedCommand<?>) function;
+                               options = command.getOptions();
+                               examples = command.getExamples();
+                       } else {
+                               options = new Options();
+                               examples = null;
+                       }
+                       String description = getShortDescription(function);
+                       String commandCall = getCommandUsage(cmd, command);
+                       HelpFormatter formatter = new HelpFormatter();
+                       formatter.printHelp(new PrintWriter(out), helpWidth, commandCall, description, options, helpLeftPad,
+                                       helpDescPad, examples, false);
+               }
+               return out.toString();
+       }
+
+       private static String getShortDescription(Function<List<String>, ?> function) {
+               if (function instanceof DescribedCommand) {
+                       return ((DescribedCommand<?>) function).getDescription();
+               } else {
+                       return function.toString();
+               }
+       }
+
+       public String getCommandUsage(String cmd, DescribedCommand<?> command) {
+               String commandCall = getCommandCall(commandsCli) + " " + cmd;
+               assert command != null;
+               if (command != null && command.getUsage() != null) {
+                       commandCall = commandCall + " " + command.getUsage();
+               }
+               return commandCall;
+       }
+
+       @Override
+       public String getDescription() {
+               return "Shows this help or describes a command";
+       }
+
+       @Override
+       public String getUsage() {
+               return "[command]";
+       }
+
+       public CommandsCli getParentCommandsCli() {
+               return parentCommandsCli;
+       }
+
+       protected String getCommandCall(CommandsCli commandsCli) {
+               HelpCommand hc = commandsCli.getHelpCommand();
+               if (hc.getParentCommandsCli() != null) {
+                       return getCommandCall(hc.getParentCommandsCli()) + " " + commandsCli.getCommandName();
+               } else {
+                       return commandsCli.getCommandName();
+               }
+       }
+
+       public static void printHelp(DescribedCommand<?> command, StringWriter out) {
+               String usage = "java " + command.getClass().getName() + (command.getUsage() != null ? " " + command.getUsage() : "");
+               HelpFormatter formatter = new HelpFormatter();
+               formatter.printHelp(new PrintWriter(out), helpWidth, usage, command.getDescription(), command.getOptions(), helpLeftPad,
+                               helpDescPad, command.getExamples(), false);
+
+       }
+
+       public static void printHelp(CommandsCli commandsCli, String commandName, StringWriter out) {
+               DescribedCommand<?> command = (DescribedCommand<?>) commandsCli.getCommand(commandName);
+               String usage = commandsCli.getHelpCommand().getCommandUsage(commandName, command);
+               HelpFormatter formatter = new HelpFormatter();
+               formatter.printHelp(new PrintWriter(out), helpWidth, usage, command.getDescription(), command.getOptions(), helpLeftPad,
+                               helpDescPad, command.getExamples(), false);
+
+       }
+
+       public static void printHelp(CommandsCli commandsCli, StringWriter out) {
+               out.append(commandsCli.getDescription()).append('\n');
+               String leftPad = " ".repeat(helpLeftPad);
+               for (String cmd : commandsCli.getSubCommands()) {
+                       Function<List<String>, ?> function = commandsCli.getCommand(cmd);
+                       assert function != null;
+                       out.append(leftPad);
+                       out.append(cmd);
+                       // FIXME deal with long commands
+                       out.append(" ".repeat(helpDescPad - cmd.length()));
+                       out.append(getShortDescription(function));
+                       out.append('\n');
+               }
+       }
+}
diff --git a/org.argeo.core/src/org/argeo/cli/fs/FileSync.java b/org.argeo.core/src/org/argeo/cli/fs/FileSync.java
new file mode 100644 (file)
index 0000000..ba529ea
--- /dev/null
@@ -0,0 +1,102 @@
+package org.argeo.cli.fs;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.argeo.cli.CommandArgsException;
+import org.argeo.cli.DescribedCommand;
+import org.argeo.sync.SyncResult;
+
+public class FileSync implements DescribedCommand<SyncResult<Path>> {
+       final static Option deleteOption = Option.builder().longOpt("delete").desc("delete from target").build();
+       final static Option recursiveOption = Option.builder("r").longOpt("recursive").desc("recurse into directories")
+                       .build();
+       final static Option progressOption = Option.builder().longOpt("progress").hasArg(false).desc("show progress")
+                       .build();
+
+       @Override
+       public SyncResult<Path> apply(List<String> t) {
+               try {
+                       CommandLine line = toCommandLine(t);
+                       List<String> remaining = line.getArgList();
+                       if (remaining.size() == 0) {
+                               throw new CommandArgsException("There must be at least one argument");
+                       }
+                       URI sourceUri = new URI(remaining.get(0));
+                       URI targetUri;
+                       if (remaining.size() == 1) {
+                               targetUri = Paths.get(System.getProperty("user.dir")).toUri();
+                       } else {
+                               targetUri = new URI(remaining.get(1));
+                       }
+                       boolean delete = line.hasOption(deleteOption.getLongOpt());
+                       boolean recursive = line.hasOption(recursiveOption.getLongOpt());
+                       PathSync pathSync = new PathSync(sourceUri, targetUri, delete, recursive);
+                       return pathSync.call();
+               } catch (URISyntaxException e) {
+                       throw new CommandArgsException(e);
+               }
+       }
+
+       @Override
+       public Options getOptions() {
+               Options options = new Options();
+               options.addOption(recursiveOption);
+               options.addOption(deleteOption);
+               options.addOption(progressOption);
+               return options;
+       }
+
+       @Override
+       public String getUsage() {
+               return "[source URI] [target URI]";
+       }
+
+       public static void main(String[] args) {
+               DescribedCommand.mainImpl(new FileSync(), args);
+//             Options options = new Options();
+//             options.addOption("r", "recursive", false, "recurse into directories");
+//             options.addOption(Option.builder().longOpt("progress").hasArg(false).desc("show progress").build());
+//
+//             CommandLineParser parser = new DefaultParser();
+//             try {
+//                     CommandLine line = parser.parse(options, args);
+//                     List<String> remaining = line.getArgList();
+//                     if (remaining.size() == 0) {
+//                             System.err.println("There must be at least one argument");
+//                             printHelp(options);
+//                             System.exit(1);
+//                     }
+//                     URI sourceUri = new URI(remaining.get(0));
+//                     URI targetUri;
+//                     if (remaining.size() == 1) {
+//                             targetUri = Paths.get(System.getProperty("user.dir")).toUri();
+//                     } else {
+//                             targetUri = new URI(remaining.get(1));
+//                     }
+//                     PathSync pathSync = new PathSync(sourceUri, targetUri);
+//                     pathSync.run();
+//             } catch (Exception exp) {
+//                     exp.printStackTrace();
+//                     printHelp(options);
+//                     System.exit(1);
+//             }
+       }
+
+//     public static void printHelp(Options options) {
+//             HelpFormatter formatter = new HelpFormatter();
+//             formatter.printHelp("sync SRC [DEST]", options, true);
+//     }
+
+       @Override
+       public String getDescription() {
+               return "Synchronises files";
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/fs/FsCommands.java b/org.argeo.core/src/org/argeo/cli/fs/FsCommands.java
new file mode 100644 (file)
index 0000000..c08ad00
--- /dev/null
@@ -0,0 +1,18 @@
+package org.argeo.cli.fs;
+
+import org.argeo.cli.CommandsCli;
+
+/** File utilities. */
+public class FsCommands extends CommandsCli {
+
+       public FsCommands(String commandName) {
+               super(commandName);
+               addCommand("sync", new FileSync());
+       }
+
+       @Override
+       public String getDescription() {
+               return "Utilities around files and file systems";
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/fs/PathSync.java b/org.argeo.core/src/org/argeo/cli/fs/PathSync.java
new file mode 100644 (file)
index 0000000..01cef9e
--- /dev/null
@@ -0,0 +1,62 @@
+package org.argeo.cli.fs;
+
+import java.net.URI;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.spi.FileSystemProvider;
+import java.util.concurrent.Callable;
+
+import org.argeo.jackrabbit.fs.DavexFsProvider;
+import org.argeo.ssh.Sftp;
+import org.argeo.sync.SyncResult;
+
+/** Synchronises two paths. */
+public class PathSync implements Callable<SyncResult<Path>> {
+       private final URI sourceUri, targetUri;
+       private final boolean delete;
+       private final boolean recursive;
+
+       public PathSync(URI sourceUri, URI targetUri) {
+               this(sourceUri, targetUri, false, false);
+       }
+
+       public PathSync(URI sourceUri, URI targetUri, boolean delete, boolean recursive) {
+               this.sourceUri = sourceUri;
+               this.targetUri = targetUri;
+               this.delete = delete;
+               this.recursive = recursive;
+       }
+
+       @Override
+       public SyncResult<Path> call() {
+               try {
+                       Path sourceBasePath = createPath(sourceUri);
+                       Path targetBasePath = createPath(targetUri);
+                       SyncFileVisitor syncFileVisitor = new SyncFileVisitor(sourceBasePath, targetBasePath, delete, recursive);
+                       Files.walkFileTree(sourceBasePath, syncFileVisitor);
+                       return syncFileVisitor.getSyncResult();
+               } catch (Exception e) {
+                       throw new IllegalStateException("Cannot sync " + sourceUri + " to " + targetUri, e);
+               }
+       }
+
+       private static Path createPath(URI uri) {
+               Path path;
+               if (uri.getScheme() == null) {
+                       path = Paths.get(uri.getPath());
+               } else if (uri.getScheme().equals("file")) {
+                       FileSystemProvider fsProvider = FileSystems.getDefault().provider();
+                       path = fsProvider.getPath(uri);
+               } else if (uri.getScheme().equals("davex")) {
+                       FileSystemProvider fsProvider = new DavexFsProvider();
+                       path = fsProvider.getPath(uri);
+               } else if (uri.getScheme().equals("sftp")) {
+                       Sftp sftp = new Sftp(uri);
+                       path = sftp.getBasePath();
+               } else
+                       throw new IllegalArgumentException("URI scheme not supported for " + uri);
+               return path;
+       }
+}
diff --git a/org.argeo.core/src/org/argeo/cli/fs/SshSync.java b/org.argeo.core/src/org/argeo/cli/fs/SshSync.java
new file mode 100644 (file)
index 0000000..268e7d8
--- /dev/null
@@ -0,0 +1,135 @@
+package org.argeo.cli.fs;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import java.util.Scanner;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.sshd.agent.SshAgent;
+import org.apache.sshd.agent.SshAgentFactory;
+import org.apache.sshd.agent.local.LocalAgentFactory;
+import org.apache.sshd.agent.unix.UnixAgentFactory;
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.future.ConnectFuture;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.subsystem.sftp.fs.SftpFileSystem;
+import org.apache.sshd.client.subsystem.sftp.fs.SftpFileSystemProvider;
+
+public class SshSync {
+       private final static Log log = LogFactory.getLog(SshSync.class);
+
+       public static void main(String[] args) {
+
+               try (SshClient client = SshClient.setUpDefaultClient()) {
+                       client.start();
+                       boolean osAgent = true;
+                       SshAgentFactory agentFactory = osAgent ? new UnixAgentFactory() : new LocalAgentFactory();
+                       // SshAgentFactory agentFactory = new LocalAgentFactory();
+                       client.setAgentFactory(agentFactory);
+                       SshAgent sshAgent = agentFactory.createClient(client);
+
+                       String login = System.getProperty("user.name");
+                       String host = "localhost";
+                       int port = 22;
+
+                       if (!osAgent) {
+                               String keyPath = "/home/" + login + "/.ssh/id_rsa";
+                               System.out.print(keyPath + ": ");
+                               Scanner s = new Scanner(System.in);
+                               String password = s.next();
+//                             KeyPair keyPair = ClientIdentityLoader.DEFAULT.loadClientIdentity(keyPath,
+//                                             FilePasswordProvider.of(password));
+//                             sshAgent.addIdentity(keyPair, "NO COMMENT");
+                       }
+
+//                     List<? extends Map.Entry<PublicKey, String>> identities = sshAgent.getIdentities();
+//                     for (Map.Entry<PublicKey, String> entry : identities) {
+//                             System.out.println(entry.getValue() + " : " + entry.getKey());
+//                     }
+
+                       ConnectFuture connectFuture = client.connect(login, host, port);
+                       connectFuture.await();
+                       ClientSession session = connectFuture.getSession();
+
+                       try {
+
+//                             session.addPasswordIdentity(new String(password));
+                               session.auth().verify(1000l);
+
+                               SftpFileSystemProvider fsProvider = new SftpFileSystemProvider(client);
+
+                               SftpFileSystem fs = fsProvider.newFileSystem(session);
+                               Path testPath = fs.getPath("/home/" + login + "/tmp");
+                               Files.list(testPath).forEach(System.out::println);
+                               test(testPath);
+
+                       } finally {
+                               client.stop();
+                       }
+               } catch (Exception e) {
+                       // TODO Auto-generated catch block
+                       e.printStackTrace();
+               }
+       }
+
+       static void test(Path testBase) {
+               try {
+                       Path testPath = testBase.resolve("ssh-test.txt");
+                       Files.createFile(testPath);
+                       log.debug("Created file " + testPath);
+                       Files.delete(testPath);
+                       log.debug("Deleted " + testPath);
+                       String txt = "TEST\nTEST2\n";
+                       byte[] arr = txt.getBytes();
+                       Files.write(testPath, arr);
+                       log.debug("Wrote " + testPath);
+                       byte[] read = Files.readAllBytes(testPath);
+                       log.debug("Read " + testPath);
+                       Path testDir = testBase.resolve("testDir");
+                       log.debug("Resolved " + testDir);
+                       // Copy
+                       Files.createDirectory(testDir);
+                       log.debug("Created directory " + testDir);
+                       Path subsubdir = Files.createDirectories(testDir.resolve("subdir/subsubdir"));
+                       log.debug("Created sub directories " + subsubdir);
+                       Path copiedFile = testDir.resolve("copiedFile.txt");
+                       log.debug("Resolved " + copiedFile);
+                       Path relativeCopiedFile = testDir.relativize(copiedFile);
+                       log.debug("Relative copied file " + relativeCopiedFile);
+                       try (OutputStream out = Files.newOutputStream(copiedFile);
+                                       InputStream in = Files.newInputStream(testPath)) {
+                               IOUtils.copy(in, out);
+                       }
+                       log.debug("Copied " + testPath + " to " + copiedFile);
+                       Files.delete(testPath);
+                       log.debug("Deleted " + testPath);
+                       byte[] copiedRead = Files.readAllBytes(copiedFile);
+                       log.debug("Read " + copiedFile);
+                       // Browse directories
+                       DirectoryStream<Path> files = Files.newDirectoryStream(testDir);
+                       int fileCount = 0;
+                       Path listedFile = null;
+                       for (Path file : files) {
+                               fileCount++;
+                               if (!Files.isDirectory(file))
+                                       listedFile = file;
+                       }
+                       log.debug("Listed " + testDir);
+                       // Generic attributes
+                       Map<String, Object> attrs = Files.readAttributes(copiedFile, "*");
+                       log.debug("Read attributes of " + copiedFile + ": " + attrs.keySet());
+               } catch (IOException e) {
+                       // TODO Auto-generated catch block
+                       e.printStackTrace();
+               }
+
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/fs/SyncFileVisitor.java b/org.argeo.core/src/org/argeo/cli/fs/SyncFileVisitor.java
new file mode 100644 (file)
index 0000000..892df50
--- /dev/null
@@ -0,0 +1,31 @@
+package org.argeo.cli.fs;
+
+import java.nio.file.Path;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.argeo.fs.BasicSyncFileVisitor;
+
+/** Synchronises two directory structures. */
+public class SyncFileVisitor extends BasicSyncFileVisitor {
+       private final static Log log = LogFactory.getLog(SyncFileVisitor.class);
+
+       public SyncFileVisitor(Path sourceBasePath, Path targetBasePath, boolean delete, boolean recursive) {
+               super(sourceBasePath, targetBasePath, delete, recursive);
+       }
+
+       @Override
+       protected void error(Object obj, Throwable e) {
+               log.error(obj, e);
+       }
+
+       @Override
+       protected boolean isTraceEnabled() {
+               return log.isTraceEnabled();
+       }
+
+       @Override
+       protected void trace(Object obj) {
+               log.trace(obj);
+       }
+}
diff --git a/org.argeo.core/src/org/argeo/cli/posix/Echo.java b/org.argeo.core/src/org/argeo/cli/posix/Echo.java
new file mode 100644 (file)
index 0000000..5746ebd
--- /dev/null
@@ -0,0 +1,46 @@
+package org.argeo.cli.posix;
+
+import java.util.List;
+
+import org.apache.commons.cli.CommandLine;
+import org.apache.commons.cli.Option;
+import org.apache.commons.cli.Options;
+import org.argeo.cli.DescribedCommand;
+
+public class Echo implements DescribedCommand<String> {
+
+       @Override
+       public Options getOptions() {
+               Options options = new Options();
+               options.addOption(Option.builder("n").desc("do not output the trailing newline").build());
+               return options;
+       }
+
+       @Override
+       public String getDescription() {
+               return "Display a line of text";
+       }
+
+       @Override
+       public String getUsage() {
+               return "[STRING]...";
+       }
+
+       @Override
+       public String apply(List<String> args) {
+               CommandLine cl = toCommandLine(args);
+
+               StringBuffer sb = new StringBuffer();
+               for (String s : cl.getArgList()) {
+                       sb.append(s).append(' ');
+               }
+
+               if (cl.hasOption('n')) {
+                       sb.deleteCharAt(sb.length() - 1);
+               } else {
+                       sb.setCharAt(sb.length() - 1, '\n');
+               }
+               return sb.toString();
+       }
+
+}
diff --git a/org.argeo.core/src/org/argeo/cli/posix/PosixCommands.java b/org.argeo.core/src/org/argeo/cli/posix/PosixCommands.java
new file mode 100644 (file)
index 0000000..bb6af67
--- /dev/null
@@ -0,0 +1,21 @@
+package org.argeo.cli.posix;
+
+import org.argeo.cli.CommandsCli;
+
+/** POSIX commands. */
+public class PosixCommands extends CommandsCli {
+
+       public PosixCommands(String commandName) {
+               super(commandName);
+               addCommand("echo", new Echo());
+       }
+
+       @Override
+       public String getDescription() {
+               return "Reimplementation of some POSIX commands in plain Java";
+       }
+
+       public static void main(String[] args) {
+               mainImpl(new PosixCommands("argeo-posix"), args);
+       }
+}
diff --git a/org.argeo.core/src/org/argeo/sync/SyncException.java b/org.argeo.core/src/org/argeo/sync/SyncException.java
deleted file mode 100644 (file)
index 89bf869..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.argeo.sync;
-
-/** Commons exception for sync */
-public class SyncException extends RuntimeException {
-       private static final long serialVersionUID = -3371314343580218538L;
-
-       public SyncException(String message) {
-               super(message);
-       }
-
-       public SyncException(String message, Throwable cause) {
-               super(message, cause);
-       }
-
-       public SyncException(Object source, Object target, Throwable cause) {
-               super("Cannot sync from " + source + " to " + target, cause);
-       }
-}
diff --git a/org.argeo.core/src/org/argeo/sync/cli/Sync.java b/org.argeo.core/src/org/argeo/sync/cli/Sync.java
deleted file mode 100644 (file)
index d21b5a2..0000000
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.argeo.sync.cli;
-
-import java.net.URI;
-import java.nio.file.Paths;
-import java.util.List;
-
-import org.apache.commons.cli.CommandLine;
-import org.apache.commons.cli.CommandLineParser;
-import org.apache.commons.cli.DefaultParser;
-import org.apache.commons.cli.HelpFormatter;
-import org.apache.commons.cli.Option;
-import org.apache.commons.cli.Options;
-import org.argeo.sync.fs.PathSync;
-
-public class Sync {
-
-       public static void main(String[] args) {
-               Options options = new Options();
-               options.addOption("r", "recursive", false, "recurse into directories");
-               options.addOption(Option.builder().longOpt("progress").hasArg(false).desc("show progress").build());
-
-               CommandLineParser parser = new DefaultParser();
-               try {
-                       CommandLine line = parser.parse(options, args);
-                       List<String> remaining = line.getArgList();
-                       if (remaining.size() == 0) {
-                               System.err.println("There must be at least one argument");
-                               printHelp(options);
-                               System.exit(1);
-                       }
-                       URI sourceUri = new URI(remaining.get(0));
-                       URI targetUri;
-                       if (remaining.size() == 1) {
-                               targetUri = Paths.get(System.getProperty("user.dir")).toUri();
-                       } else {
-                               targetUri = new URI(remaining.get(1));
-                       }
-                       PathSync pathSync = new PathSync(sourceUri, targetUri);
-                       pathSync.run();
-               } catch (Exception exp) {
-                       exp.printStackTrace();
-                       printHelp(options);
-                       System.exit(1);
-               }
-       }
-
-       public static void printHelp(Options options) {
-               HelpFormatter formatter = new HelpFormatter();
-               formatter.printHelp("sync SRC [DEST]", options, true);
-       }
-
-}
diff --git a/org.argeo.core/src/org/argeo/sync/fs/PathSync.java b/org.argeo.core/src/org/argeo/sync/fs/PathSync.java
deleted file mode 100644 (file)
index 151194d..0000000
+++ /dev/null
@@ -1,53 +0,0 @@
-package org.argeo.sync.fs;
-
-import java.net.URI;
-import java.nio.file.FileSystems;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-import java.nio.file.spi.FileSystemProvider;
-
-import org.argeo.jackrabbit.fs.DavexFsProvider;
-import org.argeo.ssh.Sftp;
-import org.argeo.sync.SyncException;
-
-/** Synchronises two paths. */
-public class PathSync implements Runnable {
-       private final URI sourceUri, targetUri;
-       private boolean delete = false;
-
-       public PathSync(URI sourceUri, URI targetUri) {
-               this.sourceUri = sourceUri;
-               this.targetUri = targetUri;
-       }
-
-       @Override
-       public void run() {
-               try {
-                       Path sourceBasePath = createPath(sourceUri);
-                       Path targetBasePath = createPath(targetUri);
-                       SyncFileVisitor syncFileVisitor = new SyncFileVisitor(sourceBasePath, targetBasePath, delete);
-                       Files.walkFileTree(sourceBasePath, syncFileVisitor);
-               } catch (Exception e) {
-                       e.printStackTrace();
-               }
-       }
-
-       private static Path createPath(URI uri) {
-               Path path;
-               if (uri.getScheme() == null) {
-                       path = Paths.get(uri.getPath());
-               } else if (uri.getScheme().equals("file")) {
-                       FileSystemProvider fsProvider = FileSystems.getDefault().provider();
-                       path = fsProvider.getPath(uri);
-               } else if (uri.getScheme().equals("davex")) {
-                       FileSystemProvider fsProvider = new DavexFsProvider();
-                       path = fsProvider.getPath(uri);
-               } else if (uri.getScheme().equals("sftp")) {
-                       Sftp sftp = new Sftp(uri);
-                       path = sftp.getBasePath();
-               } else
-                       throw new SyncException("URI scheme not supported for " + uri);
-               return path;
-       }
-}
diff --git a/org.argeo.core/src/org/argeo/sync/fs/SshSync.java b/org.argeo.core/src/org/argeo/sync/fs/SshSync.java
deleted file mode 100644 (file)
index 9b15a32..0000000
+++ /dev/null
@@ -1,135 +0,0 @@
-package org.argeo.sync.fs;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.file.DirectoryStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Map;
-import java.util.Scanner;
-
-import org.apache.commons.io.IOUtils;
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-import org.apache.sshd.agent.SshAgent;
-import org.apache.sshd.agent.SshAgentFactory;
-import org.apache.sshd.agent.local.LocalAgentFactory;
-import org.apache.sshd.agent.unix.UnixAgentFactory;
-import org.apache.sshd.client.SshClient;
-import org.apache.sshd.client.future.ConnectFuture;
-import org.apache.sshd.client.session.ClientSession;
-import org.apache.sshd.client.subsystem.sftp.fs.SftpFileSystem;
-import org.apache.sshd.client.subsystem.sftp.fs.SftpFileSystemProvider;
-
-public class SshSync {
-       private final static Log log = LogFactory.getLog(SshSync.class);
-
-       public static void main(String[] args) {
-
-               try (SshClient client = SshClient.setUpDefaultClient()) {
-                       client.start();
-                       boolean osAgent = true;
-                       SshAgentFactory agentFactory = osAgent ? new UnixAgentFactory() : new LocalAgentFactory();
-                       // SshAgentFactory agentFactory = new LocalAgentFactory();
-                       client.setAgentFactory(agentFactory);
-                       SshAgent sshAgent = agentFactory.createClient(client);
-
-                       String login = System.getProperty("user.name");
-                       String host = "localhost";
-                       int port = 22;
-
-                       if (!osAgent) {
-                               String keyPath = "/home/" + login + "/.ssh/id_rsa";
-                               System.out.print(keyPath + ": ");
-                               Scanner s = new Scanner(System.in);
-                               String password = s.next();
-//                             KeyPair keyPair = ClientIdentityLoader.DEFAULT.loadClientIdentity(keyPath,
-//                                             FilePasswordProvider.of(password));
-//                             sshAgent.addIdentity(keyPair, "NO COMMENT");
-                       }
-
-//                     List<? extends Map.Entry<PublicKey, String>> identities = sshAgent.getIdentities();
-//                     for (Map.Entry<PublicKey, String> entry : identities) {
-//                             System.out.println(entry.getValue() + " : " + entry.getKey());
-//                     }
-
-                       ConnectFuture connectFuture = client.connect(login, host, port);
-                       connectFuture.await();
-                       ClientSession session = connectFuture.getSession();
-
-                       try {
-
-//                             session.addPasswordIdentity(new String(password));
-                               session.auth().verify(1000l);
-
-                               SftpFileSystemProvider fsProvider = new SftpFileSystemProvider(client);
-
-                               SftpFileSystem fs = fsProvider.newFileSystem(session);
-                               Path testPath = fs.getPath("/home/" + login + "/tmp");
-                               Files.list(testPath).forEach(System.out::println);
-                               test(testPath);
-
-                       } finally {
-                               client.stop();
-                       }
-               } catch (Exception e) {
-                       // TODO Auto-generated catch block
-                       e.printStackTrace();
-               }
-       }
-
-       static void test(Path testBase) {
-               try {
-                       Path testPath = testBase.resolve("ssh-test.txt");
-                       Files.createFile(testPath);
-                       log.debug("Created file " + testPath);
-                       Files.delete(testPath);
-                       log.debug("Deleted " + testPath);
-                       String txt = "TEST\nTEST2\n";
-                       byte[] arr = txt.getBytes();
-                       Files.write(testPath, arr);
-                       log.debug("Wrote " + testPath);
-                       byte[] read = Files.readAllBytes(testPath);
-                       log.debug("Read " + testPath);
-                       Path testDir = testBase.resolve("testDir");
-                       log.debug("Resolved " + testDir);
-                       // Copy
-                       Files.createDirectory(testDir);
-                       log.debug("Created directory " + testDir);
-                       Path subsubdir = Files.createDirectories(testDir.resolve("subdir/subsubdir"));
-                       log.debug("Created sub directories " + subsubdir);
-                       Path copiedFile = testDir.resolve("copiedFile.txt");
-                       log.debug("Resolved " + copiedFile);
-                       Path relativeCopiedFile = testDir.relativize(copiedFile);
-                       log.debug("Relative copied file " + relativeCopiedFile);
-                       try (OutputStream out = Files.newOutputStream(copiedFile);
-                                       InputStream in = Files.newInputStream(testPath)) {
-                               IOUtils.copy(in, out);
-                       }
-                       log.debug("Copied " + testPath + " to " + copiedFile);
-                       Files.delete(testPath);
-                       log.debug("Deleted " + testPath);
-                       byte[] copiedRead = Files.readAllBytes(copiedFile);
-                       log.debug("Read " + copiedFile);
-                       // Browse directories
-                       DirectoryStream<Path> files = Files.newDirectoryStream(testDir);
-                       int fileCount = 0;
-                       Path listedFile = null;
-                       for (Path file : files) {
-                               fileCount++;
-                               if (!Files.isDirectory(file))
-                                       listedFile = file;
-                       }
-                       log.debug("Listed " + testDir);
-                       // Generic attributes
-                       Map<String, Object> attrs = Files.readAttributes(copiedFile, "*");
-                       log.debug("Read attributes of " + copiedFile + ": " + attrs.keySet());
-               } catch (IOException e) {
-                       // TODO Auto-generated catch block
-                       e.printStackTrace();
-               }
-
-       }
-
-}
diff --git a/org.argeo.core/src/org/argeo/sync/fs/SyncFileVisitor.java b/org.argeo.core/src/org/argeo/sync/fs/SyncFileVisitor.java
deleted file mode 100644 (file)
index d59a611..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-package org.argeo.sync.fs;
-
-import java.nio.file.Path;
-
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-import org.argeo.fs.BasicSyncFileVisitor;
-
-/** Synchronises two directory structures. */
-public class SyncFileVisitor extends BasicSyncFileVisitor {
-       private final static Log log = LogFactory.getLog(SyncFileVisitor.class);
-
-       public SyncFileVisitor(Path sourceBasePath, Path targetBasePath, boolean delete) {
-               super(sourceBasePath, targetBasePath, delete);
-       }
-
-       @Override
-       protected void error(Object obj, Throwable e) {
-               log.error(obj, e);
-       }
-
-       @Override
-       protected boolean isDebugEnabled() {
-               return log.isDebugEnabled();
-       }
-
-       @Override
-       protected void debug(Object obj) {
-               log.debug(obj);
-       }
-}
index c60492d0847285ce3eae690f37bf74e2250dd1f9..03bac592c72d100a0bf56a9e210c245e16b94572 100644 (file)
@@ -9,23 +9,31 @@ import java.nio.file.StandardCopyOption;
 import java.nio.file.attribute.BasicFileAttributes;
 import java.nio.file.attribute.FileTime;
 
+import org.argeo.sync.SyncResult;
+
 /** Synchronises two directory structures. */
 public class BasicSyncFileVisitor extends SimpleFileVisitor<Path> {
        // TODO make it configurable
-       private boolean debug = false;
+       private boolean trace = false;
 
        private final Path sourceBasePath;
        private final Path targetBasePath;
        private final boolean delete;
+       private final boolean recursive;
+
+       private SyncResult<Path> syncResult = new SyncResult<>();
 
-       public BasicSyncFileVisitor(Path sourceBasePath, Path targetBasePath, boolean delete) {
+       public BasicSyncFileVisitor(Path sourceBasePath, Path targetBasePath, boolean delete, boolean recursive) {
                this.sourceBasePath = sourceBasePath;
                this.targetBasePath = targetBasePath;
                this.delete = delete;
+               this.recursive = recursive;
        }
 
        @Override
        public FileVisitResult preVisitDirectory(Path sourceDir, BasicFileAttributes attrs) throws IOException {
+               if (!recursive && !sourceDir.equals(sourceBasePath))
+                       return FileVisitResult.SKIP_SUBTREE;
                Path targetDir = toTargetPath(sourceDir);
                Files.createDirectories(targetDir);
                return FileVisitResult.CONTINUE;
@@ -56,7 +64,7 @@ public class BasicSyncFileVisitor extends SimpleFileVisitor<Path> {
                try {
                        if (!Files.exists(targetFile)) {
                                Files.copy(sourceFile, targetFile);
-                               copied(sourceFile, targetFile);
+                               added(sourceFile, targetFile);
                        } else {
                                if (shouldOverwrite(sourceFile, targetFile)) {
                                        Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
@@ -105,22 +113,34 @@ public class BasicSyncFileVisitor extends SimpleFileVisitor<Path> {
                return targetBasePath;
        }
 
-       protected void copied(Path sourcePath, Path targetPath) {
-               if (isDebugEnabled())
-                       debug("Copied " + sourcePath + " to " + targetPath);
+       protected void added(Path sourcePath, Path targetPath) {
+               syncResult.getAdded().add(targetPath);
+               if (isTraceEnabled())
+                       trace("Added " + sourcePath + " as " + targetPath);
+       }
+
+       protected void modified(Path sourcePath, Path targetPath) {
+               syncResult.getModified().add(targetPath);
+               if (isTraceEnabled())
+                       trace("Overwritten from " + sourcePath + " to " + targetPath);
        }
 
        protected void copyFailed(Path sourcePath, Path targetPath, Exception e) {
-               error("Cannot copy " + sourcePath + " to " + targetPath, e);
+               syncResult.addError(sourcePath, targetPath, e);
+               if (isTraceEnabled())
+                       error("Cannot copy " + sourcePath + " to " + targetPath, e);
        }
 
        protected void deleted(Path targetPath) {
-               if (isDebugEnabled())
-                       debug("Deleted " + targetPath);
+               syncResult.getDeleted().add(targetPath);
+               if (isTraceEnabled())
+                       trace("Deleted " + targetPath);
        }
 
        protected void deleteFailed(Path targetPath, Exception e) {
-               error("Cannot delete " + targetPath, e);
+               syncResult.addError(null, targetPath, e);
+               if (isTraceEnabled())
+                       error("Cannot delete " + targetPath, e);
        }
 
        /** Log error. */
@@ -129,11 +149,16 @@ public class BasicSyncFileVisitor extends SimpleFileVisitor<Path> {
                e.printStackTrace();
        }
 
-       protected boolean isDebugEnabled() {
-               return debug;
+       protected boolean isTraceEnabled() {
+               return trace;
        }
 
-       protected void debug(Object obj) {
+       protected void trace(Object obj) {
                System.out.println(obj);
        }
+
+       public SyncResult<Path> getSyncResult() {
+               return syncResult;
+       }
+
 }
index 6fc7bd25dd7470a32bb2f040c02840d2e157b9f2..c96f56ed26818dae6fc2f2e3f792326bb802c502 100644 (file)
@@ -16,7 +16,7 @@ public class FsUtils {
 
        /** Sync a source path with a target path. */
        public static void sync(Path sourceBasePath, Path targetBasePath, boolean delete) {
-               sync(new BasicSyncFileVisitor(sourceBasePath, targetBasePath, delete));
+               sync(new BasicSyncFileVisitor(sourceBasePath, targetBasePath, delete, true));
        }
 
        public static void sync(BasicSyncFileVisitor syncFileVisitor) {
diff --git a/org.argeo.jcr/src/org/argeo/sync/SyncException.java b/org.argeo.jcr/src/org/argeo/sync/SyncException.java
new file mode 100644 (file)
index 0000000..89bf869
--- /dev/null
@@ -0,0 +1,18 @@
+package org.argeo.sync;
+
+/** Commons exception for sync */
+public class SyncException extends RuntimeException {
+       private static final long serialVersionUID = -3371314343580218538L;
+
+       public SyncException(String message) {
+               super(message);
+       }
+
+       public SyncException(String message, Throwable cause) {
+               super(message, cause);
+       }
+
+       public SyncException(Object source, Object target, Throwable cause) {
+               super("Cannot sync from " + source + " to " + target, cause);
+       }
+}
diff --git a/org.argeo.jcr/src/org/argeo/sync/SyncResult.java b/org.argeo.jcr/src/org/argeo/sync/SyncResult.java
new file mode 100644 (file)
index 0000000..6d12ada
--- /dev/null
@@ -0,0 +1,101 @@
+package org.argeo.sync;
+
+import java.time.Instant;
+import java.util.Set;
+import java.util.TreeSet;
+
+/** Describes what happendend during a sync operation. */
+public class SyncResult<T> {
+       private final Set<T> added = new TreeSet<>();
+       private final Set<T> modified = new TreeSet<>();
+       private final Set<T> deleted = new TreeSet<>();
+       private final Set<Error> errors = new TreeSet<>();
+
+       public Set<T> getAdded() {
+               return added;
+       }
+
+       public Set<T> getModified() {
+               return modified;
+       }
+
+       public Set<T> getDeleted() {
+               return deleted;
+       }
+
+       public Set<Error> getErrors() {
+               return errors;
+       }
+
+       public void addError(T sourcePath, T targetPath, Exception e) {
+               Error error = new Error(sourcePath, targetPath, e);
+               errors.add(error);
+       }
+
+       public boolean noModification() {
+               return modified.isEmpty() && deleted.isEmpty() && added.isEmpty();
+       }
+
+       @Override
+       public String toString() {
+               if (noModification())
+                       return "No modification.";
+               StringBuffer sb = new StringBuffer();
+               for (T p : modified)
+                       sb.append("MOD ").append(p).append('\n');
+               for (T p : deleted)
+                       sb.append("DEL ").append(p).append('\n');
+               for (T p : added)
+                       sb.append("ADD ").append(p).append('\n');
+               for (Error error : errors)
+                       sb.append(error).append('\n');
+               return sb.toString();
+       }
+
+       public class Error implements Comparable<Error> {
+               private final T sourcePath;// if null this is a failed delete
+               private final T targetPath;
+               private final Exception exception;
+               private final Instant timestamp = Instant.now();
+
+               public Error(T sourcePath, T targetPath, Exception e) {
+                       super();
+                       this.sourcePath = sourcePath;
+                       this.targetPath = targetPath;
+                       this.exception = e;
+               }
+
+               public T getSourcePath() {
+                       return sourcePath;
+               }
+
+               public T getTargetPath() {
+                       return targetPath;
+               }
+
+               public Exception getException() {
+                       return exception;
+               }
+
+               public Instant getTimestamp() {
+                       return timestamp;
+               }
+
+               @Override
+               public int compareTo(Error o) {
+                       return timestamp.compareTo(o.timestamp);
+               }
+
+               @Override
+               public int hashCode() {
+                       return timestamp.hashCode();
+               }
+
+               @Override
+               public String toString() {
+                       return "ERR " + timestamp + (sourcePath == null ? "Deletion failed" : "Copy failed " + sourcePath) + " "
+                                       + targetPath + " " + exception.getMessage();
+               }
+
+       }
+}