PathAwareFeatureTransferManager.java

/*
 * Syncany, www.syncany.org
 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
 *
 * 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 org.syncany.plugins.transfer.features;

import java.io.File;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.syncany.config.Config;
import org.syncany.plugins.transfer.FileType;
import org.syncany.plugins.transfer.StorageException;
import org.syncany.plugins.transfer.StorageTestResult;
import org.syncany.plugins.transfer.TransferManager;
import org.syncany.plugins.transfer.TransferPlugin;
import org.syncany.plugins.transfer.files.MultichunkRemoteFile;
import org.syncany.plugins.transfer.files.RemoteFile;
import org.syncany.plugins.transfer.files.RemoteFileAttributes;
import org.syncany.util.ReflectionUtil;
import org.syncany.util.StringUtil;

import com.google.common.base.Charsets;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Maps;
import com.google.common.hash.Hashing;

/**
 * The path aware transfer manager can be used to extend a backend storage
 * with the ability to add subfolders to the folders with many files (e.g. multichunks
 * or temporary files). This is especially critical if the backend storage has a limit
 * on how many files can be stored in a single folder (e.g. the Dropbox plugin). 
 * 
 * <p>To enable subfoldering in {@link TransferPlugin}s, the plugin's {@link TransferManager}
 * has to be annotated with the {@link PathAware} annotation, and a {@link PathAwareFeatureExtension}
 * has to be provided.
 * 
 * <p>The sub-path for a {@link RemoteFile} can then be accessed via the
 * {@link PathAwareRemoteFileAttributes} using the {@link RemoteFile#getAttributes(Class)} method.
 * 
 * @see PathAware
 * @see PathAwareFeatureExtension
 * @see PathAwareRemoteFileAttributes
 * @author Christian Roth (christian.roth@port17.de)
 */
public class PathAwareFeatureTransferManager implements FeatureTransferManager {
	private static final Logger logger = Logger.getLogger(PathAwareFeatureTransferManager.class.getSimpleName());

	private final TransferManager underlyingTransferManager;
	
	private final int subfolderDepth;
	private final int bytesPerFolder;
	private final char folderSeparator;
	private final List<Class<? extends RemoteFile>> affectedFiles;
	private final PathAwareFeatureExtension pathAwareFeatureExtension;

	public PathAwareFeatureTransferManager(TransferManager originalTransferManager, TransferManager underlyingTransferManager, Config config, PathAware pathAwareAnnotation) {
		this.underlyingTransferManager = underlyingTransferManager;

		this.subfolderDepth = pathAwareAnnotation.subfolderDepth();
		this.bytesPerFolder = pathAwareAnnotation.bytesPerFolder();
		this.folderSeparator = pathAwareAnnotation.folderSeparator();
		this.affectedFiles = ImmutableList.copyOf(pathAwareAnnotation.affected());

		this.pathAwareFeatureExtension = getPathAwareFeatureExtension(originalTransferManager, pathAwareAnnotation);
	}

	@SuppressWarnings("unchecked")
	private PathAwareFeatureExtension getPathAwareFeatureExtension(TransferManager originalTransferManager, PathAware pathAwareAnnotation) {
		Class<? extends TransferManager> originalTransferManagerClass = originalTransferManager.getClass();
		Class<PathAwareFeatureExtension> pathAwareFeatureExtensionClass = (Class<PathAwareFeatureExtension>) pathAwareAnnotation.extension();

		try {
			Constructor<?> constructor = ReflectionUtil.getMatchingConstructorForClass(pathAwareFeatureExtensionClass, originalTransferManagerClass);

			if (constructor != null) {
				return (PathAwareFeatureExtension) constructor.newInstance(originalTransferManager);
			}

			return pathAwareFeatureExtensionClass.newInstance();
		}
		catch (InvocationTargetException | InstantiationException | IllegalAccessException | NullPointerException e) {
			throw new RuntimeException("Cannot instantiate PathAwareFeatureExtension (perhaps " + pathAwareFeatureExtensionClass + " does not exist?)", e);
		}
	}

	@Override
	public void connect() throws StorageException {
		underlyingTransferManager.connect();
	}

	@Override
	public void disconnect() throws StorageException {
		underlyingTransferManager.disconnect();
	}

	@Override
	public void init(final boolean createIfRequired) throws StorageException {
		underlyingTransferManager.init(createIfRequired);
	}

	@Override
	public void download(final RemoteFile remoteFile, final File localFile) throws StorageException {
		underlyingTransferManager.download(createPathAwareRemoteFile(remoteFile), localFile);
	}

	@Override
	public void move(final RemoteFile sourceFile, final RemoteFile targetFile) throws StorageException {
		final RemoteFile pathAwareSourceFile = createPathAwareRemoteFile(sourceFile);
		final RemoteFile pathAwareTargetFile = createPathAwareRemoteFile(targetFile);

		if (!createFolder(pathAwareTargetFile)) {
			throw new StorageException("Unable to create path for " + pathAwareTargetFile);
		}

		underlyingTransferManager.move(pathAwareSourceFile, pathAwareTargetFile);
		removeFolder(pathAwareSourceFile);
	}

	@Override
	public void upload(final File localFile, final RemoteFile remoteFile) throws StorageException {
		final RemoteFile pathAwareRemoteFile = createPathAwareRemoteFile(remoteFile);

		if (!createFolder(pathAwareRemoteFile)) {
			throw new StorageException("Unable to create path for " + pathAwareRemoteFile);
		}

		underlyingTransferManager.upload(localFile, pathAwareRemoteFile);
	}

	@Override
	public boolean delete(final RemoteFile remoteFile) throws StorageException {
		RemoteFile pathAwareRemoteFile = createPathAwareRemoteFile(remoteFile);
		
		boolean fileDeleted = underlyingTransferManager.delete(pathAwareRemoteFile);
		boolean folderDeleted = removeFolder(pathAwareRemoteFile);

		return fileDeleted && folderDeleted;
	}

	@Override
	public <T extends RemoteFile> Map<String, T> list(final Class<T> remoteFileClass) throws StorageException {
		Map<String, T> filesInFolder = Maps.newHashMap();
		String remoteFilePath = getRemoteFilePath(remoteFileClass);

		list(remoteFilePath, filesInFolder, remoteFileClass);

		return filesInFolder;
	}

	private <T extends RemoteFile> void list(String remoteFilePath, Map<String, T> remoteFiles, Class<T> remoteFileClass) throws StorageException {
		logger.log(Level.INFO, "Listing folder for files matching " + remoteFileClass.getSimpleName() + ": " + remoteFilePath);
		Map<String, FileType> folderList = pathAwareFeatureExtension.listFolder(remoteFilePath);
		
		for (Map.Entry<String, FileType> folderListEntry : folderList.entrySet()) {
			String fileName = folderListEntry.getKey();
			FileType fileType = folderListEntry.getValue();
			
			if (fileType == FileType.FILE) {
				try {
					remoteFiles.put(fileName, RemoteFile.createRemoteFile(fileName, remoteFileClass));
					logger.log(Level.INFO, "- File: " + fileName);					
				}
				catch (StorageException e) {
					// We don't care and ignore non-matching files!
				}
			}
			else if (fileType == FileType.FOLDER) {
				logger.log(Level.INFO, "- Folder: " + fileName);

				String newRemoteFilePath = remoteFilePath + folderSeparator + fileName;
				list(newRemoteFilePath, remoteFiles, remoteFileClass);
			}
		}
	}

	@Override
	public String getRemoteFilePath(Class<? extends RemoteFile> remoteFileClass) {
		return underlyingTransferManager.getRemoteFilePath(remoteFileClass);
	}

	@Override
	public StorageTestResult test(boolean testCreateTarget) {
		return underlyingTransferManager.test(testCreateTarget);
	}

	@Override
	public boolean testTargetExists() throws StorageException {
		return underlyingTransferManager.testTargetExists();
	}

	@Override
	public boolean testTargetCanWrite() throws StorageException {
		return underlyingTransferManager.testTargetCanWrite();
	}

	@Override
	public boolean testTargetCanCreate() throws StorageException {
		return underlyingTransferManager.testTargetCanCreate();
	}

	@Override
	public boolean testRepoFileExists() throws StorageException {
		return underlyingTransferManager.testRepoFileExists();
	}

	private boolean isFolderizable(Class<? extends RemoteFile> remoteFileClass) {
		return affectedFiles.contains(remoteFileClass);
	}

	private RemoteFile createPathAwareRemoteFile(RemoteFile remoteFile) throws StorageException {		
		PathAwareRemoteFileAttributes pathAwareRemoteFileAttributes = new PathAwareRemoteFileAttributes();
		remoteFile.setAttributes(pathAwareRemoteFileAttributes);

		if (isFolderizable(remoteFile.getClass())) {
			// If remote file is folderizable, i.e. an 'affected file', 
			// get the sub-path for it
			
			String subPathId = getSubPathId(remoteFile);	
			String subPath = getSubPath(subPathId);
			
			pathAwareRemoteFileAttributes.setPath(subPath);
		}
		
		return remoteFile;
	}

	/**
	 * Returns the subpath identifier for this file. For {@link MultichunkRemoteFile}s, this is the
	 * hex string of the multichunk identifier. For all other files, this is the 128-bit murmur3 hash
	 * of the full filename (fast algorithm!).
	 */
	private String getSubPathId(RemoteFile remoteFile) {
		if (remoteFile.getClass() == MultichunkRemoteFile.class) {
			return StringUtil.toHex(((MultichunkRemoteFile) remoteFile).getMultiChunkId());
		}
		else {
			return StringUtil.toHex(Hashing.murmur3_128().hashString(remoteFile.getName(), Charsets.UTF_8).asBytes());
		}
	}

	private String getSubPath(String fileId) {
		StringBuilder path = new StringBuilder();

		for (int i = 0; i < subfolderDepth; i++) {
			String subPathPart = fileId.substring(i * bytesPerFolder * 2, (i + 1) * bytesPerFolder * 2); 
			
			path.append(subPathPart);
			path.append(folderSeparator);
		}
		
		return path.toString();
	}

	private String pathToString(Path path) {
		return path.toString().replace(File.separator, String.valueOf(folderSeparator));
	}

	private boolean createFolder(RemoteFile remoteFile) throws StorageException {
		PathAwareRemoteFileAttributes pathAwareRemoteFileAttributes = remoteFile.getAttributes(PathAwareRemoteFileAttributes.class);		
		boolean notAPathAwareRemoteFile = pathAwareRemoteFileAttributes == null || !pathAwareRemoteFileAttributes.hasPath();
		
		if (notAPathAwareRemoteFile) {
			return true;
		} 
		else {
			String remoteFilePath = pathToString(Paths.get(underlyingTransferManager.getRemoteFilePath(remoteFile.getClass()), pathAwareRemoteFileAttributes.getPath()));
	
			logger.log(Level.INFO, "Remote file is path aware, creating folder " + remoteFilePath);
			boolean success = pathAwareFeatureExtension.createPath(remoteFilePath);
			
			return success;
		}
	}

	private boolean removeFolder(RemoteFile remoteFile) throws StorageException {
		PathAwareRemoteFileAttributes pathAwareRemoteFileAttributes = remoteFile.getAttributes(PathAwareRemoteFileAttributes.class);
		boolean notAPathAwareRemoteFile = pathAwareRemoteFileAttributes == null || !pathAwareRemoteFileAttributes.hasPath();
		
		if (notAPathAwareRemoteFile) {
			return true;
		}
		else {
			String remoteFilePath = pathToString(Paths.get(underlyingTransferManager.getRemoteFilePath(remoteFile.getClass()), pathAwareRemoteFileAttributes.getPath()));

			logger.log(Level.INFO, "Remote file is path aware, cleaning empty folders at " + remoteFilePath);
			boolean success = removeFolder(remoteFilePath);
		
			return success;
		}
	}

	private boolean removeFolder(String folder) throws StorageException {
		for(int i = 0; i < subfolderDepth; i++) {
			logger.log(Level.FINE, "Removing folder " + folder);

			if (pathAwareFeatureExtension.listFolder(folder).size() != 0) {
				return true;
			}

			if (!pathAwareFeatureExtension.removeFolder(folder)) {
				return false;
			}

			folder = folder.substring(0, folder.lastIndexOf(folderSeparator));
		}

		return true;
	}

	public static class PathAwareRemoteFileAttributes extends RemoteFileAttributes {
		private String path;

		public boolean hasPath() {
			return path != null;
		}

		public String getPath() {
			return path;
		}

		public void setPath(String path) {
			this.path = path;
		}
	}

}