+/*
+ * 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;
+ }
+}