/* * 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.io.Serializable; 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> _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 aEntries, File aThumbnail, File aPhoto, String aPath) { PhotoEntry entry = new FileSystemPhoto(aThumbnail, aPhoto, aPath); aEntries.add(entry); return true; } public boolean albumFound(List aEntries, File aAlbum, String aPath) throws IOException { PhotoEntry entry = new FileSystemAlbum(aAlbum, aPath, _entries.getCache()); aEntries.add(entry); return true; } } private static final class AlbumComputation implements CachedObject.Computation>, Serializable { private FileSystemAlbum album; public AlbumComputation(FileSystemAlbum aAlbum) { album = aAlbum; } @Override public ArrayList getObject(String aObjectKey) throws Exception { return album.compute(); } } /** * Creates the album. * * @param aDir Directory where the album is located. * @param aPath Path that this album represents. * @param aCache Cache to use. Note that a cache usedin one album hierarachy should not be used in another. * @throws IOException */ public FileSystemAlbum(File aDir, String aPath, Cache> aCache) throws IOException { if (!aDir.isDirectory()) { throw new IOException("Directory '" + aDir.getPath() + "' does not exist."); } _dir = aDir; _path = aPath; _entries = new CachedObject>(aCache, aPath, new AlbumComputation(this)); } /** * Computes the photo entries for this album based on the file system. * * @return List of photo entries. */ private ArrayList compute() { ArrayList result = new ArrayList(); 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 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 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 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 findInMap(String aId) { List entries = _entries.get(); for (int i = 0; i < entries.size(); i++) { PhotoEntry entry = entries.get(i); if (entry.getId().equals(aId)) { return new Pair(entry, i); } } return new Pair(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 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 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 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 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 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; } }