just before adding authorization service.
[photos] / src / main / java / org / wamblee / photos / model / filesystem / FileSystemAlbum.java
diff --git a/src/main/java/org/wamblee/photos/model/filesystem/FileSystemAlbum.java b/src/main/java/org/wamblee/photos/model/filesystem/FileSystemAlbum.java
new file mode 100644 (file)
index 0000000..68a8faf
--- /dev/null
@@ -0,0 +1,731 @@
+/*
+ * Copyright 2005 the original author or authors.
+ * 
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ * 
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ * 
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.wamblee.photos.model.filesystem;
+
+import java.awt.image.BufferedImage;
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.wamblee.cache.Cache;
+import org.wamblee.cache.CachedObject;
+import org.wamblee.general.Pair;
+import org.wamblee.photos.model.Album;
+import org.wamblee.photos.model.Path;
+import org.wamblee.photos.model.Photo;
+import org.wamblee.photos.model.PhotoEntry;
+import org.wamblee.photos.utils.JpegUtils;
+
+/**
+ * Represents a photo album stored in a directory structure on the file system.
+ */
+public class FileSystemAlbum implements Album {
+
+    private static final Log LOG = LogFactory.getLog(FileSystemAlbum.class);
+
+    /**
+     * Subdirectory where the thumbnails are stored.
+     */
+    public static final String THUMBNAILS_DIR = "thumbnails";
+
+    /**
+     * Subdirectory where the photos are stored in their full size.
+     */
+    public static final String PHOTOS_DIR = "fotos";
+
+    /**
+     * Extension used for JPEG pictures.
+     */
+    private static final String JPG_EXTENSION = ".jpg";
+
+    /**
+     * Last part of the file name that a thumbnail must end with.
+     */
+    private static final String THUMBNAIL_ENDING = "_thumb.jpg";
+
+    private static final int THUMBNAIL_WIDTH = 100;
+
+    private static final int THUMBNAIL_HEIGHT = 100;
+
+    private static final int JPG_QUALITY = 75;
+
+    /**
+     * Array of photo entries.
+     */
+    private CachedObject<String, ArrayList<PhotoEntry>> _entries;
+
+    /**
+     * Storage directory for this album.
+     */
+    private File _dir;
+
+    /**
+     * Relative path with respect to the root album.
+     */
+    private String _path;
+
+    private class CreateEntryCallback implements EntryFoundCallback {
+        public boolean photoFound(List<PhotoEntry> aEntries, File aThumbnail,
+            File aPhoto, String aPath) {
+            PhotoEntry entry = new FileSystemPhoto(aThumbnail, aPhoto, aPath);
+            aEntries.add(entry);
+            return true;
+        }
+
+        public boolean albumFound(List<PhotoEntry> aEntries, File aAlbum,
+            String aPath) throws IOException {
+            PhotoEntry entry = new FileSystemAlbum(aAlbum, aPath,
+                _entries.getCache());
+            aEntries.add(entry);
+            return true;
+        }
+    }
+
+    /**
+     * Creates the album.
+     * 
+     * @param aDir
+     *            Directory where the album is located.
+     * @param aPath
+     *            Path that this album represents.
+     * @param aCache
+     *            Cache to use.
+     * @throws IOException
+     */
+    public FileSystemAlbum(File aDir, String aPath,
+        Cache<String, ArrayList<PhotoEntry>> aCache) throws IOException {
+        if (!aDir.isDirectory()) {
+            throw new IOException("Directory '" + aDir.getPath() +
+                "' does not exist.");
+        }
+        _dir = aDir;
+        _path = aPath;
+        _entries = new CachedObject<String, ArrayList<PhotoEntry>>(aCache,
+            aPath,
+            new CachedObject.Computation<String, ArrayList<PhotoEntry>>() {
+                public ArrayList<PhotoEntry> getObject(String aObjectKey) {
+                    return FileSystemAlbum.this.compute();
+                }
+            });
+
+    }
+
+    /**
+     * Computes the photo entries for this album based on the file system.
+     * 
+     * @return List of photo entries.
+     */
+    private ArrayList<PhotoEntry> compute() {
+        ArrayList<PhotoEntry> result = new ArrayList<PhotoEntry>();
+        try {
+            LOG.info("Initializing album for directory " + _dir);
+            traverse(getPath(), result, _dir, new CreateEntryCallback());
+        } catch (IOException e) {
+            LOG.fatal("IOException occurred: " + e.getMessage(), e);
+        }
+        return result;
+    }
+
+    /**
+     * Initializes the album.
+     * 
+     * @param aPath
+     *            Path of the album
+     * @param aEntries
+     *            Photo entries for the album.
+     * @param aDir
+     *            Directory where the album is stored.
+     * @throws IOException
+     */
+    static boolean traverse(String aPath, List<PhotoEntry> aEntries, File aDir,
+        EntryFoundCallback aCallback) throws IOException {
+        File[] files = listFilesInDir(aDir);
+        for (int i = 0; i < files.length; i++) {
+            File file = files[i];
+            if (file.isDirectory()) {
+
+                if (file.getName().equals(THUMBNAILS_DIR)) {
+                    if (!traverseThumbnails(aPath, aEntries, aDir, aCallback,
+                        file)) {
+                        return false;
+                    }
+                } else if (file.getName().equals(PHOTOS_DIR)) {
+                    // Skip the photos directory.
+                } else {
+                    // A nested album.
+                    String newPath = aPath + "/" + file.getName();
+                    newPath = newPath.replaceAll("//", "/");
+                    if (!aCallback.albumFound(aEntries, file, newPath)) {
+                        return false; // finished.
+                    }
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Traverse the thumnails directory.
+     * 
+     * @param aPath
+     *            Path of the photo album.
+     * @param aEntries
+     *            Entries of the album.
+     * @param aDir
+     *            Directory of the album.
+     * @param aCallback
+     *            Callback to call when a thumbnail has been found.
+     * @param aFile
+     *            Directory of the photo album.
+     */
+    private static boolean traverseThumbnails(String aPath,
+        List<PhotoEntry> aEntries, File aDir, EntryFoundCallback aCallback,
+        File aFile) {
+        // Go inside the thumbnails directory to scan
+        // for available photos.
+
+        // Check if the corresponding photos directory
+        // exists
+        File photosDir = new File(aDir, PHOTOS_DIR);
+        if (photosDir.isDirectory()) {
+            if (!buildAlbum(aPath, aEntries, aFile, photosDir, aCallback)) {
+                return false;
+            }
+        } else {
+            LOG.info("Thumbnails director " + aFile.getPath() +
+                " exists but corresponding photo directory " +
+                photosDir.getPath() + " not found");
+        }
+        return true;
+    }
+
+    /**
+     * Builds up the photo album for a given thumbnails and photo directory.
+     * 
+     * @param aPath
+     *            Path of the album.
+     * @param aEntries
+     *            Photo entries of the album.
+     * @param aThumbnailsDir
+     *            Directory containing thumbnail pictures.
+     * @param aPhotosDir
+     *            Directory containing full size photos.
+     */
+    private static boolean buildAlbum(String aPath, List<PhotoEntry> aEntries,
+        File aThumbnailsDir, File aPhotosDir, EntryFoundCallback aCallback) {
+
+        File[] thumbnails = listFilesInDir(aThumbnailsDir);
+        for (int i = 0; i < thumbnails.length; i++) {
+            File thumbnail = thumbnails[i];
+            if (!isThumbNail(thumbnail)) {
+                LOG.info("Skipping " + thumbnail.getPath() +
+                    " because it is not a thumbnail file.");
+                continue;
+            }
+            File photo = new File(aPhotosDir, photoName(thumbnail.getName()));
+            if (!photo.isFile()) {
+                LOG.info("Photo file " + photo.getPath() + " for thumbnail " +
+                    thumbnail.getPath() + " does not exist.");
+                continue;
+            }
+            String photoPath = photo.getName();
+            photoPath = photoPath.substring(0, photoPath.length() -
+                JPG_EXTENSION.length());
+            photoPath = aPath + "/" + photoPath;
+            photoPath = photoPath.replaceAll("//", "/");
+            if (!aCallback.photoFound(aEntries, thumbnail, photo, photoPath)) {
+                return false;
+            }
+        }
+        return true; // continue.
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.PhotoEntry#getPath()
+     */
+    public String getPath() {
+        return _path;
+    }
+
+    /**
+     * Checks if the file represents a thumbnail.
+     * 
+     * @param aFile
+     *            File to check.
+     * @return True iff the file is a thumbnail.
+     */
+    private static boolean isThumbNail(File aFile) {
+        return aFile.getName().endsWith(THUMBNAIL_ENDING);
+    }
+
+    /**
+     * Constructs the photo name based on the thumbnail name.
+     * 
+     * @param aThumbnailName
+     *            Thumbnail name.
+     * @return Photo name.
+     */
+    private static String photoName(String aThumbnailName) {
+        return aThumbnailName.substring(0, aThumbnailName.length() -
+            THUMBNAIL_ENDING.length()) +
+            JPG_EXTENSION;
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#getEntry(java.lang.String)
+     */
+    public PhotoEntry getEntry(String aPath) {
+        if (!aPath.startsWith("/")) {
+            throw new IllegalArgumentException(
+                "Path must start with / character");
+        }
+        if (aPath.equals("/")) {
+            return this;
+        }
+        String[] fields = aPath.substring(1).split("/");
+        return getEntry(fields, 0);
+    }
+
+    /**
+     * Gets the entry for the given path.
+     */
+    public PhotoEntry getEntry(Path aPath) {
+        return getEntry(aPath.toString());
+    }
+
+    /**
+     * Gets the entry at a given path.
+     * 
+     * @param aPath
+     *            Array of components of the path.
+     * @param aLevel
+     *            Current level in the path.
+     * @return Entry.
+     */
+    private PhotoEntry getEntry(String[] aPath, int aLevel) {
+        String id = aPath[aLevel];
+        PhotoEntry entry = find(id);
+        if (entry == null) {
+            return entry;
+        }
+        if (aLevel < aPath.length - 1) {
+            if (!(entry instanceof Album)) {
+                return null;
+            }
+            return ((FileSystemAlbum) entry).getEntry(aPath, aLevel + 1);
+        } else {
+            return entry; // end of the path.
+        }
+    }
+
+    /**
+     * Finds an entry in the album with the given id.
+     * 
+     * @param aId
+     *            Photo entry id.
+     * @return Photo entry.
+     */
+    private PhotoEntry find(String aId) {
+        return findInMap(aId).getFirst();
+    }
+
+    /**
+     * Finds a photo entry in the map.
+     * 
+     * @param aId
+     *            Id of the photo entry.
+     * @return Pair of a photo entry and the index of the item.
+     */
+    private Pair<PhotoEntry, Integer> findInMap(String aId) {
+        List<PhotoEntry> entries = _entries.get();
+        for (int i = 0; i < entries.size(); i++) {
+            PhotoEntry entry = entries.get(i);
+            if (entry.getId().equals(aId)) {
+                return new Pair<PhotoEntry, Integer>(entry, i);
+            }
+        }
+        return new Pair<PhotoEntry, Integer>(null, -1);
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#getEntry(int)
+     */
+    public PhotoEntry getEntry(int aIndex) {
+        return _entries.get().get(aIndex);
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#size()
+     */
+    public int size() {
+        return _entries.get().size();
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.PhotoEntry#getId()
+     */
+    public String getId() {
+        return _dir.getName();
+    }
+
+    public String toString() {
+        return print("");
+    }
+
+    /**
+     * Prints the album with a given indentation.
+     * 
+     * @param aIndent
+     *            Indentation string.
+     * @return String representation of the album.
+     */
+    private String print(String aIndent) {
+        String res = aIndent + "ALBUM: " + getId() + "\n";
+        aIndent += "  ";
+        for (int i = 0; i < size(); i++) {
+            PhotoEntry entry = getEntry(i);
+            if (entry instanceof FileSystemAlbum) {
+                res += ((FileSystemAlbum) entry).print(aIndent);
+            } else {
+                res += ((FileSystemPhoto) entry).print(aIndent);
+            }
+            res += "\n";
+        }
+        return res;
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#addImage(java.lang.String,
+     *      java.awt.Image)
+     */
+    public void addImage(String aId, InputStream aImageStream)
+        throws IOException {
+        File photoDir = getPhotoDir();
+        File thumbnailDir = getThumbnailDir();
+
+        checkPhotoDirs(aId, photoDir, thumbnailDir);
+
+        BufferedImage image;
+        try {
+            image = JpegUtils.loadJpegImage(aImageStream);
+        } catch (InterruptedException e) {
+            throw new IOException("Loading image interruptedS: " +
+                e.getMessage());
+        }
+        BufferedImage thumbnailImage = JpegUtils.scaleImage(THUMBNAIL_WIDTH,
+            THUMBNAIL_HEIGHT, image);
+
+        File thumbnail = new File(thumbnailDir, aId + THUMBNAIL_ENDING);
+        File photo = new File(photoDir, aId + JPG_EXTENSION);
+
+        writeImage(photo, image);
+        writeImage(thumbnail, thumbnailImage);
+
+        Photo photoEntry = new FileSystemPhoto(thumbnail, photo, (getPath()
+            .equals("/") ? "" : getPath()) + "/" + aId);
+        addEntry(photoEntry);
+    }
+
+    /**
+     * Checks if a given entry can be added to the album.
+     * 
+     * @param aId
+     *            Id of the new entry.
+     * @param aPhotoDir
+     *            Photo directory.
+     * @param aThumbnailDir
+     *            Thumbnail directory.
+     * @throws IOException
+     */
+    private void checkPhotoDirs(String aId, File aPhotoDir, File aThumbnailDir)
+        throws IOException {
+        // Create directories if they do not already exist.
+        aPhotoDir.mkdir();
+        aThumbnailDir.mkdir();
+
+        if (!aPhotoDir.isDirectory() || !aThumbnailDir.isDirectory()) {
+            throw new IOException("Album " + getId() +
+                " does not accept new images.");
+        }
+
+        if (getEntry("/" + aId) != null) {
+            throw new IOException("An entry with the name '" + aId +
+                "' already exists in the album " + getPath());
+        }
+    }
+
+    /**
+     * Gets the thumbnail directory for the album.
+     * 
+     * @return Directory.
+     */
+    private File getThumbnailDir() {
+        File thumbnailDir = new File(_dir, THUMBNAILS_DIR);
+        return thumbnailDir;
+    }
+
+    /**
+     * Gets the photo directory for the album.
+     * 
+     * @return Photo directory
+     */
+    private File getPhotoDir() {
+        File photoDir = new File(_dir, PHOTOS_DIR);
+        return photoDir;
+    }
+
+    /**
+     * Writes an image to a file.
+     * 
+     * @param aFile
+     *            File to write to.
+     * @param aImage
+     *            Image.
+     * @throws IOException
+     */
+    private static void writeImage(File aFile, BufferedImage aImage)
+        throws IOException {
+        OutputStream output = new BufferedOutputStream(new FileOutputStream(
+            aFile));
+        JpegUtils.writeJpegImage(output, JPG_QUALITY, aImage);
+        output.close();
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#addAlbum(java.lang.String)
+     */
+    public void addAlbum(String aId) throws IOException {
+        PhotoEntry entry = find(aId);
+        if (entry != null) {
+            throw new IOException("Entry already exists in album " + getId() +
+                " : " + aId);
+        }
+        // Entry not yet found. Try to create it.
+        File albumDir = new File(_dir, aId);
+        if (!albumDir.mkdir()) {
+            throw new IOException("Could not create album: " + aId);
+        }
+        File photosDir = new File(albumDir, PHOTOS_DIR);
+        if (!photosDir.mkdir()) {
+            throw new IOException("Could  not create photo storage dir: " +
+                photosDir.getPath());
+        }
+        File thumbnailsDir = new File(albumDir, THUMBNAILS_DIR);
+        if (!thumbnailsDir.mkdir()) {
+            throw new IOException("Coul dnot create thumbnails storage dir: " +
+                thumbnailsDir.getPath());
+        }
+        String newPath = _path + "/" + aId;
+        newPath = newPath.replaceAll("//", "/");
+        FileSystemAlbum album = new FileSystemAlbum(albumDir, newPath,
+            _entries.getCache());
+        addEntry(album);
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see org.wamblee.photos.database.Album#removeAlbum(java.lang.String)
+     */
+    public void removeEntry(String aId) throws IOException {
+        PhotoEntry entry = find(aId);
+        if (entry == null) {
+            throw new IOException("Entry " + aId + " not found.");
+        }
+        if (entry instanceof FileSystemAlbum) {
+            // album.
+            removeAlbum((FileSystemAlbum) entry);
+        } else {
+            // regular photo
+            removePhoto(entry);
+        }
+    }
+
+    /**
+     * Removes a photo entry.
+     * 
+     * @param aEntry
+     *            Photo entry to remove.
+     * @throws IOException
+     */
+    private void removePhoto(PhotoEntry aEntry) throws IOException {
+        File photo = new File(_dir, PHOTOS_DIR);
+        photo = new File(photo, aEntry.getId() + JPG_EXTENSION);
+        File thumbnail = new File(_dir, THUMBNAILS_DIR);
+        thumbnail = new File(thumbnail, aEntry.getId() + THUMBNAIL_ENDING);
+        if (!photo.isFile()) {
+            throw new IOException("Photo file not found: " + photo.getPath());
+        }
+        if (!thumbnail.isFile()) {
+            throw new IOException("Thumbnail file not found: " +
+                thumbnail.getPath());
+        }
+        if (!thumbnail.delete()) {
+            throw new IOException("Could not delete thumbnail file: " +
+                thumbnail.getPath());
+        }
+        _entries.get().remove(aEntry);
+        if (!photo.delete()) {
+            throw new IOException("Could not delete photo file: " +
+                photo.getPath());
+        }
+    }
+
+    /**
+     * Removes the album entry from this album.
+     * 
+     * @param aAlbum
+     *            Album to remove
+     * @throws IOException
+     */
+    private void removeAlbum(FileSystemAlbum aAlbum) throws IOException {
+        if (aAlbum.size() > 0) {
+            throw new IOException("Album " + aAlbum.getId() + " not empty.");
+        }
+        // album is empty, safe to remove.
+        File photosDir = new File(aAlbum._dir, PHOTOS_DIR);
+        File thumbnailsDir = new File(aAlbum._dir, THUMBNAILS_DIR);
+        if (!photosDir.delete() || !thumbnailsDir.delete() ||
+            !aAlbum._dir.delete()) {
+            throw new IOException("Could not delete directories for album:" +
+                aAlbum.getId());
+        }
+        _entries.get().remove(aAlbum);
+    }
+
+    /**
+     * Adds an entry.
+     * 
+     * @param aEntry
+     *            Entry to add.
+     */
+    private void addEntry(PhotoEntry aEntry) {
+        int i = 0;
+        List<PhotoEntry> entries = _entries.get();
+        int nentries = entries.size();
+        while (i < nentries && aEntry.compareTo(entries.get(i)) > 0) {
+            i++;
+        }
+        entries.add(i, aEntry);
+    }
+
+    /* (non-Javadoc)
+     * @see java.lang.Object#equals(java.lang.Object)
+     */
+    @Override
+    public boolean equals(Object obj) {
+        if (!(obj instanceof Album)) {
+            return false;
+        }
+        return toString().equals(obj.toString());
+    }
+
+    /* (non-Javadoc)
+     * @see java.lang.Object#hashCode()
+     */
+    @Override
+    public int hashCode() {
+        return toString().hashCode();
+    }
+
+    /*
+     * (non-Javadoc)
+     * 
+     * @see java.lang.Comparable#compareTo(java.lang.Object)
+     */
+    public int compareTo(PhotoEntry aEntry) {
+        return getId().compareTo(((PhotoEntry) aEntry).getId());
+    }
+
+    /**
+     * Returns and alphabetically sorted list of files.
+     * 
+     * @param aDir
+     * @return Sorted list of files.
+     */
+    private static File[] listFilesInDir(File aDir) {
+        File[] lResult = aDir.listFiles();
+        Arrays.sort(lResult);
+        return lResult;
+    }
+
+    /*
+     *  (non-Javadoc)
+     * @see org.wamblee.photos.model.Album#findPhotoBefore(java.lang.String)
+     */
+    public Photo findPhotoBefore(String aId) {
+        Pair<PhotoEntry, Integer> entry = findInMap(aId);
+        if (entry.getFirst() == null) {
+            throw new IllegalArgumentException("Entry with id '" + aId +
+                "' not found in album '" + getPath() + "'");
+        }
+
+        int i = entry.getSecond() - 1;
+        List<PhotoEntry> entries = _entries.get();
+        while (i >= 0 && i < entries.size() &&
+            !(entries.get(i) instanceof Photo)) {
+            i--;
+        }
+        if (i >= 0) {
+            return (Photo) entries.get(i);
+        }
+        return null;
+    }
+
+    /*
+     *  (non-Javadoc)
+     * @see org.wamblee.photos.model.Album#findPhotoAfter(java.lang.String)
+     */
+    public Photo findPhotoAfter(String aId) {
+        Pair<PhotoEntry, Integer> entry = findInMap(aId);
+        if (entry.getFirst() == null) {
+            throw new IllegalArgumentException("Entry with id '" + aId +
+                "' not found in album '" + getPath() + "'");
+        }
+        int i = entry.getSecond() + 1;
+        List<PhotoEntry> entries = _entries.get();
+        while (i >= 0 && i < entries.size() &&
+            !(entries.get(i) instanceof Photo)) {
+            i++;
+        }
+        if (i < entries.size()) {
+            return (Photo) entries.get(i);
+        }
+        return null;
+    }
+}