com.t3.persistence.PackedFile.java Source code

Java tutorial

Introduction

Here is the source code for com.t3.persistence.PackedFile.java

Source

/*
 * Copyright (c) 2014 tabletoptool.com team.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 * 
 * Contributors:
 *     rptools.com team - initial implementation
 *     tabletoptool.com team - further development
 */
package com.t3.persistence;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Reader;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.Charset;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import org.apache.commons.io.IOUtils;
import org.apache.log4j.Logger;

import com.t3.CodeTimer;
import com.t3.guid.GUID;

/**
 * Represents a container of content/files within a single actual file.
 * <p>
 * A packed file contains three parts:
 * <ul>
 * <li>Contents - A single object that represents the core content of the packed file, as a convenience method
 * <li>Properties - A String to Object map of arbitrary properties that can be used to describe the packed file
 * <li>Files - Any arbitrary files/data that are packed within the packed file
 * </ul>
 * <p>
 * The implementation uses two {@link Set}s, <b>addedFileSet</b> and <b>removedFileSet</b>,
 * to keep track of paths for content that has been added or removed from the packed
 * file, respectively.  This is because the actual file itself isn't written until the {@link #save()}
 * method is called, yet the application may want to dynamically add and remove paths to
 * the packed file and query the state of which files are currently included or excluded
 * from the packed file.
 * <p>
 * In addition, the API allows for storing multiple types of objects into the packed file.  The
 * easiest to understand are byte streams as they are binary values that are not modified
 * by character set encoding during output.  They are represented by an array of bytes or
 * an {@link InputStream}.
 * <p>
 * The second type of data is the file, represented here as a URL as it is more universally
 * applicable (although currently it is unused outside this class).  URLs have their content
 * retrieved from the source and are currently written into the packed file without any
 * character encoding (it is possible that the <code>Content-Type</code> of the data
 * stream could provide information on how the data should be written so this may
 * change in the future).
 * <p>
 * The last and most important type of data is the POJO (plain old Java object).  These
 * are converted into XML using the XStream library from codehaus.org.  As the data
 * is written to the output file it is character set encoded to UTF-8.  It is hoped that
 * this solves the localization issues with saved macros and other data not being
 * restored properly.
 * Because of this, data loaded from a packed file is always retrieved without
 * character set encoding <b>unless</b> it is XML data.  This should preserve
 * binary data such as JPEG and PNG images properly.  A side effect of this is that
 * all character data should be written to the packed file as POJOs in order to obtain
 * the automatic character set encoding.  (Otherwise, strings can be converted to
 * UTF-8 using the {@link String#getBytes(String)} method.
 */
//TODO refactor this piece of shit
public class PackedFile implements Closeable {

    private static final String PROPERTY_FILE = "properties.xml";
    private static final String CONTENT_FILE = "content.xml";

    private static final Logger log = Logger.getLogger(PackedFile.class);

    private static File tmpDir = new File(System.getProperty("java.io.tmpdir")); // Shared temporary directory

    private final File file; // Original zip file
    private final File tmpFile; // Temporary directory where changes are kept

    private boolean dirty;
    private boolean propsLoaded;

    private Map<String, Object> propertyMap = new HashMap<String, Object>();
    private final Set<String> addedFileSet = new HashSet<String>();
    private final Set<String> removedFileSet = new HashSet<String>();

    /**
     * By default all temporary files are handled in /tmp.  Use this method
     * to globally set the location of the temporary directory
     */
    public static void init(File tmpDir) {
        PackedFile.tmpDir = tmpDir;
    }

    public PackedFile(File file) {
        this.file = file;
        dirty = !file.exists();
        tmpFile = new File(tmpDir.getAbsolutePath() + "/" + new GUID() + ".tmp");
    }

    /**
     * Retrieves the property map from the campaign file and accesses the given key,
     * returning an Object that the key holds.  The Object is constructed from the
     * XML content and could be anything.
     * 
     * @param key key for accessing the property map
     * @return the value (typically a String)
     * @throws IOException
     */
    public Object getProperty(String key) throws IOException {
        return getPropertyMap().get(key);
    }

    /**
     * Returns a list of all keys in the campaign's property map.
     * See also {@link #getProperty(String)}.
     * 
     * @return list of all keys
     * @throws IOException
     */
    public Iterator<String> getPropertyNames() throws IOException {
        return getPropertyMap().keySet().iterator();
    }

    /**
     * Stores a new key/value pair into the property map.  Existing keys are
     * overwritten.
     * 
     * @param key
     * @param value any POJO; will be serialized into XML upon writing
     * @return the previous value for the given key
     * @throws IOException
     */
    public Object setProperty(String key, Object value) throws IOException {
        dirty = true;
        return getPropertyMap().put(key, value);
    }

    /**
     * Remove the property with the associated key from the property map.
     * 
     * @param key
     * @return the previous value for the given key
     * @throws IOException
     */
    public Object removeProperty(String key) throws IOException {
        dirty = true;
        return getPropertyMap().remove(key);
    }

    /**
     * Retrieves the contents of the <code>CONTENT_FILE</code> as a POJO.
     * This object is the top-level data structure for all information regarding the
     * content of the PackedFile.
     * 
     * @return the results of the deserialization
     * @throws IOException
     */
    public Object getContent() throws IOException {
        return getContent((String) getProperty("version"));
    }

    /**
     * Same as {@link #getContent()} except that the version can be specified.  This
     * allows a newer release of an application to provide automatic transformation
     * information that will be applied to the XML as the object is deserialized.
     * The default trasnformation manager is used.
     * (Think of the transformation as a simplifed XSTL process.)
     * 
     * @param fileVersion such as "1.3.70"
     * @return the results of the deserialization
     * @throws IOException
     */
    public Object getContent(String fileVersion) throws IOException {
        try {
            return getFileObject(CONTENT_FILE);
        } catch (NullPointerException npe) {
            log.error("Problem finding/converting content file", npe);
            return null;
        }
    }

    protected Map<String, Object> getPropertyMap() throws IOException {
        if (hasFile(PROPERTY_FILE) && !propsLoaded) {
            propertyMap = null;
            // This is the case when we're pointing to a file but haven't loaded it yet
            try {
                Object obj = getFileObject(PROPERTY_FILE);
                if (obj instanceof Map<?, ?>) {
                    propertyMap = (Map<String, Object>) obj;
                    propsLoaded = true;
                } else
                    log.error("Unexpected class type for property object: " + obj.getClass().getName());
            } catch (NullPointerException npe) {
                log.error("Problem finding/converting property file", npe);
            }
        }
        return propertyMap;
    }

    public boolean isDirty() {
        return dirty;
    }

    public void save() throws IOException {
        CodeTimer saveTimer;

        if (!dirty) {
            return;
        }
        saveTimer = new CodeTimer("PackedFile.save");
        saveTimer.setEnabled(log.isDebugEnabled());

        // Create the new file
        File newFile = new File(tmpDir.getAbsolutePath() + "/" + new GUID() + ".pak");

        try (ZipOutputStream zout = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(newFile)))) {
            zout.setLevel(1); // fast compression
            saveTimer.start("contentFile");
            if (hasFile(CONTENT_FILE)) {
                zout.putNextEntry(new ZipEntry(CONTENT_FILE));
                try (InputStream is = getFileAsInputStream(CONTENT_FILE)) { // When copying, always use an InputStream
                    IOUtils.copy(is, zout);
                }
                zout.closeEntry();
            }
            saveTimer.stop("contentFile");

            saveTimer.start("propertyFile");
            if (getPropertyMap().isEmpty()) {
                removeFile(PROPERTY_FILE);
            } else {
                zout.putNextEntry(new ZipEntry(PROPERTY_FILE));
                Persister.newInstance().toXML(getPropertyMap(), zout);
                zout.closeEntry();
            }
            saveTimer.stop("propertyFile");

            // Now put each file
            saveTimer.start("addFiles");
            addedFileSet.remove(CONTENT_FILE);
            for (String path : addedFileSet) {
                zout.putNextEntry(new ZipEntry(path));
                try (InputStream is = getFileAsInputStream(path)) { // When copying, always use an InputStream
                    IOUtils.copy(is, zout);
                }
                zout.closeEntry();
            }
            saveTimer.stop("addFiles");

            // Copy the rest of the zip entries over
            saveTimer.start("copyFiles");
            if (file.exists()) {
                Enumeration<? extends ZipEntry> entries = zFile.entries();
                while (entries.hasMoreElements()) {
                    ZipEntry entry = entries.nextElement();
                    if (!entry.isDirectory() && !addedFileSet.contains(entry.getName())
                            && !removedFileSet.contains(entry.getName()) && !CONTENT_FILE.equals(entry.getName())
                            && !PROPERTY_FILE.equals(entry.getName())) {
                        zout.putNextEntry(entry);
                        try (InputStream is = getFileAsInputStream(entry.getName())) { // When copying, always use an InputStream
                            IOUtils.copy(is, zout);
                        }
                        zout.closeEntry();
                    } else if (entry.isDirectory()) {
                        zout.putNextEntry(entry);
                        zout.closeEntry();
                    }
                }
            }
            try {
                if (zFile != null)
                    zFile.close();
            } catch (IOException e) {
                // ignore close exception
            }
            zFile = null;
            saveTimer.stop("copyFiles");

            saveTimer.start("close");
            zout.close();
            saveTimer.stop("close");

            // Backup the original
            saveTimer.start("backup");
            File backupFile = new File(tmpDir.getAbsolutePath() + "/" + new GUID() + ".mv");
            if (file.exists()) {
                backupFile.delete(); // Always delete the old backup file first; renameTo() is very platform-dependent
                if (!file.renameTo(backupFile)) {
                    FileUtil.copyFile(file, backupFile);
                    file.delete();
                }
            }
            saveTimer.stop("backup");

            saveTimer.start("finalize");
            // Finalize
            if (!newFile.renameTo(file))
                FileUtil.copyFile(newFile, file);

            if (backupFile.exists())
                backupFile.delete();
            saveTimer.stop("finalize");

            dirty = false;
        } finally {
            saveTimer.start("cleanup");
            try {
                if (zFile != null)
                    zFile.close();
            } catch (IOException e) {
                // ignore close exception
            }
            if (newFile.exists())
                newFile.delete();
            saveTimer.stop("cleanup");

            if (log.isDebugEnabled())
                log.debug(saveTimer);
            saveTimer = null;
        }
    }

    /**
     * Set the given object as the information to write to the 'content.xml' file in the archive.
     * 
     * @param content
     * @throws IOException
     */
    public void setContent(Object content) throws IOException {
        putFile(CONTENT_FILE, content);
    }

    /**
     * Does the work of preparing for output to a temporary file, returning the {@link File}
     * object associated with the temporary location.  The caller is then expected to
     * open and write their data to the file which will later be added to the ZIP file.
     *
     * @param path path within the ZIP to write to
     * @return the <code>File</code> object for the temporary location
     * @throws IOException
     */
    private File putFileImpl(String path) throws IOException {
        if (!tmpFile.exists())
            tmpFile.getParentFile().mkdirs();

        // Have to store it in the exploded area since we can't directly save it to the zip
        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists()) {
            explodedFile.delete();
        } else {
            explodedFile.getParentFile().mkdirs();
        }

        // We just remember that we added it, then go look for it later...
        addedFileSet.add(path);
        removedFileSet.remove(path);
        dirty = true;
        return explodedFile;
    }

    /**
     * Write the <code>byte</code> data to the given path in the ZIP file; as the data
     * is binary there is no {@link Charset} conversion.
     *
     * @param path location within the ZIP file
     * @param data the binary data to be written
     * @throws IOException
     */
    public void putFile(String path, byte[] data) throws IOException {
        putFile(path, new ByteArrayInputStream(data));
    }

    /**
     * Write the <b>binary</b> data to the given path in the ZIP file; as the data is
     * presumed to be binary there is no charset conversion.
     *
     * @param path location within the ZIP file
     * @param data the binary data to be written in the form of an InputStream
     * @throws IOException
     */
    public void putFile(String path, InputStream is) throws IOException {
        File explodedFile = putFileImpl(path);
        try (FileOutputStream fos = new FileOutputStream(explodedFile)) {
            IOUtils.copy(is, fos);
        }
    }

    /**
     * Write the serialized object to the given path in the ZIP file; as the data is an
     * object it is first converted to XML and character set encoding will take place
     * as the data is written to the (temporary) file.
     *
     * @param path location within the ZIP file
     * @param obj the object to be written
     * @throws IOException
     */
    public void putFile(String path, Object obj) throws IOException {
        File explodedFile = putFileImpl(path);
        try (BufferedWriter bw = new BufferedWriter(
                new OutputStreamWriter(new FileOutputStream(explodedFile), "UTF-8"))) {
            Persister.newInstance().toXML(obj, bw);
            bw.newLine(); // Not necessary but editing the file looks nicer. ;-)
        }
    }

    /**
     * Write the data from the given URL to the path in the ZIP file; as the data
     * is presumed binary there is no {@link Charset} conversion.
     *<p>
     * FIXME Should the MIME type of the InputStream be checked??
     *
     * @param path location within the ZIP file
     * @param data the binary data to be written
     * @throws IOException
     */
    public void putFile(String path, URL url) throws IOException {
        try (InputStream is = url.openStream()) {
            putFile(path, is);
        }
    }

    public boolean hasFile(String path) throws IOException {
        if (removedFileSet.contains(path))
            return false;

        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists())
            return true;

        boolean ret = false;
        if (file.exists()) {
            ZipFile zipFile = getZipFile();
            ZipEntry ze = zipFile.getEntry(path);
            ret = (ze != null);
        }
        return ret;
    }

    private ZipFile zFile = null;

    private ZipFile getZipFile() throws IOException {
        if (zFile == null)
            zFile = new ZipFile(file);
        return zFile;
    }

    /**
     * Returns a POJO by reading the contents of the zip archive path specified and
     * converting the XML via the associated XStream object.
     * (Because the XML is character data, this routine calls
     * {@link #getFileAsReader(String)} to handle character encoding.)
     * <p>
     * <b>TODO:</b> add {@link ModelVersionManager} support
     * 
     * @param path zip file archive path entry
     * @return Object created by translating the XML
     * @throws IOException
     */
    public Object getFileObject(String path) throws IOException {
        try (Reader r = getFileAsReader(path)) {
            return Persister.newInstance().fromXML(r);
        }
    }

    /**
     * Returns an InputStreamReader that corresponds to the zip file path specified.
     * This method should be called only for character-based file contents such as
     * the <b>CONTENT_FILE</b> and <b>PROPERTY_FILE</b>.  For binary
     * data, such as images (assets and thumbnails) use {@link #getFileAsInputStream(String)}
     * instead.
     * 
     * @param path zip file archive path entry
     * @return Reader representing the data stream
     * @throws IOException
     */
    public Reader getFileAsReader(String path) throws IOException {
        File explodedFile = getExplodedFile(path);
        if ((!file.exists() && !tmpFile.exists() && !explodedFile.exists()) || removedFileSet.contains(path))
            throw new FileNotFoundException(path);
        if (explodedFile.exists())
            return FileUtil.getFileAsReader(explodedFile);

        ZipEntry entry = new ZipEntry(path);
        ZipFile zipFile = getZipFile();
        InputStream in = null;
        try {
            in = new BufferedInputStream(zipFile.getInputStream(entry));
            if (in != null) {
                if (log.isDebugEnabled()) {
                    String type;
                    type = FileUtil.getContentType(in);
                    if (type == null)
                        type = FileUtil.getContentType(explodedFile);
                    log.debug("FileUtil.getContentType() returned " + (type != null ? type : "(null)"));
                }
                return new InputStreamReader(in);
            }
        } catch (Exception ex) {
            // Don't need to close 'in' since zipFile.close() will do so
        }
        throw new FileNotFoundException(path);
    }

    /**
     * Returns an InputStream that corresponds to the zip file path specified.
     * This method should be called only for binary file contents such as
     * images (assets and thumbnails).
     * For character-based data, use {@link #getFileAsReader(String)}
     * instead.
     * 
     * @param path zip file archive path entry
     * @return InputStream representing the data stream
     * @throws IOException
     */
    public InputStream getFileAsInputStream(String path) throws IOException {
        File explodedFile = getExplodedFile(path);
        if ((!file.exists() && !tmpFile.exists() && !explodedFile.exists()) || removedFileSet.contains(path))
            throw new FileNotFoundException(path);
        if (explodedFile.exists())
            return FileUtil.getFileAsInputStream(explodedFile);

        ZipEntry entry = new ZipEntry(path);
        ZipFile zipFile = getZipFile();
        InputStream in = null;
        try {
            in = zipFile.getInputStream(entry);
            if (in != null) {
                String type = FileUtil.getContentType(in);
                if (log.isDebugEnabled() && type != null)
                    log.debug("FileUtil.getContentType() returned " + type);
                return in;
            }
        } catch (Exception ex) {
            // Don't need to close 'in' since zipFile.close() will do so
        }
        throw new FileNotFoundException(path);
    }

    @Override
    public void close() {
        if (zFile != null) {
            IOUtils.closeQuietly(zFile);
            zFile = null;
        }
        if (tmpFile.exists())
            FileUtil.delete(tmpFile);
        propertyMap.clear();
        addedFileSet.clear();
        removedFileSet.clear();
        propsLoaded = false;
        dirty = !file.exists();
    }

    @Override
    protected void finalize() throws Throwable {
        close();
    }

    protected File getExplodedFile(String path) {
        return new File(tmpFile.getAbsolutePath() + "/" + path);
    }

    /**
     * Get all of the path names for this packed file.
     *
     * @return All the path names. Changing this set does not affect the packed file. Changes to the
     * file made after this method is called are not reflected in the path and do not cause a
     * ConcurrentModificationException. Directories in the packed file are also included in the set.
     * @throws IOException Problem with the zip file.
     */
    public Set<String> getPaths() throws IOException {
        Set<String> paths = new HashSet<String>(addedFileSet);
        paths.add(CONTENT_FILE);
        paths.add(PROPERTY_FILE);
        if (file.exists()) {
            ZipFile zf = getZipFile();
            Enumeration<? extends ZipEntry> e = zf.entries();
            while (e.hasMoreElements()) {
                paths.add(e.nextElement().getName());
            }
        }
        paths.removeAll(removedFileSet);
        return paths;
    }

    /** @return Getter for file */
    public File getPackedFile() {
        return file;
    }

    /**
     * Return a URL for a path in this file.
     *
     * @param path Get the url for this path
     * @return URL that can be used to access the file.
     * @throws IOException invalid zip file.
     */
    public URL getURL(String path) throws IOException {
        if (!hasFile(path))
            throw new FileNotFoundException("The path '" + path + "' is not in this packed file.");
        try {

            // Check for exploded first
            File explodedFile = getExplodedFile(path);
            if (explodedFile.exists())
                return explodedFile.toURI().toURL();

            // Otherwise it is in the zip file.
            if (!path.startsWith("/"))
                path = "/" + path;
            String url = "jar:" + file.toURI().toURL().toExternalForm() + "!" + path;
            return new URL(url);
        } catch (MalformedURLException e) {
            throw new IllegalArgumentException("Couldn't create a url for path: '" + path + "'");
        }
    }

    /**
     * Remove a path from the packed file.
     *
     * @param path Remove this path
     */
    public void removeFile(String path) {
        removedFileSet.add(path);
        addedFileSet.remove(path);
        File explodedFile = getExplodedFile(path);
        if (explodedFile.exists()) {
            explodedFile.delete();
        }
        dirty = true;
    }

    /**
     * Remove all files and directories from the zip file.
     *
     * @throws IOException Problem reading the zip file.
     */
    public void removeAll() throws IOException {
        Set<String> paths = getPaths();
        for (String path : paths) {
            removeFile(path);
        } // endfor
    }

    /**
     * Create an output stream that will be written to the packed file. Caller is
     * responsible for closing the stream.
     *
     * @param path Path of the file being saved.
     * @return Stream that can be used to write the data.
     * @throws IOException Error opening the stream.
     */
    public OutputStream getOutputStream(String path) throws IOException {

        if (!tmpFile.exists()) {
            tmpFile.mkdirs();
        }

        File explodedFile = getExplodedFile(path);
        dirty = true;
        if (explodedFile.exists()) {
            return new FileOutputStream(explodedFile);
        } else {
            explodedFile.getParentFile().mkdirs();
        }

        addedFileSet.add(path);
        removedFileSet.remove(path);
        dirty = true;
        return new FileOutputStream(explodedFile);
    }

    public static File getTmpDir(String name) {
        try {
            return File.createTempFile(name, null);
        } catch (IOException e) {
            throw new Error(e);
        }
    }
}