/**
 * ZipSnap 2.1
 * Copyright 2007 Zach Scrivena
 * 2007-08-26
 * zachscrivena@gmail.com
 * http://zipsnap.sourceforge.net/
 *
 * ZipSnap is a simple command-line incremental backup tool for directories.
 *
 * 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 zipsnap;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileWriter;
import java.io.PrintWriter;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.NavigableMap;
import java.util.Stack;
import java.util.TreeMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;


/**
 * ZipSnap is a simple command-line directory incremental backup tool.
 */
public class ZipSnap
{
    /*********************
    * GENERAL CONSTANTS *
    *********************/

    /** constant: program title */
    static final String programTitle =
            "ZipSnap 2.1   Copyright 2007 Zach Scrivena   2007-08-26";

    /** constant: program version */
    static final double version = 2.1;

    /** constant: size in bytes of read/write buffer (1 Mb) */
    static final int bufferSize = 1048576;

    /**
    * constant: minimum file size in bytes for compression (256 bytes);
    * files smaller than this limit will be stored without compression
    */
    static final int minSizeForCompression = 256;

    /** constant: current time */
    static final Date currentTime = new Date();

    /**********************
    * GENERAL PARAMETERS *
    **********************/

    /** parameter: archive name (as specified by the command-line argument) */
    private static String archive = null;

    /** parameter: archive directory (absolute and canonical path) */
    private static File archiveDir = null;

    /** parameter: archive directory absolute and canonical pathname (ends with a trailing separator) */
    private static String archiveDirName = null;

    /** parameter: current directory (absolute and canonical path) */
    static File currentDir = null;

    /** parameter: current directory absolute and canonical pathname (ends with a trailing separator) */
    private static String currentDirName = null;

    /** parameter: simulate only (do not actually add/restore snapshot); by default, simulation mode is not used */
    static boolean simulateOnly = false;

    /*********************************
    * COMMAND AND OPTION PARAMETERS *
    *********************************/

    /** ZipSnap commands to be executed (ADD, RESTORE, INFO) */
    private static enum Command
    {
        ADD, RESTORE, INFO;
    }

    /** parameter: ZipSnap command to be executed (ADD, RESTORE, INFO) */
    private static Command command;

    /** parameter: use file CRC-32 checksum in file matching; by default, the checksum is not used */
    static boolean useCrc = false;

    /**
    * parameter: time-tolerance in milliseconds to be used in file matching;
    * by default, a 0-millisecond time-tolerance is used
    */
    private static long timeTolerance = 0;

    /**
    * parameter: Add/extract all files/directories when adding/restoring
    * a snapshot without performing file matching first. More explicitly, when
    * adding a snapshot, add all files/directories, not just those different
    * from previously archived files/directories; when restoring a snapshot,
    * extract all files/directories, not just those different from existing
    * files/directories. By default, only files/directories that fail file
    * matching are added/extracted.
    */
    private static boolean all = false;

    /** parameter: snapshot index to be restored */
    private static int snapshotIndex = -1;

    /**
    * parameter: force addition of snapshot, even if it is identical
    * to the last snapshot; by default, an identical snapshot is not added
    */
    private static boolean forceAdd = false;

    /**
    * parameter: default action on overwriting existing files;
    * by default, the user is prompted on whether to overwrite existing files
    */
    static char defaultActionOnOverwrite = '\0';

    /**
    * parameter: default action on deleting existing files;
    * by default, the user is prompted on whether to delete existing files
    */
    static char defaultActionOnDelete = '\0';

    /** archive volume types (ZIP, JAR, LIST) */
    private static enum VolumeType
    {
        ZIP, JAR, LIST;
    }

    /** parameter: archive volume type (ZIP, JAR, LIST); by default, the ZIP volume type is used */
    private static VolumeType volumeType = VolumeType.ZIP;

    /** parameter: compression level (0 = min, 9 = max); default compression level is 9 */
    static int compressionLevel = 9;

    /** parameter: search paths for volume files when restoring a snapshot */
    static final List<File> searchPaths = new ArrayList<File>();

    /** filter types (GLOB, REGEX) */
    private static enum FilterType
    {
        GLOB, REGEX;
    }

    /** parameter: filter type (GLOB, REGEX) */
    private static FilterType filterType;

    /** parameter: filter string for matching filenames */
    private static String filterString = null;

    /**
    * parameter: match filter string against full (relative) pathnames;
    * by default, only the name of the file/directory is used for matching
    */
    static boolean filterFullPathname = false;

    /** parameter: filter pattern for matching filenames */
    static Pattern filterPattern = null;


    /**
    * Main entry point for the ZipSnap program.
    *
    * @param args
    *     Command-line argument strings
    */
    public static void main(
            final String[] args)
    {
        /* print program title */
        System.out.print("\n" + ZipSnap.programTitle + "\n");

        /* create error and warning handler */
        ErrorWarningHandler.setPauseOnWarning(true); /* pause on warning by default */

        try
        {
            /* process command-line arguments */
            processArguments(args);

            /* construct Java regex Pattern, if necessary */
            if (ZipSnap.filterString != null)
            {
                if (ZipSnap.filterType == ZipSnap.FilterType.GLOB)
                {
                    ZipSnap.filterPattern = getGlobFilterPattern();
                }
                else if (ZipSnap.filterType == ZipSnap.FilterType.REGEX)
                {
                    ZipSnap.filterPattern = getRegexFilterPattern();
                }
            }

            /* execute requested command */
            if (ZipSnap.command == ZipSnap.Command.ADD)
            {
                addSnapshotToArchive();
            }
            else if (ZipSnap.command == ZipSnap.Command.RESTORE)
            {
                restoreSnapshotFromArchive();
            }
            else if (ZipSnap.command == ZipSnap.command.INFO)
            {
                displayArchiveInfo();
            }

            System.out.print("\n\nZipSnap is done!");

            final int numWarnings = ErrorWarningHandler.getNumWarnings();

            if (numWarnings > 0)
                System.out.print("\n(" + numWarnings + " " + ((numWarnings == 1) ? "warning" : "warnings") + " encountered)");

            System.out.print("\n\n");
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("An unexpected error has occurred:\n" +
                    ErrorWarningHandler.getExceptionMessage(e));
        }

        System.exit(0);
    }


    /**
    * Process command-line arguments.
    *
    * @param args
    *     Command-line argument strings
    */
    private static void processArguments(
            final String[] args)
    {
        /* error message */
        String err = null;

        CheckArguments:
        do
        {
            /* run one iteration */

            if (args.length == 0)
            {
                /* print usage help */
                printUsage();
                System.exit(0);
                break CheckArguments;
            }
            else if (args.length < 2)
            {
                err = "Insufficient arguments:\nA command and a ZipSnap archive directory must be specified.";
                break CheckArguments;
            }

            /* archive name is the last argument */
            ZipSnap.archive = args[args.length - 1];

            /* command is the first argument */
            final String command = args[0];

            if ("a".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.ADD;
            }
            else if ("r".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.RESTORE;
            }
            else if ("i".equals(command))
            {
                ZipSnap.command = ZipSnap.Command.INFO;
            }
            else
            {
                err = "Invalid command \"" + command +
                        "\" specified:\nCommand must be \"a\", \"r\", or \"i\".";
                break CheckArguments;
            }

            /* flags for volume type */
            int swZip = 0;
            int swJar = 0;
            int swList = 0;

            /* process command-specific switches */
            ProcessNextSwitch:
            for (int i = 1; i < args.length - 1; i++)
            {
                final String sw = args[i];


                /******************************************
                * (1) SWITCHES FOR ADD, RESTORE, OR INFO *
                ******************************************/
                if ((ZipSnap.command == ZipSnap.Command.ADD) ||
                        (ZipSnap.command == ZipSnap.Command.RESTORE) ||
                        (ZipSnap.command == ZipSnap.Command.INFO))
                {
                    if ("-i".equals(sw) || "--ignorewarnings".equals(sw))
                    {
                        /* ignore warnings */
                        ErrorWarningHandler.setPauseOnWarning(false);
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-f:") || sw.startsWith("--filter:"))
                    {
                        /* glob filter for file/directory name */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --filter parameter specified:\nGlob filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.GLOB;
                        ZipSnap.filterFullPathname = false;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-F:") || sw.startsWith("--FILTER:"))
                    {
                        /* glob filter for full (relative) file/directory pathname */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --FILTER parameter specified:\nGlob filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.GLOB;
                        ZipSnap.filterFullPathname = true;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-e:") || sw.startsWith("--filterregex:"))
                    {
                        /* regex filter for file/directory name */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --filterregex parameter specified:\nRegex filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.REGEX;
                        ZipSnap.filterFullPathname = false;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-E:") || sw.startsWith("--FILTERREGEX:"))
                    {
                        /* regex filter for full (relative) file/directory pathname */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --FILTERREGEX parameter specified:\nRegex filter string must be nonempty.";
                            break CheckArguments;
                        }

                        ZipSnap.filterString = a;
                        ZipSnap.filterType = ZipSnap.FilterType.REGEX;
                        ZipSnap.filterFullPathname = true;
                        continue ProcessNextSwitch;
                    }
                }


                /************************************
                * (2) SWITCHES FOR ADD, OR RESTORE *
                ************************************/
                if ((ZipSnap.command == ZipSnap.Command.ADD) ||
                        (ZipSnap.command == ZipSnap.Command.RESTORE))
                {
                    if ("-s".equals(sw) || "--simulate".equals(sw))
                    {
                        /* simulate only; do not actually add/restore snapshot */
                        ZipSnap.simulateOnly = true;
                        ErrorWarningHandler.setPauseOnWarning(false);
                        continue ProcessNextSwitch;
                    }
                    else if ("--all".equals(sw))
                    {
                        /* add/restore all files/directories without performing matching */
                        ZipSnap.all = true;
                        continue ProcessNextSwitch;
                    }
                    else if ("-c".equals(sw) || "--crc".equals(sw))
                    {
                        /* use file/directory CRC-32 checksum for matching */
                        ZipSnap.useCrc = true;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-t:") || sw.startsWith("--time:"))
                    {
                        /* use specified time-tolerance (in milliseconds) for matching */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --time parameter specified:\nTime-tolerance (in milliseconds) must be a nonnegative integer.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.timeTolerance = Long.parseLong(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.timeTolerance = -1;
                        }

                        if (ZipSnap.timeTolerance < 0)
                        {
                            err = "Invalid --time parameter \"" + a +
                                    "\" specified:\nTime-tolerance (in milliseconds) must be a nonnegative integer.";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if ("-l".equals(sw) || "--list".equals(sw))
                    {
                        /* create a list of files/directories to be added/extracted */
                        swList = 1;
                        continue ProcessNextSwitch;
                    }
                }


                /************************
                * (3) SWITCHES FOR ADD *
                ************************/
                if (ZipSnap.command == ZipSnap.Command.ADD)
                {
                    if ("--forceadd".equals(sw))
                    {
                        /* force addition of snapshot, even if identical to the last snapshot */
                        ZipSnap.forceAdd = true;
                        continue ProcessNextSwitch;
                    }
                    else if ("-z".equals(sw) || "--zip".equals(sw))
                    {
                        /* add files/directories to a ZIP volume */
                        swZip = 1;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-z:") || sw.startsWith("--zip:"))
                    {
                        /* add files/directories to a ZIP volume with specified compression level */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --zip parameter specified:\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.compressionLevel = Integer.parseInt(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.compressionLevel = -1;
                        }

                        if ((ZipSnap.compressionLevel < 0) || (ZipSnap.compressionLevel > 9))
                        {
                            err = "Invalid --zip parameter \"" + a +
                                    "\" specified.\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        swZip = 1;
                        continue ProcessNextSwitch;
                    }
                    else if ("-j".equals(sw) || "--jar".equals(sw))
                    {
                        /* add files/directories to a JAR volume */
                        swJar = 1;
                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-j:") || sw.startsWith("--jar:"))
                    {
                        /* add files/directories to a JAR volume with specified compression level */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --jar parameter specified:\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.compressionLevel = Integer.parseInt(a);
                        }
                        catch (Exception e)
                        {
                            ZipSnap.compressionLevel = -1;
                        }

                        if ((ZipSnap.compressionLevel < 0) || (ZipSnap.compressionLevel > 9))
                        {
                            err = "Invalid --jar parameter \"" + a +
                                    "\" specified.\nCompression level must be an integer 0-9.";
                            break CheckArguments;
                        }

                        swJar = 1;
                        continue ProcessNextSwitch;
                    }
                }


                /****************************
                * (4) SWITCHES FOR RESTORE *
                ****************************/
                if (ZipSnap.command == ZipSnap.Command.RESTORE)
                {
                    if (sw.startsWith("--snapshot:"))
                    {
                        /* snapshot number to be restored */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --snapshot parameter specified:\nSnapshot number must be a positive integer.";
                            break CheckArguments;
                        }

                        try
                        {
                            ZipSnap.snapshotIndex = Integer.parseInt(a) - 1; /* convert "number" to "index" */
                        }
                        catch (Exception e)
                        {
                            ZipSnap.snapshotIndex = -1;
                        }

                        if (ZipSnap.snapshotIndex < 0)
                        {
                            err = "Invalid --snapshot parameter \"" + a +
                                    "\" specified:\nSnapshot number must be a positive integer.";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-o:") || sw.startsWith("--overwrite:"))
                    {
                        /* overwrite existing files/directories? */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --overwrite parameter specified:\nOverwrite parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        if ("y".equals(a))
                        {
                            ZipSnap.defaultActionOnOverwrite = 'Y';
                        }
                        else if ("n".equals(a))
                        {
                            ZipSnap.defaultActionOnOverwrite = 'N';
                        }
                        else
                        {
                            err = "Invalid --overwrite parameter \"" + a +
                                    "\" specified:\nOverwrite parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-d:") || sw.startsWith("--delete:"))
                    {
                        /* delete unmatched existing files/directories? */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --delete parameter specified:\nDelete parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        if ("y".equals(a))
                        {
                            ZipSnap.defaultActionOnDelete = 'Y';
                        }
                        else if ("n".equals(a))
                        {
                            ZipSnap.defaultActionOnDelete = 'N';
                        }
                        else
                        {
                            err = "Invalid --delete parameter \"" + a +
                                    "\" specified:\nDelete parameter must be \"y\" or \"n\".";
                            break CheckArguments;
                        }

                        continue ProcessNextSwitch;
                    }
                    else if (sw.startsWith("-p:") || sw.startsWith("--path:"))
                    {
                        /* additional search path for volume files */
                        final String a = sw.substring(sw.indexOf(':') + 1);

                        if (a.isEmpty())
                        {
                            err = "Empty --path parameter specified:\nPath parameter must be an existing directory.";
                            break CheckArguments;
                        }

                        final File dir;

                        try
                        {
                            dir = (new File(a)).getCanonicalFile();
                        }
                        catch (Exception e)
                        {
                            err = "Invalid --path parameter specified:\nCannot get absolute path of directory \"" +
                                    a + "\":\n" + ErrorWarningHandler.getExceptionMessage(e);
                            break CheckArguments;
                        }

                        if (!dir.exists())
                        {
                            err = "Invalid --path parameter specified:\nDirectory \"" +
                                    a + "\" does not exist.";
                            break CheckArguments;
                        }

                        if (!dir.isDirectory())
                        {
                            err = "Invalid --path parameter specified:\n\"" +
                                    a + "\" is not a directory; could it be a file?";
                            break CheckArguments;
                        }

                        ZipSnap.searchPaths.add(dir);
                        continue ProcessNextSwitch;
                    }
                }


                /**********************
                * (5) INVALID SWITCH *
                **********************/
                /* MUST BE LAST BLOCK */
                err = "\"" + sw + "\" is not a valid switch for command \"" + command + "\".";
                break CheckArguments;
            }

            if (ZipSnap.all && (ZipSnap.timeTolerance > 0))
            {
                err = "Switches --all and --time cannot be used together.";
                break CheckArguments;
            }

            if (ZipSnap.all && ZipSnap.useCrc)
            {
                err = "Switches --all and --crc cannot be used together.";
                break CheckArguments;
            }

            if (ZipSnap.all && ZipSnap.forceAdd)
            {
                err = "Switches --all and --forceadd cannot be used together.";
                break CheckArguments;
            }

            if (swZip + swJar + swList > 1)
            {
                err = "Only one of the three switches --zip, --jar, and --list can be used.";
                break CheckArguments;
            }

            /* volume type */
            if (swZip > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.ZIP;

            if (swJar > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.JAR;

            if (swList > 0)
                ZipSnap.volumeType = ZipSnap.VolumeType.LIST;
        }
        while (false);

        /* invalid command-line arguments encountered */
        if (err != null)
            ErrorWarningHandler.reportErrorAndExit(err +
                    "\nTo display help, run ZipSnap without any command-line arguments.\n");
    }


    /**
    * Add a snapshot of the current directory to the ZipSnap archive.
    */
    private static void addSnapshotToArchive()
    {
        System.out.print("\nADD SNAPSHOT TO ARCHIVE" +
                (ZipSnap.simulateOnly ? " (SIMULATION MODE)" : "") + "\n");

        /* get current time */
        final String timestamp = (new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.ENGLISH)).format(ZipSnap.currentTime);


        /*******************************
        * (1) CHECK CURRENT DIRECTORY *
        *******************************/

        try
        {
            /* get absolute and canonical pathname of the current directory */
            ZipSnap.currentDir = (new File("")).getCanonicalFile();
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to get absolute pathname of the current directory:\n" +
                    ErrorWarningHandler.getExceptionMessage(e));
        }

        ZipSnap.currentDirName = FileIO.trimTrailingSeparator(ZipSnap.currentDir.getPath()) + File.separatorChar;

        System.out.print("\nCurrent directory  : \"" + ZipSnap.currentDirName + "\"");

        /* check if current directory is valid */
        if (!ZipSnap.currentDir.exists())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" does not exist.");

        if (!ZipSnap.currentDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" is not a directory; could it be a file?");

        if (!ZipSnap.currentDir.canRead())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" cannot be read.");


        /***************************************
        * (2) CHECK ZIPSNAP ARCHIVE DIRECTORY *
        ***************************************/

        try
        {
            /* get absolute and canonical pathname of the archive directory */
            ZipSnap.archiveDir = (new File(ZipSnap.archive)).getCanonicalFile();
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to get absolute pathname of ZipSnap archive directory \"" +
                    ZipSnap.archive + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        ZipSnap.archiveDirName = FileIO.trimTrailingSeparator(ZipSnap.archiveDir.getPath()) + File.separatorChar;

        System.out.print("\nZipSnap archive    : \"" + ZipSnap.archiveDirName + "\"");

        /* check that archive directory is not a subdirectory of the current directory */
        if (ZipSnap.archiveDirName.startsWith(ZipSnap.currentDirName))
            ErrorWarningHandler.reportWarning("The ZipSnap archive directory \"" + ZipSnap.archiveDirName +
                    "\" should not be a subdirectory of the current directory \"" + ZipSnap.currentDirName + "\".");


        /****************************************
        * (3) READ CATALOG FOR ZIPSNAP ARCHIVE *
        ****************************************/

        /* File object representing the latest catalog (txt/txt.zip/txt.jar) */
        File catalogFile = null;

        /* check if ZipSnap archive directory is valid */
        if (ZipSnap.archiveDir.exists())
        {
            /* archive directory already exists */
            if (!ZipSnap.archiveDir.isDirectory())
                ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" is not a directory; could it be a file?");

            if (!ZipSnap.archiveDir.canRead())
                ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" cannot be read.");

            /* get the latest catalog in the ZipSnap archive directory */
            catalogFile = getLatestCatalog();
        }

        /* catalog for the ZipSnap archive */
        Catalog catalog = null;

        if (catalogFile == null)
        {
            /* no existing catalog found; this is a new archive */
            System.out.print("\n - Latest catalog  : (none found)");
            System.out.flush();

            /* initialize a new empty catalog */
            catalog = new Catalog();
        }
        else
        {
            /* found an existing catalog; proceed to read it */
            System.out.print("\n - Latest catalog  : \"" + catalogFile.getName() + "\"");
            System.out.flush();

            /* read existing catalog file */
            catalog = new Catalog(catalogFile);
        }

        /* display this snapshot number and timestamp */
        ZipSnap.snapshotIndex = catalog.snapshots.size();
        System.out.print("\n - This snapshot   : " + timestamp + "." + (ZipSnap.snapshotIndex + 1));
        System.out.flush();

        /* display filter, if defined */
        if (ZipSnap.filterPattern != null)
        {
            if (ZipSnap.filterType == ZipSnap.FilterType.GLOB)
            {
                System.out.print("\nGlob filter        : \"" + ZipSnap.filterString + "\"");
            }
            else if (ZipSnap.filterType == ZipSnap.FilterType.REGEX)
            {
                System.out.print("\nRegex filter       : \"" + ZipSnap.filterString + "\"");
            }

            if (ZipSnap.filterFullPathname)
            {
                System.out.print(" (match full relative pathname)");
            }
            else
            {
                System.out.print(" (match name only)");
            }

            System.out.flush();
        }


        /***************************************
        * (4) SCAN CURRENT DIRECTORY CONTENTS *
        ***************************************/

        /* get filtered contents of the current directory                          */
        /* (currentDirContents is sorted in lexicographic order of the name field) */
        final List<FileUnit> currentDirContents = getCurrentDirContents();

        if (currentDirContents.isEmpty())
            ErrorWarningHandler.reportWarning("The current directory is empty.");


        /***********************************************************************************
        * (5) DETERMINE NEW/MODIFIED FILES/DIRECTORIES TO BE ADDED TO THE ZIPSNAP ARCHIVE *
        ***********************************************************************************/

        /* determine new/modified files/directories to be added to the ZipSnap archive */
        final List<FileUnit> addFiles = new ArrayList<FileUnit>();

        if (ZipSnap.all || (catalogFile == null))
        {
            /* add all files/directories without matching previously archived files/directories */
            System.out.print("\n\nAdding all " + currentDirContents.size() +
                    " files/directories in this snapshot.");
            System.out.flush();

            addFiles.addAll(currentDirContents);
        }
        else
        {
            /* match files/directories in the current directory */
            /* against previously archived files/directories    */
            System.out.print("\n\nMatching current directory contents against the catalog..." +
                    (ZipSnap.useCrc ? "\n(file checksum computations can be time-intensive)" : ""));
            System.out.flush();

            /* count number of matched files/directories that belong to the last snapshot */
            int numLastSnapshotFilesMatched = 0;

            for (FileUnit u : currentDirContents)
            {
                /* if match found, update snapshot number of cataloged item   */
                /* if match not found, add item to new/modified list of items */

                /* search for a file/directory in the catalog with the same name */
                final int i = Collections.binarySearch(catalog.files, u);

                if (i < 0)
                {
                    /* this file/directory is not cataloged, so we add it to the new volume */
                    addFiles.add(u);
                }
                else
                {
                    /* this file/directory is cataloged, so we look for the best match */

                    /* index of the best match */
                    int bestMatch = -1;

                    /* latest snapshot index of the best match */
                    int bestMatchLatestSnapshot = -1;

                    /* match files upwards */
                    MatchNextFileUp:
                    for (int j = i - 1; j >= 0; j--)
                    {
                        final FileUnit c = catalog.files.get(j);

                        if (!u.name.equals(c.name))
                            break MatchNextFileUp;

                        /* name is already matched, next match size, time, and (checksum) */
                        if ((u.size != c.size) ||
                                (Math.abs(u.time - c.time) > ZipSnap.timeTolerance) ||
                                (ZipSnap.useCrc && (u.getCrc() != c.getCrc())))
                        {
                            continue MatchNextFileUp;
                        }

                        /* match found: proceed to compare latest snapshot indices, if necessary */
                        final int latestSnapshot = Collections.max(c.snapshots);

                        if ((bestMatch < 0) ||
                                (latestSnapshot > bestMatchLatestSnapshot))
                        {
                            bestMatch = j;
                            bestMatchLatestSnapshot = latestSnapshot;
                        }
                    }

                    /* match files downwards */
                    MatchNextFileDown:
                    for (int j = i; j < catalog.files.size(); j++)
                    {
                        final FileUnit c = catalog.files.get(j);

                        if (!u.name.equals(c.name))
                            break MatchNextFileDown;

                        /* name is already matched, next match size, time, and (checksum) */
                        if ((u.size != c.size) ||
                                (Math.abs(u.time - c.time) > ZipSnap.timeTolerance) ||
                                (ZipSnap.useCrc && (u.getCrc() != c.getCrc())))
                        {
                            continue MatchNextFileDown;
                        }

                        /* match found: proceed to compare latest snapshot indices, if necessary */
                        final int latestSnapshot = Collections.max(c.snapshots);

                        if ((bestMatch < 0) ||
                                (latestSnapshot >= bestMatchLatestSnapshot))
                        {
                            bestMatch = j;
                            bestMatchLatestSnapshot = latestSnapshot;
                        }
                    }

                    /* add best match */
                    if (bestMatch >= 0)
                    {
                        /* found a valid match */
                        final List<Integer> snapshots = catalog.files.get(bestMatch).snapshots;

                        /* add snapshot to previously archived file/directory */
                        snapshots.add(ZipSnap.snapshotIndex);

                        /* check if this matched file/directory belongs to the last snapshot */
                        if (snapshots.contains(ZipSnap.snapshotIndex - 1))
                            numLastSnapshotFilesMatched++;
                    }
                    else
                    {
                        /* no valid match found; so we add this file/directory to the new volume */
                        addFiles.add(u);
                    }
                }
            }

            System.out.print("\n\nThis snapshot contains " + (currentDirContents.size() - addFiles.size()) +
                " previously archived files/directories,\nand " + addFiles.size() +
                " new/modified files/directories.");
            System.out.flush();

            /* check if this snapshot is identical to the last snapshot */
            if (addFiles.isEmpty())
            {
                /* get size of (unfiltered) last snapshot */
                int numLastSnapshotFiles = 0;

                for (FileUnit u : catalog.files)
                {
                    if (u.snapshots.contains(ZipSnap.snapshotIndex - 1))
                        numLastSnapshotFiles++;
                }

                if (numLastSnapshotFilesMatched == numLastSnapshotFiles)
                {
                    /* this snapshot is identical to the (unfiltered) last snapshot */
                    if (ZipSnap.forceAdd)
                    {
                        System.out.print("\n\nThis snapshot is identical to the last snapshot, but will still be added.");
                    }
                    else
                    {
                        System.out.print("\n\nThis snapshot is identical to the last snapshot, and will not be added.");
                        return;
                    }
                }
            }
        }


        /**********************************************************
        * (6) ADD NEW/MODIFIED FILES/DIRECTORIES TO A NEW VOLUME *
        **********************************************************/

        /* add new/modified files/directories to a new volume */
        final List<FileUnit> addedFiles = new ArrayList<FileUnit>();

        /* create new volume to contain new/modified files */
        final String volumeName = ZipSnap.archiveDir.getName() + "." + timestamp +
                "." + (ZipSnap.snapshotIndex + 1);

        if (ZipSnap.volumeType == ZipSnap.VolumeType.ZIP)
        {
            CompressionIO.zipFiles(new File(ZipSnap.archiveDir, volumeName + ".zip"), addFiles, addedFiles);
        }
        else if (ZipSnap.volumeType == ZipSnap.VolumeType.JAR)
        {
            CompressionIO.jarFiles(new File(ZipSnap.archiveDir, volumeName + ".jar"), addFiles, addedFiles);
        }
        else if (ZipSnap.volumeType == ZipSnap.VolumeType.LIST)
        {
            listFiles(new File(ZipSnap.archiveDir, volumeName + ".list"), addFiles, addedFiles);
        }


        /******************************************
        * (7) UPDATE CATALOG FOR ZIPSNAP ARCHIVE *
        ******************************************/

        /* add new snapshot to the catalog */
        final SnapshotUnit s = new SnapshotUnit(timestamp);
        catalog.snapshots.add(ZipSnap.snapshotIndex, s);

        /* add new/modified files/directories to the catalog */
        for (FileUnit u : addedFiles)
        {
            /* add this snapshot index to the newly archived file/directory */
            u.snapshots.add(ZipSnap.snapshotIndex);

            /* add newly archived file/directory to catalog */
            catalog.files.add(u);
        }

        /* sort files/directories in catalog */
        catalog.sort();


        /*********************************************
        * (8) WRITE NEW CATALOG FOR ZIPSNAP ARCHIVE *
        *********************************************/

        /* write new catalog */
        final String newCatalogName = ZipSnap.archiveDir.getName() + "." + timestamp +
                "." + (ZipSnap.snapshotIndex + 1);

        if (ZipSnap.volumeType == ZipSnap.VolumeType.ZIP)
        {
            catalog.writeToFile(new File(ZipSnap.archiveDir, newCatalogName + ".txt.zip"));
        }
        else if (ZipSnap.volumeType == ZipSnap.VolumeType.JAR)
        {
            catalog.writeToFile(new File(ZipSnap.archiveDir, newCatalogName + ".txt.jar"));
        }
        else if (ZipSnap.volumeType == ZipSnap.VolumeType.LIST)
        {
            catalog.writeToFile(new File(ZipSnap.archiveDir, newCatalogName + ".txt"));
        }
    }


    /**
    * Restore a snapshot to the current directory from the ZipSnap archive.
    */
    private static void restoreSnapshotFromArchive()
    {
        System.out.print("\nRESTORE SNAPSHOT FROM ARCHIVE" +
                (ZipSnap.simulateOnly ? " (SIMULATION MODE)" : "") + "\n");


        /*******************************
        * (1) CHECK CURRENT DIRECTORY *
        *******************************/

        try
        {
            /* get absolute, canonical path of the current directory */
            ZipSnap.currentDir = (new File("")).getCanonicalFile();
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to get absolute pathname of the current directory:\n" +
                    ErrorWarningHandler.getExceptionMessage(e));
        }

        ZipSnap.currentDirName = FileIO.trimTrailingSeparator(ZipSnap.currentDir.getPath()) + File.separatorChar;

        System.out.print("\nCurrent directory  : \"" + ZipSnap.currentDirName + "\"");

        /* check if current directory is valid */
        if (!ZipSnap.currentDir.exists())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" does not exist.");

        if (!ZipSnap.currentDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" is not a directory; could it be a file?");

        if (!ZipSnap.currentDir.canRead())
            ErrorWarningHandler.reportErrorAndExit("The current directory \"" + ZipSnap.currentDirName + "\" cannot be read.");


        /***************************************
        * (2) CHECK ZIPSNAP ARCHIVE DIRECTORY *
        ***************************************/

        try
        {
            /* get absolute, canonical path of the archive directory */
            ZipSnap.archiveDir = (new File(ZipSnap.archive)).getCanonicalFile();
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to get absolute pathname of ZipSnap archive directory \"" +
                    ZipSnap.archive + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        ZipSnap.archiveDirName = FileIO.trimTrailingSeparator(ZipSnap.archiveDir.getPath()) + File.separatorChar;

        System.out.print("\nZipSnap archive    : \"" + ZipSnap.archiveDirName + "\"");

        /* check if ZipSnap archive directory is valid */
        if (!ZipSnap.archiveDir.exists())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" does not exist.");

        if (!ZipSnap.archiveDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" is not a directory; could it be a file?");

        if (!ZipSnap.archiveDir.canRead())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" cannot be read.");

        /* check that archive directory is not a subdirectory of the current directory */
        if (ZipSnap.archiveDirName.startsWith(ZipSnap.currentDirName))
            ErrorWarningHandler.reportWarning("The ZipSnap archive directory \"" + ZipSnap.archiveDirName +
                    "\" should not be a subdirectory of the current directory \"" +
                    ZipSnap.currentDirName + "\".");

        /* display additional search paths for volume files, if specified */
        if (!ZipSnap.searchPaths.isEmpty())
        {
            System.out.print("\n - Additional search paths for volume files:");

            int i = 0;
            for (File dir : ZipSnap.searchPaths)
                System.out.print("\n   [" + (++i) + "] \"" + dir.getPath() + "\"");
        }

        /* add archive directory to search paths for volume files */
        ZipSnap.searchPaths.add(ZipSnap.archiveDir);


        /****************************************
        * (3) READ CATALOG FOR ZIPSNAP ARCHIVE *
        ****************************************/

        /* get the latest catalog in the ZipSnap archive directory */
        final File catalogFile = getLatestCatalog();

        /* check if a catalog is found */
        if (catalogFile == null)
            ErrorWarningHandler.reportErrorAndExit("Unable to find a catalog in the specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\".");

        /* found a catalog; proceed to read it */
        System.out.print("\n - Latest catalog  : \"" + catalogFile.getName() + "\"");
        System.out.flush();

        /* read catalog for the ZipSnap archive */
        final Catalog catalog = new Catalog(catalogFile);

        /* check that catalog contains at least one snapshot */
        if (catalog.snapshots.isEmpty())
            ErrorWarningHandler.reportErrorAndExit("No snapshots found in the catalog \"" + catalogFile.getName() + "\" for this ZipSnap archive.");

        /* pick the latest snapshot, if not specified */
        if (ZipSnap.snapshotIndex < 0)
            ZipSnap.snapshotIndex = catalog.snapshots.size() - 1;

        /* display specified snapshot number and timestamp */
        System.out.print("\n - Restore snapshot: " +
                catalog.snapshots.get(ZipSnap.snapshotIndex).time + "." +
                (ZipSnap.snapshotIndex + 1));
        System.out.flush();

        /* display filter, if defined */
        if (ZipSnap.filterPattern != null)
        {
            if (ZipSnap.filterType == ZipSnap.FilterType.GLOB)
            {
                System.out.print("\nGlob filter        : \"" + ZipSnap.filterString + "\"");
            }
            else if (ZipSnap.filterType == ZipSnap.FilterType.REGEX)
            {
                System.out.print("\nRegex filter       : \"" + ZipSnap.filterString + "\"");
            }

            if (ZipSnap.filterFullPathname)
            {
                System.out.print(" (match full relative pathname)");
            }
            else
            {
                System.out.print(" (match name only)");
            }

            System.out.flush();
        }

        /* get filtered files/directories that belong to the specified snapshot */
        final List<FileUnit> snapshotFiles = new ArrayList<FileUnit>();

        for (FileUnit u : catalog.files)
        {
            if (u.snapshots.contains(ZipSnap.snapshotIndex) && u.matchesFilter())
                snapshotFiles.add(u);
        }

        System.out.print("\n\nThis snapshot contains " + snapshotFiles.size() + " files/directories.");
        System.out.flush();


        /***************************************
        * (4) SCAN CURRENT DIRECTORY CONTENTS *
        ***************************************/

        /* get filtered contents of the current directory                          */
        /* (currentDirContents is sorted in lexicographic order of the name field) */
        final List<FileUnit> currentDirContents = getCurrentDirContents();


        /****************************************************************************
        * (5) DETERMINE FILES/DIRECTORIES TO BE DELETED FROM THE CURRENT DIRECTORY *
        ****************************************************************************/

        /* determine files/directories to be deleted from the current directory */
        /* (initialized to ALL files/directories in the current directory)      */
        final List<FileUnit> deleteFiles = new ArrayList<FileUnit>(currentDirContents);

        /* unmark file/directory to be deleted from the current directory */
        deleteFiles.removeAll(snapshotFiles);

        /* reverse sort the files/directories to be deleted */
        Collections.reverse(deleteFiles);


        /*****************************************************************************
        * (6) DETERMINE FILES/DIRECTORIES TO BE EXTRACTED FROM THE ZIPSNAP ARCHIVE, *
        *     AND THE VOLUMES FROM WHICH TO EXTRACT THEM                            *
        *****************************************************************************/

        /* determine files/directories to be extracted from the ZipSnap archive */
        final List<List<FileUnit>> extractFiles =
                new ArrayList<List<FileUnit>>(ZipSnap.snapshotIndex + 1);

        int numExtractFiles = 0;

        for (int i = 0; i <= ZipSnap.snapshotIndex; i++)
            extractFiles.add(i, new ArrayList<FileUnit>());

        if (ZipSnap.all)
        {
            /* extract all files/directories that belong to this snapshot,      */
            /* without matching files/directories against the current directory */
            for (FileUnit u : snapshotFiles)
            {
                extractFiles.get(u.snapshots.get(0)).add(u);
                numExtractFiles++;
            }
        }
        else
        {
            /* match files/directories of this snapshot against */
            /* the files/directories in the current directory   */
            System.out.print("\n\nMatching snapshot files/directories against current directory contents..." +
                    (ZipSnap.useCrc ? "\n(file checksum computations can be time-intensive)" : ""));
            System.out.flush();

            for (FileUnit u : snapshotFiles)
            {
                /* if match not found, mark the item for extraction */

                /* file/directory from the current directory */
                final int i = Collections.binarySearch(currentDirContents, u);
                final FileUnit c = (i >= 0) ? currentDirContents.get(i) : null;

                if ((c == null) ||
                        (u.size != c.size) ||
                        (Math.abs(u.time - c.time) > ZipSnap.timeTolerance) ||
                        (ZipSnap.useCrc && (u.getCrc() != c.getCrc())))
                {
                    /* no matching snapshot item found in current directory, */
                    /* so we mark file/directory for extraction              */
                    extractFiles.get(u.snapshots.get(0)).add(u);
                    numExtractFiles++;
                }
            }
        }

        System.out.print("\n\nZipSnap will extract " + numExtractFiles +
                " files/directories from the archive,\nand delete " +
                deleteFiles.size() + " files/directories from the current directory.");
        System.out.flush();


        /***************************************************************
        * (7) EXTRACT FILES/DIRECTORIES FROM THEIR RESPECTIVE VOLUMES *
        ***************************************************************/

        /* candidate volume file extensions */
        final String volumeFileExtensions[] = {".zip", ".jar"};

        /* successfully extracted files/directories */
        final List<FileUnit> extractedFiles = new ArrayList<FileUnit>();

        /* extract files/directories from their respective volumes */
        int numExtractedFiles = 0;

        ExtractNextVolume:
        for (int index = 0; index <= ZipSnap.snapshotIndex; index++)
        {
            final List<FileUnit> extractFilesVolume = extractFiles.get(index);

            if (extractFilesVolume.isEmpty())
                continue ExtractNextVolume;

            final String volumeName = ZipSnap.archiveDir.getName() + "." +
                    catalog.snapshots.get(index).time + "." + (index + 1);

            if (ZipSnap.volumeType == ZipSnap.VolumeType.LIST)
            {
                /* just list the files/directories to be extracted */
                final File listFile = new File(ZipSnap.currentDir, volumeName + ".list");
                numExtractedFiles += listFiles(listFile, extractFilesVolume, extractedFiles);
                continue ExtractNextVolume;
            }

            /* select the volume file with the latest last-modified time */
            File volumeFile = null;

            for (File dir : ZipSnap.searchPaths)
            {
                for (String ext : volumeFileExtensions)
                {
                    final File f = new File(dir, volumeName + ext);

                    if (f.exists() && !f.isDirectory() &&
                            ((volumeFile == null) ||
                            (f.lastModified() > volumeFile.lastModified())))
                    {
                        volumeFile = f;
                    }
                }
            }

            /* extract files from the selected volume */
            if (volumeFile == null)
            {
                ErrorWarningHandler.reportWarning("Unable to find volume \"" + volumeName +
                        ".{zip,jar}\";\nthe " + extractFilesVolume.size() +
                        " files/directories from this volume will not be extracted.");
                continue ExtractNextVolume;
            }

            if (volumeFile.getName().endsWith(".zip"))
            {
                numExtractedFiles += CompressionIO.unZipFiles(volumeFile, extractFilesVolume, extractedFiles);
            }
            else if (volumeFile.getName().endsWith(".jar"))
            {
                numExtractedFiles += CompressionIO.unJarFiles(volumeFile, extractFilesVolume, extractedFiles);
            }
        }

        if (numExtractFiles > 0)
        {
            if (ZipSnap.volumeType == ZipSnap.VolumeType.LIST)
            {
                System.out.print("\n\nZipSnap has listed " + numExtractedFiles +
                        " out of " + numExtractFiles + " files/directories from the archive.");
            }
            else
            {
                System.out.print("\n\nZipSnap has extracted " + numExtractedFiles +
                        " out of " + numExtractFiles + " files/directories from the archive.");
            }

            System.out.flush();
        }


        /*********************************************************
        * (8) DELETE FILES/DIRECTORIES IN THE CURRENT DIRECTORY *
        *********************************************************/

        /* successfully deleted files/directories */
        final List<FileUnit> deletedFiles = new ArrayList<FileUnit>();

        /* delete files/directories in the current directory */
        if (!deleteFiles.isEmpty())
            deleteFiles(deleteFiles, deletedFiles);

        /* stop here if simulating only */
        if (ZipSnap.simulateOnly)
            return;


        /*************************************************************
        * (9) RESTORE DIRECTORY TIMESTAMPS IN THE CURRENT DIRECTORY *
        *************************************************************/

        /* updated current directory contents */
        final List<FileUnit> updatedCurrentDirContents = new ArrayList<FileUnit>(currentDirContents);

        /* add extracted files/directories */
        for (FileUnit u : extractedFiles)
        {
            final int i = Collections.binarySearch(currentDirContents, u);

            if (i >= 0)
            {
                /* overwrite existing file/directory */
                updatedCurrentDirContents.set(i, u);
            }
            else
            {
                /* add extracted file/directory */
                updatedCurrentDirContents.add(u);
            }
        }

        /* sort in lexicographic order of the name field */
        Collections.sort(updatedCurrentDirContents);

        /* remove deleted files/directories */
        updatedCurrentDirContents.removeAll(deletedFiles);

        /* determine directories with timestamps to be restored */
        final List<FileUnit> restoreDirs = new ArrayList<FileUnit>();

        for (FileUnit u : updatedCurrentDirContents)
        {
            if (u.isDirectory && u.file.isDirectory() &&
                    (u.file.lastModified() != u.time))
            {
                /* get canonical pathname of directory */
                String pathname = null;

                try
                {
                    pathname = u.file.getCanonicalPath();
                }
                catch (Exception e)
                {
                    pathname = null;
                }

                if ((pathname != null) && pathname.equals(u.file.getPath()))
                    restoreDirs.add(u);
            }
        }

        /* reverse sort the directories to be restored */
        Collections.reverse(restoreDirs);

        /* directories with timestamps successfully restored */
        final List<FileUnit> restoredDirs = new ArrayList<FileUnit>();

        /* restore directory timestamps in the current directory */
        if (!restoreDirs.isEmpty())
            restoreDirTime(restoreDirs, restoredDirs);
    }


    /**
    * Display information about the ZipSnap archive.
    */
    private static void displayArchiveInfo()
    {
        System.out.print("\nDISPLAY ARCHIVE INFORMATION\n");


        /***************************************
        * (1) CHECK ZIPSNAP ARCHIVE DIRECTORY *
        ***************************************/

        try
        {
            /* get absolute, canonical path of the archive directory */
            ZipSnap.archiveDir = (new File(ZipSnap.archive)).getCanonicalFile();
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to get absolute pathname of ZipSnap archive directory \"" +
                    ZipSnap.archive + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        ZipSnap.archiveDirName = FileIO.trimTrailingSeparator(ZipSnap.archiveDir.getPath()) + File.separatorChar;

        System.out.print("\nZipSnap archive    : \"" + ZipSnap.archiveDirName + "\"");

        /* check if ZipSnap archive directory is valid */
        if (!ZipSnap.archiveDir.exists())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" does not exist.");

        if (!ZipSnap.archiveDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" is not a directory; could it be a file?");

        if (!ZipSnap.archiveDir.canRead())
            ErrorWarningHandler.reportErrorAndExit("The specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\" cannot be read.");


        /****************************************
        * (2) READ CATALOG FOR ZIPSNAP ARCHIVE *
        ****************************************/

        /* get the latest catalog in the ZipSnap archive directory */
        final File catalogFile = getLatestCatalog();

        /* check if a catalog is found */
        if (catalogFile == null)
            ErrorWarningHandler.reportErrorAndExit("Unable to find a catalog in the specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\".");

        /* found a catalog; proceed to read it */
        System.out.print("\n - Latest catalog  : \"" + catalogFile.getName() + "\"");
        System.out.flush();

        /* read catalog for the ZipSnap archive */
        final Catalog catalog = new Catalog(catalogFile);

        /* check that catalog contains at least one snapshot */
        if (catalog.snapshots.isEmpty())
            ErrorWarningHandler.reportErrorAndExit("No snapshots found in the catalog \"" + catalogFile.getName() + "\" for this ZipSnap archive.");

        /* display filter, if defined */
        if (ZipSnap.filterPattern != null)
        {
            if (ZipSnap.filterType == ZipSnap.FilterType.GLOB)
            {
                System.out.print("\nGlob filter        : \"" + ZipSnap.filterString + "\"");
            }
            else if (ZipSnap.filterType == ZipSnap.FilterType.REGEX)
            {
                System.out.print("\nRegex filter       : \"" + ZipSnap.filterString + "\"");
            }

            if (ZipSnap.filterFullPathname)
            {
                System.out.print(" (match full relative pathname)");
            }
            else
            {
                System.out.print(" (match name only)");
            }

            System.out.flush();
        }


        /**************************************************
        * (3) COUNT FILES/DIRECTORIES FROM EACH SNAPSHOT *
        **************************************************/

        /* total number of files/directories */
        int numSnapshotFiles[] = new int[catalog.snapshots.size()];
        Arrays.fill(numSnapshotFiles, 0);

        /* number of new/modified files/directories */
        int numSnapshotFilesAdded[] = new int[catalog.snapshots.size()];
        Arrays.fill(numSnapshotFilesAdded, 0);

        /* count filtered files/directories from each snapshot */
        for (FileUnit u : catalog.files)
        {
            if (u.matchesFilter())
            {
                numSnapshotFilesAdded[u.snapshots.get(0)]++;

                for (int i : u.snapshots)
                    numSnapshotFiles[i]++;
            }
        }


        /************************************
        * (4) DISPLAY SNAPSHOT INFORMATION *
        ************************************/

        System.out.print("\n\nThis ZipSnap archive contains " + catalog.snapshots.size() + " snapshots and " +
                catalog.files.size() + " files/directories:\n" +
                "\nSnapshot      Snapshot         No. of new/modified      Total no. of" +
                "\n Number         Time         files/directories added  files/directories" +
                "\n--------  -----------------  -----------------------  -----------------");

        for (int i = 0; i < catalog.snapshots.size(); i++)
        {
            System.out.print("\n" +
                    StringManipulator.centerJustify(i + 1, 8) + "  " +
                    StringManipulator.centerJustify(catalog.snapshots.get(i).time, 17) + "  " +
                    StringManipulator.centerJustify(numSnapshotFilesAdded[i], 23) + "  " +
                    StringManipulator.centerJustify(numSnapshotFiles[i], 17));
        }
    }


    /**
    * Get the latest catalog in the archive.
    *
    * @return
    *     File corresponding to the latest catalog;
    *     null if no catalog is found
    */
    private static File getLatestCatalog()
    {
        /* get contents of the ZipSnap archive directory */
        final File dirContents[] = ZipSnap.archiveDir.listFiles();

        if (dirContents == null)
            ErrorWarningHandler.reportErrorAndExit("Unable to get contents of the specified ZipSnap archive directory \"" + ZipSnap.archiveDirName + "\".");

        /* create regex pattern to match files:                  */
        /* "archiveName.yyyyMMdd-HHmmss.n.{txt,txt.zip,txt.jar}" */
        /* (capture group 1 = n, i.e. the snapshot number)       */
        final Pattern catalogNamePattern = Pattern.compile(
                Pattern.quote(ZipSnap.archiveDir.getName()) +
                "\\.[0-9]{8}\\-[0-9]{6}\\.([1-9][0-9]*)\\.(?:txt|txt\\.zip|txt\\.jar)");

        /* select the latest catalog file using the following sequence of "keys": */
        /*   1. Largest snapshot number (value of n)                              */
        /*   2. Latest last-modified time                                         */
        final NavigableMap<Integer,File> candidateCatalogs = new TreeMap<Integer,File>();

        ProcessNextFile:
        for (File f : dirContents)
        {
            if (f.isFile())
            {
                final Matcher m = catalogNamePattern.matcher(f.getName());

                if (m.matches())
                {
                    /* found a match, i.e. this file is a catalog; get value of n */
                    final int n = Integer.parseInt(m.group(1));
                    final File c = candidateCatalogs.get(n);

                    if ((c == null) ||
                            (f.lastModified() > c.lastModified()))
                    {
                        /* add this catalog file as a candidate */
                        candidateCatalogs.put(n, f);
                    }
                }
            }
        }

        /* check if there are any candidate catalog files */
        if (candidateCatalogs.isEmpty())
            return null;

        /* return catalog file corresponding to highest snapshot number */
        return candidateCatalogs.get(candidateCatalogs.lastKey());
    }


    /**
    * Get filtered contents (files and directories) of the current directory.
    * The returned FileUnits are sorted in lexicographic order of their name field.
    *
    * @return
    *     FileUnits corresponding to the filtered contents (files and
    *     directories) of the current directory
    */
    private static List<FileUnit> getCurrentDirContents()
    {
        /* get contents (files and directories) of the current directory */
        System.out.print("\n\nScanning current directory contents...");
        System.out.flush();

        /* return value */
        final List<FileUnit> currentDirContents = new ArrayList<FileUnit>();

        /* get contents of the current directory recursively */
        getDirContents(ZipSnap.currentDir, currentDirContents);

        /* sort in lexicographic order of the name field */
        Collections.sort(currentDirContents);

        System.out.print("\nThe current directory contains " + currentDirContents.size() + " files/directories.");
        System.out.flush();

        return currentDirContents;
    }


    /**
    * Get filtered files and subdirectories in the specified directory.
    * This method is called recursively if necessary.
    *
    * @param dir
    *     Subdirectory to be scanned
    * @param dirContents
    *     Directory contents (to be populated)
    */
    private static void getDirContents(
            final File dir,
            final List<FileUnit> dirContents)
    {
        /* files and subdirectories of the specified directory */
        final List<File> files = new ArrayList<File>();
        final List<File> dirs = new ArrayList<File>();

        /* get directory contents */
        final FileIO.FileIOResult result = FileIO.getDirContents(dir, files, dirs);

        if (!result.success)
        {
            ErrorWarningHandler.reportWarning("Unable to get contents of directory \"" +
                    FileIO.trimTrailingSeparator(dir.getPath()) + File.separatorChar + "\":\n" +
                    result.errorMessage + "\nThis directory will be ignored.");

            return;
        }

        final int currentDirNameLen = ZipSnap.currentDirName.length();


        /**********************************************
        * (1) ADD THIS DIRECTORY TO THE RETURN VALUE *
        **********************************************/

        if (!dir.equals(ZipSnap.currentDir))
        {
            /* native and absolute pathname */
            final String absoluteNativeName = FileIO.trimTrailingSeparator(dir.getPath()) + File.separatorChar;

            if (absoluteNativeName.startsWith(ZipSnap.currentDirName))
            {
                final FileUnit u = new FileUnit(
                        absoluteNativeName.substring(currentDirNameLen), /* native and relative pathname of directory */
                        dir,    /* File object representing this directory */
                        true); /* this FileUnit represents a directory    */

                /* apply filter */
                if (u.matchesFilter())
                {
                    /* neutral and relative pathname of directory */
                    u.name = FileIO.nativeToNeutral(u.nativeName);

                    /* size of directory in bytes (size of directory is fixed at zero bytes) */
                    u.size = 0L;

                    /* last-modified time in milliseconds since the epoch (00:00:00 GMT, January 1, 1970) */
                    u.time = dir.lastModified();

                    dirContents.add(u);
                }
            }
            else
            {
                ErrorWarningHandler.reportWarning("The absolute pathname of directory \"" + absoluteNativeName +
                        "\" does not begin with the absolute pathname of the current directory \"" +
                        ZipSnap.currentDirName + "\".\nThis directory will be ignored.");
            }
        }


        /*******************************************************
        * (2) ADD FILES OF THIS DIRECTORY TO THE RETURN VALUE *
        *******************************************************/

        ProcessNextFile:
        for (File f: files)
        {
            /* native and absolute pathname */
            final String absoluteNativeName = f.getPath();

            if (absoluteNativeName.startsWith(ZipSnap.currentDirName))
            {
                final FileUnit u = new FileUnit(
                        absoluteNativeName.substring(currentDirNameLen), /* native and relative pathname of file */
                        f,       /* File object representing this file */
                        false); /* this FileUnit represents a file    */

                /* apply filter */
                if (u.matchesFilter())
                {
                    /* neutral and relative pathname of file */
                    u.name = FileIO.nativeToNeutral(u.nativeName);

                    /* size of file in bytes */
                    u.size = f.length();

                    /* last-modified time in milliseconds since the epoch (00:00:00 GMT, January 1, 1970) */
                    u.time = f.lastModified();

                    dirContents.add(u);
                }
            }
            else
            {
                ErrorWarningHandler.reportWarning("The absolute pathname of file \"" + absoluteNativeName +
                        "\" does not begin with the absolute pathname of the current directory \"" +
                        ZipSnap.currentDirName + "\".\nThis file will be ignored.");
            }
        }


        /***********************************
        * (3) RECURSE INTO SUBDIRECTORIES *
        ***********************************/

        for (File d : dirs)
            getDirContents(d, dirContents);
    }


    /**
    * Return the Java regex Pattern corresponding to the glob filter
    * string ZipSnap.filterString.
    *
    * @return
    *     Corresponding Java Regex Pattern
    */
    private static Pattern getGlobFilterPattern()
    {
        /* regex expression to be compiled into a Java regex Pattern */
        final StringBuilder t = new StringBuilder();

        /* Stack to keep track of the parser mode: */
        /* "--" : Base mode (first on the stack)   */
        /* "[]" : Square brackets mode "[...]"     */
        /* "{}" : Curly braces mode "{...}"        */
        final Stack<String> parserMode = new Stack<String>();
        parserMode.push("--"); /* base mode */

        final StringBuilder remainingChars = new StringBuilder(ZipSnap.filterString);

        /* parse each character of the fitler string */
        ParseNextChar:
        while (remainingChars.length() > 0)
        {
            /* read one character */
            char c = remainingChars.charAt(0);
            remainingChars.deleteCharAt(0);


            /***********************
            * (1) ESCAPE SEQUENCE *
            ***********************/

            /* handle escape sequence in the filter string (MUST BE FIRST BLOCK) */
            if (c == '\\')
            {
                if (remainingChars.length() == 0)
                {
                    /* no characters left, so treat '\' as literal char */
                    t.append(Pattern.quote(c + ""));
                    continue ParseNextChar;
                }

                /* read next character */
                c = remainingChars.charAt(0);
                remainingChars.deleteCharAt(0);
                final String s = c + "";

                if ("--".equals(parserMode.peek()) && !"\\[]{}?*".contains(s))
                {
                    ErrorWarningHandler.reportErrorAndExit("Invalid escape sequence \"\\" +
                            c + "\" near position " +
                            (ZipSnap.filterString.length() - remainingChars.length()) +
                            " of glob filter string.");
                }
                else if ("[]".equals(parserMode.peek()) && !"\\[]{}?*!-".contains(s))
                {
                    ErrorWarningHandler.reportErrorAndExit("Invalid escape sequence \"\\" +
                            c + "\" near position " +
                            (ZipSnap.filterString.length() - remainingChars.length()) +
                            " of glob filter string.");
                }
                else if ("{}".equals(parserMode.peek()) && !"\\[]{}?*,".contains(s))
                {
                    ErrorWarningHandler.reportErrorAndExit("Invalid escape sequence \"\\" +
                            c + "\" near position " +
                            (ZipSnap.filterString.length() - remainingChars.length()) +
                            " of glob filter string.");
                }

                t.append(Pattern.quote(s));
                continue ParseNextChar;
            }


            /************************
            * (2) GLOB PATTERN '*' *
            ************************/

            /* handle glob pattern '*' */
            if (c == '*')
            {
                /* create non-capturing group to match zero or more characters */
                t.append(".*");
                continue ParseNextChar;
            }


            /************************
            * (3) GLOB PATTERN '?' *
            ************************/

            /* handle glob pattern '?' */
            if (c == '?')
            {
                /* create non-capturing group to match exactly one character */
                t.append('.');
                continue ParseNextChar;
            }


            /****************************
            * (4) GLOB PATTERN "[...]" *
            ****************************/

            /* handle glob pattern "[...]" */
            /* opening square bracket '[' */
            if (c == '[')
            {
                /* create non-capturing group to match exactly one character */
                /* inside the sequence */
                t.append('[');
                parserMode.push("[]");

                /* check for negation character '!' immediately after */
                /* the opening bracket '[' */
                if ((remainingChars.length() > 0) &&
                        (remainingChars.charAt(0) == '!'))
                {
                    c = remainingChars.charAt(0);
                    remainingChars.deleteCharAt(0);
                    t.append('^');
                }

                continue ParseNextChar;
            }

            /* closing square bracket ']' */
            if ("[]".equals(parserMode.peek()) && (c == ']'))
            {
                t.append(']');
                parserMode.pop();
                continue ParseNextChar;
            }

            /* character range '-' in "[...]" */
            if ("[]".equals(parserMode.peek()) && (c == '-'))
            {
                t.append('-');
                continue ParseNextChar;
            }
            /* end: handle glob pattern "[...]" */


            /****************************
            * (5) GLOB PATTERN "{...}" *
            ****************************/

            /* handle glob pattern "{...}" */
            /* opening curly brace '{' */
            if (c == '{')
            {
                /* create non-capturing group to match one of the */
                /* strings inside the sequence */
                t.append("(?:(?:");
                parserMode.push("{}");
                continue ParseNextChar;
            }

            /* closing curly brace '}' */
            if ("{}".equals(parserMode.peek()) && (c == '}'))
            {
                t.append("))");
                parserMode.pop();
                continue ParseNextChar;
            }

            /* comma between strings in "{...}" */
            if ("{}".equals(parserMode.peek()) && (c == ','))
            {
                t.append(")|(?:");
                continue ParseNextChar;
            }
            /* end: handle glob pattern "{...}" */


            /*************************
            * (6) LITERAL CHARACTER *
            *************************/

            /* convert literal character to a regex sequence (MUST BE LAST BLOCK) */
            t.append(Pattern.quote(c + ""));
            continue ParseNextChar;
        }
        /* done parsing all chars of the filter string */

        /* check for mismatched [...] or {...} */
        if ("[]".equals(parserMode.peek()))
            ErrorWarningHandler.reportErrorAndExit("Cannot find matching closing square bracket ']' in glob filter string \"" + ZipSnap.filterString + "\".");

        if ("{}".equals(parserMode.peek()))
            ErrorWarningHandler.reportErrorAndExit("Cannot find matching closing curly brace '}' in glob filter string \"" + ZipSnap.filterString + "\".");

        /* compile the Java regex expression */
        Pattern filterPattern = null;

        try
        {
            filterPattern = Pattern.compile(t.toString());
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to compile the specified glob filter string \"" +
                    ZipSnap.filterString + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        return filterPattern;
    }


    /**
    * Return the Java regex Pattern corresponding to the regex filter
    * string ZipSnap.filterString.
    *
    * @return
    *     Corresponding Java Regex Pattern
    */
    private static Pattern getRegexFilterPattern()
    {
        Pattern filterPattern = null;

        try
        {
            filterPattern = Pattern.compile(ZipSnap.filterString);
        }
        catch (Exception e)
        {
            ErrorWarningHandler.reportErrorAndExit("Unable to compile the specified regex filter string \"" +
                    ZipSnap.filterString + "\":\n" + ErrorWarningHandler.getExceptionMessage(e));
        }

        return filterPattern;
    }


    /**
    * Write a plain text file containing a given list of files/directories.
    *
    * @param listFile
    *     Plain text file to be created
    * @param files
    *     The files/directories to be listed in the file
    * @param addedFiles
    *     The files/directories that were successfully listed in the file
    * @return
    *     Number of files/directories successfully listed in the file
    */
    private static int listFiles(
            final File listFile,
            final List<FileUnit> files,
            final List<FileUnit> listedFiles)
    {
        final String listFileName = listFile.getName();

        if (ZipSnap.simulateOnly)
        {
            return CompressionIO.simulateProcessFiles(
                    "\n\nSimulating creation of list \"" + listFileName + "\" of " + files.size() + " files/directories:",
                    "",
                    "No. of files/directories listed:",
                    true,
                    files,
                    listedFiles);
        }

        System.out.print("\n\nCreating list \"" + listFileName + "\" of " + files.size() + " files/directories:");

        final File parentDir = listFile.getParentFile();

        /* create parent directories if necessary */
        if ((parentDir != null) && !parentDir.exists())
            parentDir.mkdirs();

        /* check that the parent directory of the list file exists */
        if ((parentDir == null) || !parentDir.isDirectory())
            ErrorWarningHandler.reportErrorAndExit("Unable to create list \"" + listFile.getPath() +
                    "\":\nThe parent directory of the file does not exist, and cannot be created.");

        /* check if the list file already exists */
        if (listFile.exists())
            ErrorWarningHandler.reportErrorAndExit("Unable to create list \"" + listFile.getPath() +
                    "\":\nA " + (listFile.isDirectory() ? "directory" : "file") + " of the same name already exists.");

        /* return value */
        int numListedFiles = 0;

        if (files.isEmpty())
        {
            /* create an empty file and return */
            try
            {
                final FileOutputStream fos = new FileOutputStream(listFile);
                fos.flush();
                fos.close();
            }
            catch (Exception e)
            {
                ErrorWarningHandler.reportErrorAndExit("Unable to create list \"" + listFileName + "\":\n" +
                        ErrorWarningHandler.getExceptionMessage(e));
            }
        }
        else
        {
            /* at least one file/directory to be listed */
            PrintWriter pw = null;

            try
            {
                pw = new PrintWriter(new BufferedWriter(new FileWriter(listFile)));
            }
            catch (Exception e)
            {
                ErrorWarningHandler.reportErrorAndExit("Unable to create list \"" + listFileName + "\":\n" +
                        ErrorWarningHandler.getExceptionMessage(e));
            }

            int i = 0;

            AddNextFile:
            for (FileUnit u : files)
            {
                i++;

                System.out.print("\n  [" + i + "] \"" + u.nativeName + "\"");
                System.out.flush();

                pw.println(u.nativeName);

                /* file/directory successfully added to list file */
                listedFiles.add(u);
                numListedFiles++;
            }

            /* close the file */
            try
            {
                pw.flush();
                pw.close();
            }
            catch (Exception e)
            {
                ErrorWarningHandler.reportWarning("Unable to close list \"" + listFileName +
                        "\"\n(list may not be written successfully):\n" +
                        ErrorWarningHandler.getExceptionMessage(e));
            }
        }

        /* print summary */
        System.out.print(
            "\n  --------------------------------" +
            "\n  No. of files/directories listed: " +
            numListedFiles + " out of " + files.size());
        System.out.flush();

        /* set timestamp of list */
        final FileIO.FileIOResult result = FileIO.setFileTime(listFile, ZipSnap.currentTime.getTime());

        if (!result.success)
            ErrorWarningHandler.reportWarning("Unable to set last-modified time of list \"" + listFileName + "\".");

        return numListedFiles;
    }


    /**
    * Delete a given list of files/directories from the current directory.
    *
    * @param files
    *     The files/directories to be deleted
    *     (must be in reverse lexicographic order of their name field)
    * @param deletedFiles
    *     The files/directories that were successfully deleted
    * @return
    *     Number of files/directories successfully deleted
    */
    private static int deleteFiles(
            final List<FileUnit> files,
            final List<FileUnit> deletedFiles)
    {
        if (ZipSnap.simulateOnly)
        {
            if (ZipSnap.defaultActionOnDelete == 'N')
            {
                return CompressionIO.simulateProcessFiles(
                        "\n\nSimulating deletion of " + files.size() + " files/directories from the current directory:",
                        "Skipping ",
                        "No. of files/directories deleted:",
                        false,
                        files,
                        deletedFiles);
            }
            else
            {
                return CompressionIO.simulateProcessFiles(
                        "\n\nSimulating deletion of " + files.size() + " files/directories from the current directory:",
                        "",
                        "No. of files/directories deleted:",
                        true,
                        files,
                        deletedFiles);
            }
        }

        System.out.print("\n\nDeleting " + files.size() + " files/directories from the current directory:");

        /* return value */
        int numDeletedFiles = 0;

        int i = 0;

        /* delete files/directories in reverse lexicographic order so */
        /* that subdirectories are deleted before parent directories  */

        DeleteNextFile:
        for (FileUnit u : files)
        {
            i++;

            if (!u.file.exists() ||
                    (u.isDirectory != u.file.isDirectory()))
            {
                System.out.print("\n  [" + i + "] \"" + u.nativeName + "\" does not exist anymore.");
                System.out.flush();
                continue DeleteNextFile;
            }

            /* get canonical pathname of file/directory to be deleted */
            String pathname = null;

            try
            {
                pathname = u.file.getCanonicalPath();
            }
            catch (Exception e)
            {
                pathname = null;
            }

            if ((pathname == null) ||
                !pathname.equals(u.file.getPath()))
            {
                System.out.print("\n  [" + i + "] \"" + u.nativeName + "\" does not exist anymore.");
                System.out.flush();
                continue DeleteNextFile;
            }

            /* prompt user on action (default = false) */
            boolean deleteFile = false;

            if (ZipSnap.defaultActionOnDelete == 'Y')
            {
                System.out.print("\n  [" + i + "] \"" + u.nativeName + "\"");
                deleteFile = true;
            }
            else if (ZipSnap.defaultActionOnDelete == 'N')
            {
                System.out.print("\n  [" + i + "] Skipping \"" + u.nativeName + "\"");
            }
            else if (ZipSnap.defaultActionOnDelete == '\0')
            {
                System.out.print("\n  [" + i + "] Delete \"" + u.nativeName + "\"?\n  ");

                final char choice = UserIO.userCharPrompt(
                        "(Y)es/(N)o/(A)lways/Neve(R): ",
                        "YNAR");

                if (choice == 'Y')
                {
                    deleteFile = true;
                }
                else if (choice == 'A')
                {
                    ZipSnap.defaultActionOnDelete = 'Y';
                    deleteFile = true;
                }
                else if (choice == 'R')
                {
                    ZipSnap.defaultActionOnDelete = 'N';
                }
            }

            System.out.flush();

            /* take action */
            if (deleteFile)
            {
                FileIO.FileIOResult result = null;

                if (u.isDirectory)
                {
                    result = FileIO.deleteDir(u.file);
                }
                else
                {
                    result = FileIO.deleteFile(u.file);
                }

                if (result.success)
                {
                    deletedFiles.add(u);
                    numDeletedFiles++;
                }
                else
                {
                    ErrorWarningHandler.reportWarning("Unable to delete " +
                            (u.isDirectory ? "directory" : "file") + " \"" +
                            u.nativeName + "\":\n" + result.errorMessage);
                }
            }
        }

        /* print summary */
        System.out.print(
            "\n  ---------------------------------" +
            "\n  No. of files/directories deleted: " +
            numDeletedFiles + " out of " + files.size());
        System.out.flush();

        return numDeletedFiles;
    }


    /**
    * Restore directory timestamps.
    *
    * @param dirs
    *     The directories for which timestamps are to be restored
    *     (must be in reverse lexicographic order of their name field)
    * @param restoredDirs
    *     The directories for which timestamps were successfully restored
    * @return
    *     Number of directory timestamps successfully restored
    */
    private static int restoreDirTime(
            final List<FileUnit> dirs,
            final List<FileUnit> restoredDirs)
    {
        System.out.print("\n\nRestoring timestamps of " + dirs.size() + " directories:");

        /* return value */
        int numRestoredDirs = 0;

        int i = 0;

        /* restore timestamps of directories in reverse lexicographic order  */
        /* so that subdirectories are synchronized before parent directories */

        for (FileUnit u : dirs)
        {
            i++;

            System.out.print("\n  [" + i + "] \"" + u.nativeName + "\"");
            System.out.flush();

            final FileIO.FileIOResult result = FileIO.setDirTime(u.file, u.time);

            if (result.success)
            {
                restoredDirs.add(u);
                numRestoredDirs++;
            }
            else
            {
                ErrorWarningHandler.reportWarning("Unable to restore timestamp of directory \"" + u.nativeName + "\".");
            }
        }

        /* print summary */
        System.out.print(
            "\n  -------------------------------------" +
            "\n  No. of directory timestamps restored: " +
            numRestoredDirs + " out of " + dirs.size());
        System.out.flush();

        return numRestoredDirs;
    }


    /**
    * Print out usage syntax, notes, and comments.
    */
    private static void printUsage()
    {
        /* RULER   00000000011111111112222222222333333333344444444445555555555666666666677777777778 */
        /* RULER   12345678901234567890123456789012345678901234567890123456789012345678901234567890 */
        System.out.print(
                "\nZipSnap is a simple command-line incremental backup tool for directories."+
                "\n"+
                "\nUSAGE:  java -jar ZipSnap.jar  [command]  <switches>  [\"Archive\"]"+
                "\n"+
                "\n[Commands]:"+
                "\n" +
                "\n a  ADD a snapshot of the current directory to the archive:"+
                "\n     ZipSnap looks for the latest catalog in directory [\"Archive\"] and performs" +
                "\n     file matching between the current directory contents and previously" +
                "\n     archived files and directories as described by the catalog. By default," +
                "\n     files and directories are matched by full (relative) pathname, size, and" +
                "\n     last-modified time (in milliseconds). The new or modified files and" +
                "\n     directories in the current directory (i.e. the unmatched contents) are" +
                "\n     then added to a new compressed volume (by default, a ZIP file), and a new" +
                "\n     catalog (by default, a plain text file compressed as a ZIP file) is" +
                "\n     written. If the archive does not exist yet, a new archive is created." +
                "\n" +
                "\n r  RESTORE a snapshot from the archive to the current directory:"+
                "\n     ZipSnap looks for the latest catalog in directory [\"Archive\"] and by" +
                "\n     default, restores the latest snapshot from the archive. File matching is" +
                "\n     performed between the current directory contents and the snapshot" +
                "\n     contents, to determine the unmatched files and directories that need to be" +
                "\n     extracted from their respective compressed volumes, and to be deleted from" +
                "\n     the current directory. By default, files and directories are matched by" +
                "\n     full (relative) pathname, size, and last-modified time (in milliseconds)." +
                "\n" +
                "\n i  Display INFORMATION on the archive:" +
                "\n     ZipSnap looks for the latest catalog in directory [\"Archive\"] and displays" +
                "\n     the file counts for each snapshot in the archive." +
                "\n"+
                "\n<Switches>:"+
                "\n" +
                "\n -s, --simulate        Simulate only; do not actually add/restore snapshot" +
                "\n -i, --ignorewarnings  Ignore warnings; do not pause" +
                "\n" +
                "\n -c, --crc             Use file CRC-32 checksum for file matching, in addition" +
                "\n                        to full (relative) pathname, size, and last-modified" +
                "\n                        time (in milliseconds)" +
                "\n -t, --time:[x]        Use a x-millisecond time-tolerance for file matching" +
                "\n                        (by default, a 0-millisecond time-tolerance is used)" +
                "\n     --all             Add/extract ALL files/directories when adding/restoring" +
                "\n                        a snapshot without performing file matching first" +
                "\n     --forceadd        Force addition of snapshot, even if it is identical to" +
                "\n                        the last snapshot (by default, an identical snapshot" +
                "\n                        is not added)" +
                "\n" +
                "\n -l, --list            Create a list of files/directories to be added/extracted" +
                "\n                        when adding/restoring a snapshot" +
                "\n -z, --zip:<x>         Add files/directories to a ZIP volume using compression" +
                "\n                        level x, with 0 and 9 representing minimum and maximum" +
                "\n                        compression respectively (by default, maximum" +
                "\n                        compression is used)" +
                "\n -j, --jar:<x>         Add files/directories to a JAR volume ..." +
                "\n" +
                "\n     --snapshot:[x]    Restore snapshot number x (by default, the latest" +
                "\n                        snapshot is restored)" +
                "\n -o, --overwrite:[y|n] Always[y]/never[n] overwrite existing files/directories" +
                "\n                        when restoring a snapshot" +
                "\n -d, --delete:[y|n]    Always[y]/never[n] delete unmatched existing" +
                "\n                        files/directories when restoring a snapshot" +
                "\n -p, --path:[\"x\"]      Include additional search path x for volume files when" +
                "\n                        restoring a snapshot (can be used repeatedly to specify" +
                "\n                        multiple search paths)" +
                "\n" +
                "\n -f, --filter:[\"x\"]       Apply GLOB filter string x for file/directory names" +
                "\n -F, --FILTER:[\"x\"]       ... for full (relative) file/directory pathnames" +
                "\n -e, --filterregex:[\"x\"]  Apply REGEX filter string x for file/directory names" +
                "\n -E, --FILTERREGEX:[\"x\"]  ... for full (relative) file/directory pathnames" +
                "\n"+
                "\n[\"Archive\"]:"+
                "\n" +
                "\n ZipSnap archive, i.e. the directory containing the catalogs and compressed" +
                "\n volumes"+
                "\n"+
                "\nNOTES:"+
                "\n" +
                "\n 1. ZipSnap creates archives that are collections of point-in-time snapshots." +
                "\n     An archive is just a directory of catalogs (plain text files compressed as" +
                "\n     ZIP files) and compressed volumes (ZIP files)." +
                "\n" +
                "\n 2. Catalogs are written cumulatively, i.e. a later catalog contains all the" +
                "\n     information from previous catalogs. Therefore, only the latest catalog is" +
                "\n     needed when adding/restoring snapshots." +
                "\n" +
                "\n 3. Catalogs and compressed volumes are automatically timestamped as" +
                "\n     ArchiveName.yyyyMMdd-HHmmss.n, with extensions .txt.zip and .zip" +
                "\n     respectively, where n is the snapshot number." +
                "\n" +
                "\n 4. The latest catalog selected by ZipSnap is the catalog with the largest" +
                "\n     snapshot number in the archive directory. To break ties, the catalog with" +
                "\n     the latest last-modified time is selected." +
                "\n" +
                "\n 5. When restoring a snapshot, ZipSnap looks for volume files with specific" +
                "\n     timestamps and snapshot numbers, in the archive directory. Additional" +
                "\n     search paths, if specified, are also searched. To break ties, the volume" +
                "\n     with the latest last-modified time is selected." +
                "\n" +
                "\n 6. Because ZipSnap never modifies a catalog or compressed volume after it is"+
                "\n     created, archives can be stored on write-once-only media." +
                "\n"+
                "\n 7. ZipSnap supports GLOB and REGEX filters for file/directory names, or their" +
                "\n     full (relative) pathnames. The supported REGEX patterns are given by the" +
                "\n     Java API documentation." +
                "\n" +
                "\n    GLOB Patterns and Wildcards:" +
                "\n      *    Matches a string of zero or more characters" +
                "\n      ?    Matches exactly one character" +
                "\n     [ ]   Matches exactly one character from within the brackets:" +
                "\n             [abc]      matches a, b, or c" +
                "\n             [!abc]     matches any character except a, b, or c (negation)" +
                "\n             [a-z0-9]   matches any character a through z, or 0 through 9," +
                "\n                         inclusive (range)" +
                "\n     { }   Matches exactly one comma-delimited string from within the braces:" +
                "\n             {a,bc,def} matches either a, bc, or def" +
                "\n"+
                "\nEXAMPLES:"+
                "\n"+
                "\n 1. ADD a snapshot to the archive; if the archive does not exist, a new archive" +
                "\n     is created:" +
                "\n    java -jar ZipSnap.jar a \"C:\\Backups\\Work\"" +
                "\n" +
                "\n 2. RESTORE the latest snapshot from the archive:" +
                "\n    java -jar ZipSnap.jar r \"C:\\Backups\\Work\"" +
                "\n" +
                "\n 3. RESTORE the latest snapshot from the archive, and always overwrite existing"+
                "\n     files/directories, but never delete unmatched existing files/directories:" +
                "\n    java -jar ZipSnap.jar r --overwrite:y --delete:n \"C:\\Backups\\Work\""+
                "\n" +
                "\n 4. RESTORE snapshot number 2 from the archive:" +
                "\n    java -jar ZipSnap.jar r --snapshot:2 \"C:\\Backups\\Work\"" +
                "\n" +
                "\n 5. RESTORE only jpg or html files in the latest snapshot from the archive:" +
                "\n    java -jar ZipSnap.jar r --filter:\"*.{jpg,html}\" \"C:\\Backups\\Work\"" +
                "\n" +
                "\n 6. Display INFORMATION on the archive:" +
                "\n    java -jar ZipSnap.jar i \"C:\\Backups\\Work\"" +
                "\n\n");
    }
}