/**
* Sync 2.1
* Copyright 2007 Zach Scrivena
* 2007-12-09
* zachscrivena@gmail.com
* http://syncdir.sourceforge.net/
*
* Sync performs one-way directory or file synchronization.
*
* TERMS AND CONDITIONS:
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package sync;
import java.io.Console;
import java.io.File;
import java.io.PrintWriter;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Scanner;
import java.util.TreeMap;
import java.util.regex.PatternSyntaxException;
/**
* Sync performs one-way directory or file synchronization.
*/
public class Sync
{
/******************************************
* CONSTANTS AND MISCELLANEOUS PARAMETERS *
******************************************/
/** constant: program title */
private static final String PROGRAM_TITLE =
"Sync 2.1 Copyright 2007 Zach Scrivena 2007-12-09";
/** constant: time format string (yyyy-MM-dd HH:mm:ss.SSS) */
static final String TIME_FORMAT_STRING = "%1$tF %1$tT.%1$tL";
/** parameter: true if this is a Windows OS, false otherwise */
private static boolean isWindowsOperatingSystem = false;
/******************************
* SYNCHRONIZATION PARAMETERS *
******************************/
/** parameter: simulate only; do not modify target (default = false) */
private static boolean simulateOnly = false;
/** parameter: ignore warnings; do not pause (default = false) */
private static boolean ignoreWarnings = false;
/** parameter: canonical full pathname of log file (default = null) */
private static String logName = null;
/** parameter: log file PrintWriter (default = null) */
static PrintWriter log = null;
/** parameter: standard output */
static PrintWriter stdout = null;
/** parameter: standard error */
static PrintWriter stderr = null;
/** parameter: do not recurse into subdirectories (default = false) */
private static boolean noRecurse = false;
/** enum type: synchronization mode (DIRECTORY, FILE) */
private static enum SyncMode
{
DIRECTORY,
FILE;
}
/** parameter: synchronization mode (DIRECTORY, FILE) */
private static SyncMode syncMode;
/** parameter: match file/directory name (default = true) */
private static boolean matchName = true;
/** parameter: match file/directory size (always true) */
private static final boolean matchSize = true;
/** parameter: match file/directory last-modified time (default = true) */
private static boolean matchTime = true;
/** parameter: match file/directory CRC-32 checksum (default = true) */
private static boolean matchCrc = true;
/** parameter: time-tolerance in milliseconds for file-matching (default = 0) */
private static long matchTimeTolerance = 0L;
/** parameter: string representation of match attributes, e.g. "(name,size,time,CRC)" */
private static String matchNstcString;
/** partial FileUnit comparator for file-matching */
private static FileUnitComparator matchFileUnitComparator;
/** partial FileUnit comparator for searching (should be a "truncated" version of Sync.matchFileUnitComparator) */
private static FileUnitComparator searchFileUnitComparator;
/** name-only FileUnit comparator */
private static FileUnitComparator nameOnlyFileUnitComparator;
/** parameter: canonical full pathname of source file/directory (ends with a separator for a directory) */
private static String sourceName;
/** parameter: canonical full pathname of target file/directory (ends with a separator for a directory) */
private static String targetName;
/** parameter: source file/directory (canonical full pathname) */
private static File source;
/** parameter: target file/directory (canonical full pathname) */
private static File target;
/** parameter: default action on renaming matched files (default = '\0') */
private static char defaultActionOnRenameMatched = '\0';
/** parameter: default action on synchronizing time of matched files (default = '\0') */
private static char defaultActionOnTimeSyncMatched = '\0';
/** parameter: default action on deleting unmatched target files/directories (default = '\0') */
private static char defaultActionOnDeleteUnmatched = '\0';
/** parameter: default action on overwriting existing target files (default = '\0') */
static char defaultActionOnOverwrite = '\0';
/** parameter: filter for source file/directory names (default = null) */
private static FilterNode sourceFilter = null;
/** parameter: filter for target file/directory names (default = null) */
private static FilterNode targetFilter = null;
/** parameter: filter relative pathnames instead of filenames (default = false) */
private static boolean filterRelativePathname = false;
/** parameter: use lower case names for filtering (default = false) */
private static boolean filterLowerCase = false;
/*********************
* REPORT STATISTICS *
*********************/
/** statistic: number of warnings encountered */
private static int reportNumWarnings = 0;
/**
* Main entry point for the Sync program.
*
* @param args
* Command-line argument strings
*/
public static void main(
final String[] args)
{
/* initialize standard output and error streams */
final Console console = System.console();
if (console == null)
{
Sync.stdout = new PrintWriter(System.out);
Sync.stderr = new PrintWriter(System.err);
}
else
{
Sync.stdout = console.writer();
Sync.stderr = console.writer();
}
/* display program title */
SyncIO.printFlush("\n" + Sync.PROGRAM_TITLE);
/* exit status code to be reported to the OS when exiting (default = 0) */
int exitCode = 0;
try
{
/* determine if this is a Windows OS */
Sync.isWindowsOperatingSystem = System.getProperty("os.name").toUpperCase(Locale.ENGLISH).contains("WINDOWS") &&
(File.separatorChar == '\\');
/* process command-line arguments and configure synchronization parameters */
processArguments(args);
SyncIO.printLog("\n" + Sync.PROGRAM_TITLE);
/* perform synchronization */
switch (Sync.syncMode)
{
case DIRECTORY:
syncDirectory();
break;
case FILE:
syncFile();
break;
}
SyncIO.print("\n\nSync is done!\n\n");
}
catch (TerminatingException e)
{
/* terminating exception thrown; proceed to abort program */
/* (this should be the only place where a TerminatingException is caught) */
exitCode = e.getExitCode();
if (exitCode != 0)
{
/* abnormal termination; SyncIO.print error message */
SyncIO.printToErr("\n\nERROR: " + e.getMessage() + "\n");
SyncIO.print("\nSync aborted.\n\n");
}
}
catch (Exception e)
{
/* catch all other exceptions; proceed to abort program */
SyncIO.printToErr("\n\nERROR: An unexpected error has occurred:\n" +
getExceptionMessage(e) + "\n");
exitCode = 1;
SyncIO.print("\nSync aborted.\n\n");
}
finally
{
/* perform clean-up before exiting */
Sync.stdout.flush();
Sync.stderr.flush();
if (Sync.log != null)
{
Sync.log.flush();
Sync.log.close();
Sync.log = null;
}
}
System.exit(exitCode);
}
/**
* Process command-line arguments and configure synchronization parameters.
*
* @param args
* Command-line argument strings
*/
private static void processArguments(
final String[] args)
{
final String howHelp = "\nTo display help, run Sync without any command-line arguments.";
/* SyncIO.print usage documentation, if no arguments */
if (args.length == 0)
{
printUsage();
throw new TerminatingException(null, 0);
}
/* check if sufficient arguments */
if (args.length < 2)
throw new TerminatingException("Insufficient arguments:\nThe source and target directories/files must be specified." + howHelp);
/* process source directory/file */
Sync.source = new File(args[args.length - 2]);
try
{
Sync.source = Sync.source.getCanonicalFile();
}
catch (Exception e)
{
throw new TerminatingException("Source \"" + Sync.source.getPath() + "\" is not a valid directory/file:\n" + getExceptionMessage(e) + howHelp);
}
/* process target directory/file */
Sync.target = new File(args[args.length - 1]);
try
{
Sync.target = Sync.target.getCanonicalFile();
}
catch (Exception e)
{
throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is not a valid directory/file:\n" + getExceptionMessage(e) + howHelp);
}
/* determine synchronization mode */
if (Sync.source.isDirectory())
{
/* source is a directory; must check that target is NOT a file */
if (Sync.target.exists() && !Sync.target.isDirectory())
throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is a file.\nFor DIRECTORY synchronization, the target (if it exists) must also be a directory." + howHelp);
/* DIRECTORY synchronization */
Sync.syncMode = Sync.SyncMode.DIRECTORY;
Sync.sourceName = SyncIO.trimTrailingSeparator(Sync.source.getPath()) + File.separatorChar;
Sync.targetName = SyncIO.trimTrailingSeparator(Sync.target.getPath()) + File.separatorChar;
}
else if (source.exists())
{
/* source is a file; must check that target is NOT a directory */
if (Sync.target.isDirectory())
throw new TerminatingException("Target \"" + Sync.target.getPath() + "\" is a directory.\nFor FILE synchronization, the target (if it exists) must also be a file." + howHelp);
/* FILE synchronization */
Sync.syncMode = Sync.SyncMode.FILE;
Sync.sourceName = SyncIO.trimTrailingSeparator(Sync.source.getPath());
Sync.targetName = SyncIO.trimTrailingSeparator(Sync.target.getPath());
}
else
{
/* source does not exist */
throw new TerminatingException("Source \"" + Sync.source.getPath() + "\" does not exist." + howHelp);
}
/* initialize filename filters */
final List<String> includeSource = new ArrayList<String>();
final List<String> excludeSource = new ArrayList<String>();
final List<String> includeTarget = new ArrayList<String>();
final List<String> excludeTarget = new ArrayList<String>();
boolean regexFilter = false;
/* process command-line switches */
for (int i = 0, n = args.length - 2; i < n; i++)
{
final String sw = args[i];
if ("--simulate".equals(sw) || "-s".equals(sw))
{
/* simulate only; do not modify target */
Sync.simulateOnly = true;
Sync.ignoreWarnings = true;
}
else if ("--ignorewarnings".equals(sw))
{
/* ignore warnings; do not pause */
Sync.ignoreWarnings = true;
}
else if ("--log".equals(sw) || "-l".equals(sw))
{
/* create log file "sync.yyyyMMdd-HHmmss.log" */
if (Sync.logName != null)
throw new TerminatingException("Switch --log can be specified at most once." + howHelp);
final String timestamp = String.format("%1$tY%1$tm%1$td-%1$tH%1$tM%1$tS", Calendar.getInstance(Locale.ENGLISH));
File f = new File("sync." + timestamp + ".log");
if (f.exists())
{
/* find an unused file name */
for (long k = 0; k < Long.MAX_VALUE; k++)
{
f = new File("sync." + timestamp + "." + k + ".log");
if (f.exists())
{
f = null;
}
else
{
/* use this unused name */
break;
}
}
if (f == null)
throw new TerminatingException("Failed to create an unused filename for log file:\nRan out of suffixes n in \"sync." +
timestamp + ".n.log\"; try specifying a filename, e.g. --log:\"record.txt\"." + howHelp);
}
try
{
Sync.logName = f.getCanonicalPath();
}
catch (Exception e)
{
throw new TerminatingException("Failed to create log file \"" + f.getPath() + "\":\n" + getExceptionMessage(e) + howHelp);
}
}
else if (sw.startsWith("--log:") || sw.startsWith("-l:"))
{
/* create log file with the specified name */
if (Sync.logName != null)
throw new TerminatingException("Switch --log can be specified at most once." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --log parameter:\nA log filename must be specified, e.g. --log:\"record.txt\"." + howHelp);
File f = new File(a);
if (f.exists())
throw new TerminatingException("Log file \"" + f.getPath() + "\" already exists:\nA nonexistent file must be specified." + howHelp);
try
{
Sync.logName = f.getCanonicalPath();
}
catch (Exception e)
{
throw new TerminatingException("Failed to create log file \"" + f.getPath() + "\":\n" + getExceptionMessage(e) + howHelp);
}
}
else if ("--norecurse".equals(sw) || "-r".equals(sw))
{
/* do not recurse into subdirectories */
Sync.noRecurse = true;
}
else if ("--noname".equals(sw) || "-n".equals(sw))
{
/* do not use filename for file-matching */
Sync.matchName = false;
}
else if ("--notime".equals(sw) || "-t".equals(sw))
{
/* do not use last-modified time for file-matching */
Sync.matchTime = false;
}
else if ("--nocrc".equals(sw)|| "-c".equals(sw))
{
/* do not use CRC-32 checksum for file-matching */
Sync.matchCrc = false;
}
else if (sw.startsWith("--time:"))
{
/* use specified time-tolerance (in milliseconds) for file-matching */
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --time parameter:\nTime-tolerance (in milliseconds) must be a nonnegative integer, e.g. --time:2000." + howHelp);
try
{
Sync.matchTimeTolerance = Long.parseLong(a);
}
catch (Exception e)
{
Sync.matchTimeTolerance = -1L;
}
if (Sync.matchTimeTolerance < 0L)
throw new TerminatingException("Invalid --time parameter \"" +
a + "\":\nTime-tolerance (in milliseconds) must be a nonnegative integer, e.g. --time:2000." + howHelp);
}
else if (sw.startsWith("--rename:"))
{
/* rename matched target files? */
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --rename parameter:\nParameter must be \"y\" or \"n\", e.g. --rename:y." + howHelp);
if ("y".equals(a))
{
Sync.defaultActionOnRenameMatched = 'Y';
}
else if ("n".equals(a))
{
Sync.defaultActionOnRenameMatched = 'N';
}
else
{
throw new TerminatingException("Invalid --rename parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --rename:y." + howHelp);
}
}
else if (sw.startsWith("--synctime:"))
{
/* synchronize time of matched target files? */
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --synctime parameter:\nParameter must be \"y\" or \"n\", e.g. --synctime:y." + howHelp);
if ("y".equals(a))
{
Sync.defaultActionOnTimeSyncMatched = 'Y';
}
else if ("n".equals(a))
{
Sync.defaultActionOnTimeSyncMatched = 'N';
}
else
{
throw new TerminatingException("Invalid --synctime parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --synctime:y." + howHelp);
}
}
else if (sw.startsWith("--overwrite:"))
{
/* overwrite existing target files? */
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --overwrite parameter:\nParameter must be \"y\" or \"n\", e.g. --overwrite:y." + howHelp);
if ("y".equals(a))
{
Sync.defaultActionOnOverwrite = 'Y';
}
else if ("n".equals(a))
{
Sync.defaultActionOnOverwrite = 'N';
}
else
{
throw new TerminatingException("Invalid --overwrite parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --overwrite:y." + howHelp);
}
}
else if (sw.startsWith("--delete:"))
{
/* delete unmatched target files/directories? */
if (syncMode != syncMode.DIRECTORY)
throw new TerminatingException("Switch --delete can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --delete parameter:\nParameter must be \"y\" or \"n\", e.g. --delete:y." + howHelp);
if ("y".equals(a))
{
Sync.defaultActionOnDeleteUnmatched = 'Y';
}
else if ("n".equals(a))
{
Sync.defaultActionOnDeleteUnmatched = 'N';
}
else
{
throw new TerminatingException("Invalid --delete parameter \"" + a + "\":\nParameter must be \"y\" or \"n\", e.g. --delete:y." + howHelp);
}
}
else if ("--force".equals(sw))
{
/* equivalent to the combination: "--rename:y --synctime:y --overwrite:y --delete:y" */
Sync.defaultActionOnRenameMatched = 'Y';
Sync.defaultActionOnTimeSyncMatched = 'Y';
Sync.defaultActionOnOverwrite = 'Y';
Sync.defaultActionOnDeleteUnmatched = 'Y';
}
else if ("--path".equals(sw) || "-p".equals(sw))
{
/* filter relative pathnames instead of filenames (e.g. "work\report\jan.txt" instead of "jan.txt") */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --path can be used for only DIRECTORY synchronization." + howHelp);
Sync.filterRelativePathname = true;
}
else if ("--lower".equals(sw) || "-w".equals(sw))
{
/* use lower case names for filtering (e.g. "HelloWorld2007.JPG" ---> "helloworld2007.jpg") */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --lower can be used for only DIRECTORY synchronization." + howHelp);
Sync.filterLowerCase = true;
}
else if ("--regex".equals(sw))
{
/* use REGEX instead of GLOB filename filters */
regexFilter = true;
}
else if (sw.startsWith("--include:") || sw.startsWith("-i:"))
{
/* include source and target files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --include can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --include parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --include:\"*.{mp3,jpg}\"." + howHelp);
includeSource.add(a);
includeTarget.add(a);
}
else if (sw.startsWith("--exclude:") || sw.startsWith("-x:"))
{
/* exclude source and target files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --exclude can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --exclude parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --exclude:\"*.{mp3,jpg}\"." + howHelp);
excludeSource.add(a);
excludeTarget.add(a);
}
else if (sw.startsWith("--includesource:") || sw.startsWith("-is:"))
{
/* include source files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --includesource can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --includesource parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --includesource:\"*.{mp3,jpg}\"." + howHelp);
includeSource.add(a);
}
else if (sw.startsWith("--excludesource:") || sw.startsWith("-xs:"))
{
/* exclude source files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --excludesource can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --excludesource parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --excludesource:\"*.{mp3,jpg}\"." + howHelp);
excludeSource.add(a);
}
else if (sw.startsWith("--includetarget:") || sw.startsWith("-it:"))
{
/* include target files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --includetarget can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --includetarget parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --includetarget:\"*.{mp3,jpg}\"." + howHelp);
includeTarget.add(a);
}
else if (sw.startsWith("--excludetarget:") || sw.startsWith("-xt:"))
{
/* exclude target files/directories with names matching specified GLOB/REGEX expression */
if (Sync.syncMode != Sync.syncMode.DIRECTORY)
throw new TerminatingException("Switch --excludetarget can be used for only DIRECTORY synchronization." + howHelp);
final String a = sw.substring(sw.indexOf(':') + 1);
if (a.isEmpty())
throw new TerminatingException("Empty --excludetarget parameter:\nA GLOB (or REGEX) expression must be specified, e.g. --excludetarget:\"*.{mp3,jpg}\"." + howHelp);
excludeTarget.add(a);
}
else
{
/* invalid switch */
throw new TerminatingException("\"" + sw + "\" is not a valid switch." + howHelp);
}
}
/* process source filename filters, if any */
if (includeSource.isEmpty())
{
if (excludeSource.isEmpty())
{
Sync.sourceFilter = null;
}
else
{
Sync.sourceFilter = new FilterNode(FilterNode.LogicType.NOR);
for (String s : excludeSource)
{
try
{
Sync.sourceFilter.addFilter