/*
* Copyright 2004 - 2008 Christian Sprajc, Dennis Waldherr. All rights reserved.
*
* This file is part of PowerFolder.
*
* PowerFolder 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.
*
* PowerFolder 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 PowerFolder. If not, see <http://www.gnu.org/licenses/>.
*
* $Id: $
*/
package de.dal33t.powerfolder.disk;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import de.dal33t.powerfolder.light.FileInfo;
import de.dal33t.powerfolder.light.FileInfoFactory;
import de.dal33t.powerfolder.light.FolderInfo;
import de.dal33t.powerfolder.light.MemberInfo;
import de.dal33t.powerfolder.util.FileUtils;
import de.dal33t.powerfolder.util.Reject;
import de.dal33t.powerfolder.util.StreamUtils;
import de.dal33t.powerfolder.util.Util;
import de.schlichtherle.truezip.file.TFile;
/**
* A file archiver that tries to move a file to an archive first, and falls back
* to copying otherwise, or if forced to. <i>Note:</i> No support for removal of
* old files (yet) - special care of directories might be required Archives are
* stored in an archives directory, with suffix '_K_nnn', where 'nnn' is the
* version number. So 'data/info.txt' archive version 6 would be
* 'archive/data/info.txt_K_6'.
*
* @author dante
*/
public class FileArchiver {
private static final Logger log = Logger.getLogger(FileArchiver.class
.getName());
private static final VersionComparator VERSION_COMPARATOR = new VersionComparator();
private static final Pattern BASE_NAME_PATTERN = Pattern
.compile("(.*)_K_\\d+");
private static final String SIZE_INFO_FILE = "Size";
private final File archiveDirectory;
private volatile int versionsPerFile;
private MemberInfo mySelf;
/**
* Cached size of this file archive.
*/
private Long size;
/**
* Constructs a new FileArchiver which stores backups in the given
* directory.
*
* @param archiveDirectory
* @param mySelf
* myself
*/
public FileArchiver(File archiveDirectory, MemberInfo mySelf) {
Reject.notNull(archiveDirectory, "archiveDirectory");
Reject.ifNull(mySelf, "Myself");
this.archiveDirectory = archiveDirectory;
// Default: Store unlimited # of files
versionsPerFile = -1;
this.mySelf = mySelf;
this.size = loadSize();
}
private Long loadSize() {
File sizeFile = new File(archiveDirectory, SIZE_INFO_FILE);
if (!sizeFile.exists()) {
return null;
}
FileInputStream fin = null;
try {
fin = new FileInputStream(sizeFile);
byte[] buf = StreamUtils.readIntoByteArray(fin);
return Long.valueOf(new String(buf));
} catch (Exception e) {
log.fine("Unable to read size of archive to " + sizeFile + ". " + e);
return null;
} finally {
if (fin != null) {
try {
fin.close();
} catch (IOException e) {
}
}
}
}
/**
* @see FileArchiver#archive(FileInfo, File, boolean)
*/
public void archive(FileInfo fileInfo, File source, boolean forceKeepSource)
throws IOException
{
Reject.notNull(fileInfo, "fileInfo");
Reject.notNull(source, "source");
if (versionsPerFile == 0) {
// Optimization for zero-archive
if (!forceKeepSource) {
if (source.exists() && !source.delete()) {
log.warning("Unable to remove old file " + source);
}
source.delete();
return;
}
}
File target = getArchiveTarget(fileInfo);
if (target.exists()) {
log.warning("File " + fileInfo.toDetailString()
+ " seems to be archived already, doing nothing.");
// Maybe throw Exception instead?
return;
}
long oldSize = getSize();
if (target.getParentFile().exists() || target.getParentFile().mkdirs())
{
// Reset cache
// size = null;
boolean tryCopy = forceKeepSource;
if (!tryCopy) {
if (!source.renameTo(target)) {
log.severe("Failed to rename " + source
+ ", falling back to copying");
tryCopy = true;
} else if (size != null) {
size += target.length();
}
}
if (tryCopy) {
long lastModified = source.lastModified();
FileUtils.copyFile(source, target);
// Preserve last modification date.
target.setLastModified(lastModified);
if (size != null) {
size += target.length();
}
}
if (log.isLoggable(Level.FINE)) {
log.fine("Archived " + fileInfo.toDetailString() + " from "
+ source + " to " + target);
}
// Success, now check if we have to remove a file
File[] list = getArchivedFiles(target.getParentFile(),
fileInfo.getFilenameOnly());
checkArchivedFile(list);
if (oldSize != size) {
saveSize();
}
} else {
throw new IOException("Failed to create directory: "
+ target.getParent());
}
}
private void checkArchivedFile(File[] versions) throws IOException {
assert versions != null;
if (versionsPerFile < 0) {
// Unlimited. Don't check
return;
}
if (versions.length <= versionsPerFile) {
return;
}
Arrays.sort(versions, VERSION_COMPARATOR);
int toDelete = versions.length - versionsPerFile;
long oldSize = size;
for (File f : versions) {
if (toDelete <= 0) {
break;
}
toDelete--;
long len = f.length();
if (!f.delete()) {
throw new IOException("Could not delete old version: " + f);
} else {
if (size != null) {
size -= len;
}
if (log.isLoggable(Level.FINE)) {
log.fine("checkArchivedFile: Deleted archived file " + f);
}
}
}
if (oldSize != size) {
saveSize();
}
}
/**
* Tries to ensure that only the allowed amount of versions per file is in
* the archive.
*
* @return true the maintenance worked successfully for all files, false if
* it failed for at least one file
*/
public synchronized boolean maintain() {
boolean check = checkRecursive(archiveDirectory, new HashSet<File>());
size = null;
return check;
}
private boolean checkRecursive(File dir, Set<File> checked) {
assert dir != null && dir.isDirectory();
assert checked != null;
if (dir == null || !dir.exists() || !dir.isDirectory()) {
// Is empty or not existent.
return true;
}
boolean allSuccessful = true;
File[] flist = dir.listFiles();
Map<String, Collection<File>> fileMap = new HashMap<String, Collection<File>>();
for (File f : flist) {
if (f.getName().equals(SIZE_INFO_FILE)) {
continue;
}
if (f.isDirectory()) {
boolean thisSuccessfuly = checkRecursive(f, checked);
if (thisSuccessfuly) {
f.delete();
}
allSuccessful &= thisSuccessfuly;
} else {
String baseName = getBaseName(f);
File vf = new TFile(dir, baseName);
if (!checked.contains(vf)) {
checked.add(vf);
}
Collection<File> files = fileMap.get(baseName);
if (files == null) {
files = new LinkedList<File>();
fileMap.put(baseName, files);
}
files.add(f);
}
}
for (Collection<File> files : fileMap.values()) {
try {
checkArchivedFile(files.toArray(new File[files.size()]));
} catch (IOException e) {
allSuccessful = false;
log.log(Level.WARNING, "Failed to check " + files, e);
}
}
return allSuccessful;
}
private static String getBaseName(File file) {
Matcher m = BASE_NAME_PATTERN.matcher(file.getName());
if (m.matches()) {
return m.group(1);
} else {
throw new IllegalArgumentException("File not in archive: " + file);
}
}
private TFile getArchiveTarget(FileInfo fileInfo) {
return new TFile(archiveDirectory,
FileInfoFactory.encodeIllegalChars(fileInfo.getRelativeName())
+ "_K_" + fileInfo.getVersion());
}
private String getFileInfoName(File fileInArchive) {
return buildFileName(archiveDirectory, fileInArchive);
}
private static String buildFileName(File baseDirectory, File file) {
String fn = FileInfoFactory.decodeIllegalChars(file.getName());
int i = fn.lastIndexOf("_K_");
if (i >= 0) {
fn = fn.substring(0, i);
}
File parent = file.getParentFile();
while (!baseDirectory.equals(parent)) {
if (parent == null) {
throw new IllegalArgumentException(
"Local file seems not to be in a subdir of the local powerfolder copy");
}
fn = FileInfoFactory.decodeIllegalChars(parent.getName()) + '/'
+ fn;
parent = parent.getParentFile();
}
return fn;
}
/**
* Parse the file name for the last '_' and extract the following version
* number. Like 'file.txt_K_45' returns 45.
*
* @param file
* file to parse name.
* @return the version.
*/
private static int getVersionNumber(File file) {
String tmp = file.getName();
tmp = tmp.substring(tmp.lastIndexOf('_') + 1);
return Integer.parseInt(tmp);
}
private static File[] getArchivedFiles(File directory, final String baseName)
{
return directory.listFiles(new FilenameFilter() {
public boolean accept(File dir, String name) {
return belongsTo(name, baseName);
}
});
}
private static boolean belongsTo(String name, String baseName) {
Matcher m = BASE_NAME_PATTERN.matcher(name);
if (m.matches()) {
return Util.equalsRelativeName(m.group(1), baseName);
}
return false;
}
public boolean hasArchivedFileInfo(FileInfo fileInfo) {
Reject.ifNull(fileInfo, "FileInfo is null");
// Find archive subdirectory.
File subdirectory = FileUtils.buildFileFromRelativeName(
archiveDirectory,
FileInfoFactory.encodeIllegalChars(fileInfo.getRelativeName()))
.getParentFile();
if (!subdirectory.exists()) {
return false;
}
String[] files = subdirectory.list();
if (files == null || files.length == 0) {
return false;
}
String fn = FileInfoFactory.encodeIllegalChars(fileInfo
.getFilenameOnly());
for (String fileName : files) {
if (fileName.startsWith(fn)) {
return true;
}
}
return false;
}
public List<FileInfo> getArchivedFilesInfos(FileInfo fileInfo) {
Reject.ifNull(fileInfo, "FileInfo is null");
// Find archive subdirectory.
File subdirectory = FileUtils.buildFileFromRelativeName(
archiveDirectory,
FileInfoFactory.encodeIllegalChars(fileInfo.getRelativeName()))
.getParentFile();
if (!subdirectory.exists()) {
return Collections.emptyList();
}
File target = getArchiveTarget(fileInfo);
File[] archivedFiles = getArchivedFiles(target.getParentFile(),
FileInfoFactory.encodeIllegalChars(fileInfo.getFilenameOnly()));
if (archivedFiles == null || archivedFiles.length == 0) {
return Collections.emptyList();
}
List<FileInfo> list = new ArrayList<FileInfo>();
FolderInfo foInfo = fileInfo.getFolderInfo();
for (File file : archivedFiles) {
int version = getVersionNumber(file);
Date modDate = new Date(file.lastModified());
String name = getFileInfoName(file);
FileInfo archiveFile = FileInfoFactory.archivedFile(foInfo, name,
file.length(), mySelf, modDate, version);
list.add(archiveFile);
}
// Read-only, so others don't trash this.
return Collections.unmodifiableList(list);
}
/**
* Comparator for comparing file versions.
*/
private static class VersionComparator implements Comparator<File> {
public int compare(File o1, File o2) {
return getVersionNumber(o1) - getVersionNumber(o2);
}
}
/**
* Restore a file version.
*
* @param versionInfo
* the FileInfo of the archived file.
* @param target
*/
public boolean restore(FileInfo versionInfo, File target)
throws IOException
{
TFile archiveFile = getArchiveTarget(versionInfo);
if (archiveFile.exists()) {
log.fine("Restoring " + versionInfo.getRelativeName() + " to "
+ target.getAbsolutePath());
if (target.getParentFile() != null
&& !target.getParentFile().exists())
{
target.getParentFile().mkdirs();
}
archiveFile.cp(target);
// FileUtils.copyFile(archiveFile, target);
// #2256: New modification date. Otherwise conflict detection
// triggers
// target.setLastModified(versionInfo.getModifiedDate().getTime());
return true;
} else {
return false;
}
}
public int getVersionsPerFile() {
return versionsPerFile;
}
public void setVersionsPerFile(int versionsPerFile) {
this.versionsPerFile = versionsPerFile;
}
public synchronized long getSize() {
if (size == null) {
long s = FileUtils.calculateDirectorySizeAndCount(archiveDirectory)[0];
File sizeFile = new File(archiveDirectory, SIZE_INFO_FILE);
if (sizeFile.exists()) {
s -= sizeFile.length();
}
size = s;
saveSize();
}
return size;
}
public void purge() throws IOException {
FileUtils.recursiveDelete(archiveDirectory);
size = 0L;
saveSize();
}
/**
* Delete archives older that a specified number of days.
*
* @param cleanupDate
* Age in days of archive files to delete.
*/
public void cleanupOldArchiveFiles(Date cleanupDate) {
log.info("Cleaning up " + archiveDirectory + " for files older than "
+ cleanupDate);
cleanupOldArchiveFiles(archiveDirectory, cleanupDate);
}
private static void cleanupOldArchiveFiles(File file, Date cleanupDate) {
if (file.isDirectory()) {
for (File file1 : file.listFiles()) {
cleanupOldArchiveFiles(file1, cleanupDate);
}
} else {
Date age = new Date(file.lastModified());
if (age.before(cleanupDate)) {
if (log.isLoggable(Level.FINE)) {
log.fine("Deleting old archive file " + file + " (" + age
+ ')');
}
try {
file.delete();
} catch (SecurityException e) {
log.severe("Could not delete archive file " + file);
}
}
}
}
private void saveSize() {
File sizeFile = new File(archiveDirectory, SIZE_INFO_FILE);
ByteArrayInputStream bin = new ByteArrayInputStream(String
.valueOf(size).getBytes());
try {
FileUtils.copyFromStreamToFile(bin, sizeFile);
} catch (IOException e) {
log.fine("Unable to store size of archive to " + sizeFile);
}
}
}