2 * Copyright 2005 the original author or authors.
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
8 * http://www.apache.org/licenses/LICENSE-2.0
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
16 package org.wamblee.photos.model.filesystem;
18 import java.awt.image.BufferedImage;
19 import java.io.BufferedOutputStream;
21 import java.io.FileOutputStream;
22 import java.io.IOException;
23 import java.io.InputStream;
24 import java.io.OutputStream;
25 import java.io.Serializable;
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.List;
30 import org.apache.commons.logging.Log;
31 import org.apache.commons.logging.LogFactory;
32 import org.wamblee.cache.Cache;
33 import org.wamblee.cache.CachedObject;
34 import org.wamblee.general.Pair;
35 import org.wamblee.photos.model.Album;
36 import org.wamblee.photos.model.Path;
37 import org.wamblee.photos.model.Photo;
38 import org.wamblee.photos.model.PhotoEntry;
39 import org.wamblee.photos.utils.JpegUtils;
42 * Represents a photo album stored in a directory structure on the file system.
44 public class FileSystemAlbum implements Album {
46 private static final Log LOG = LogFactory.getLog(FileSystemAlbum.class);
49 * Subdirectory where the thumbnails are stored.
51 public static final String THUMBNAILS_DIR = "thumbnails";
54 * Subdirectory where the photos are stored in their full size.
56 public static final String PHOTOS_DIR = "fotos";
59 * Extension used for JPEG pictures.
61 private static final String JPG_EXTENSION = ".jpg";
64 * Last part of the file name that a thumbnail must end with.
66 private static final String THUMBNAIL_ENDING = "_thumb.jpg";
68 private static final int THUMBNAIL_WIDTH = 100;
70 private static final int THUMBNAIL_HEIGHT = 100;
72 private static final int JPG_QUALITY = 75;
75 * Array of photo entries.
77 private CachedObject<String, ArrayList<PhotoEntry>> _entries;
80 * Storage directory for this album.
85 * Relative path with respect to the root album.
89 private class CreateEntryCallback implements EntryFoundCallback {
90 public boolean photoFound(List<PhotoEntry> aEntries, File aThumbnail, File aPhoto, String aPath) {
91 PhotoEntry entry = new FileSystemPhoto(aThumbnail, aPhoto, aPath);
96 public boolean albumFound(List<PhotoEntry> aEntries, File aAlbum, String aPath) throws IOException {
97 PhotoEntry entry = new FileSystemAlbum(aAlbum, aPath, _entries.getCache());
103 private static final class AlbumComputation
104 implements CachedObject.Computation<String, ArrayList<PhotoEntry>>, Serializable {
105 private FileSystemAlbum album;
107 public AlbumComputation(FileSystemAlbum aAlbum) {
112 public ArrayList<PhotoEntry> getObject(String aObjectKey) throws Exception {
113 return album.compute();
120 * @param aDir Directory where the album is located.
121 * @param aPath Path that this album represents.
122 * @param aCache Cache to use. Note that a cache usedin one album hierarachy should not be used in another.
123 * @throws IOException
125 public FileSystemAlbum(File aDir, String aPath, Cache<String, ArrayList<PhotoEntry>> aCache) throws IOException {
126 if (!aDir.isDirectory()) {
127 throw new IOException("Directory '" + aDir.getPath() +
128 "' does not exist.");
132 _entries = new CachedObject<String, ArrayList<PhotoEntry>>(aCache, "fs:" + aPath, new AlbumComputation(this));
136 * Computes the photo entries for this album based on the file system.
138 * @return List of photo entries.
140 private ArrayList<PhotoEntry> compute() {
141 ArrayList<PhotoEntry> result = new ArrayList<PhotoEntry>();
143 LOG.info("Initializing album for directory " + _dir);
144 traverse(getPath(), result, _dir, new CreateEntryCallback());
146 catch (IOException e) {
147 LOG.fatal("IOException occurred: " + e.getMessage(), e);
153 * Initializes the album.
155 * @param aPath Path of the album
156 * @param aEntries Photo entries for the album.
157 * @param aDir Directory where the album is stored.
158 * @throws IOException
160 static boolean traverse(String aPath, List<PhotoEntry> aEntries, File aDir, EntryFoundCallback aCallback)
162 File[] files = listFilesInDir(aDir);
163 for (int i = 0; i < files.length; i++) {
164 File file = files[i];
165 if (file.isDirectory()) {
167 if (file.getName().equals(THUMBNAILS_DIR)) {
168 if (!traverseThumbnails(aPath, aEntries, aDir, aCallback, file)) {
171 } else if (file.getName().equals(PHOTOS_DIR)) {
172 // Skip the photos directory.
175 String newPath = aPath + "/" + file.getName();
176 newPath = newPath.replaceAll("//", "/");
177 if (!aCallback.albumFound(aEntries, file, newPath)) {
178 return false; // finished.
187 * Traverse the thumnails directory.
189 * @param aPath Path of the photo album.
190 * @param aEntries Entries of the album.
191 * @param aDir Directory of the album.
192 * @param aCallback Callback to call when a thumbnail has been found.
193 * @param aFile Directory of the photo album.
195 private static boolean traverseThumbnails(String aPath, List<PhotoEntry> aEntries, File aDir,
196 EntryFoundCallback aCallback, File aFile) {
197 // Go inside the thumbnails directory to scan
198 // for available photos.
200 // Check if the corresponding photos directory
202 File photosDir = new File(aDir, PHOTOS_DIR);
203 if (photosDir.isDirectory()) {
204 if (!buildAlbum(aPath, aEntries, aFile, photosDir, aCallback)) {
208 LOG.info("Thumbnails director " + aFile.getPath() +
209 " exists but corresponding photo directory " +
210 photosDir.getPath() + " not found");
216 * Builds up the photo album for a given thumbnails and photo directory.
218 * @param aPath Path of the album.
219 * @param aEntries Photo entries of the album.
220 * @param aThumbnailsDir Directory containing thumbnail pictures.
221 * @param aPhotosDir Directory containing full size photos.
223 private static boolean buildAlbum(String aPath, List<PhotoEntry> aEntries, File aThumbnailsDir, File aPhotosDir,
224 EntryFoundCallback aCallback) {
226 File[] thumbnails = listFilesInDir(aThumbnailsDir);
227 for (int i = 0; i < thumbnails.length; i++) {
228 File thumbnail = thumbnails[i];
229 if (!isThumbNail(thumbnail)) {
230 LOG.info("Skipping " + thumbnail.getPath() +
231 " because it is not a thumbnail file.");
234 File photo = new File(aPhotosDir, photoName(thumbnail.getName()));
235 if (!photo.isFile()) {
236 LOG.info("Photo file " + photo.getPath() + " for thumbnail " +
237 thumbnail.getPath() + " does not exist.");
240 String photoPath = photo.getName();
241 photoPath = photoPath.substring(0, photoPath.length() - JPG_EXTENSION.length());
242 photoPath = aPath + "/" + photoPath;
243 photoPath = photoPath.replaceAll("//", "/");
244 if (!aCallback.photoFound(aEntries, thumbnail, photo, photoPath)) {
248 return true; // continue.
254 * @see org.wamblee.photos.database.PhotoEntry#getPath()
256 public String getPath() {
261 * Checks if the file represents a thumbnail.
263 * @param aFile File to check.
264 * @return True iff the file is a thumbnail.
266 private static boolean isThumbNail(File aFile) {
267 return aFile.getName().endsWith(THUMBNAIL_ENDING);
271 * Constructs the photo name based on the thumbnail name.
273 * @param aThumbnailName Thumbnail name.
274 * @return Photo name.
276 private static String photoName(String aThumbnailName) {
277 return aThumbnailName.substring(0, aThumbnailName.length() - THUMBNAIL_ENDING.length()) + JPG_EXTENSION;
283 * @see org.wamblee.photos.database.Album#getEntry(java.lang.String)
285 public PhotoEntry getEntry(String aPath) {
286 if (!aPath.startsWith("/")) {
287 throw new IllegalArgumentException("Path must start with / character");
289 if (aPath.equals("/")) {
292 String[] fields = aPath.substring(1).split("/");
293 return getEntry(fields, 0);
297 * Gets the entry for the given path.
299 public PhotoEntry getEntry(Path aPath) {
300 return getEntry(aPath.toString());
304 * Gets the entry at a given path.
306 * @param aPath Array of components of the path.
307 * @param aLevel Current level in the path.
310 private PhotoEntry getEntry(String[] aPath, int aLevel) {
311 String id = aPath[aLevel];
312 PhotoEntry entry = find(id);
316 if (aLevel < aPath.length - 1) {
317 if (!(entry instanceof Album)) {
320 return ((FileSystemAlbum) entry).getEntry(aPath, aLevel + 1);
322 return entry; // end of the path.
327 * Finds an entry in the album with the given id.
329 * @param aId Photo entry id.
330 * @return Photo entry.
332 private PhotoEntry find(String aId) {
333 return findInMap(aId).getFirst();
337 * Finds a photo entry in the map.
339 * @param aId Id of the photo entry.
340 * @return Pair of a photo entry and the index of the item.
342 private Pair<PhotoEntry, Integer> findInMap(String aId) {
343 List<PhotoEntry> entries = _entries.get();
344 for (int i = 0; i < entries.size(); i++) {
345 PhotoEntry entry = entries.get(i);
346 if (entry.getId().equals(aId)) {
347 return new Pair<PhotoEntry, Integer>(entry, i);
350 return new Pair<PhotoEntry, Integer>(null, -1);
356 * @see org.wamblee.photos.database.Album#getEntry(int)
358 public PhotoEntry getEntry(int aIndex) {
359 return _entries.get().get(aIndex);
365 * @see org.wamblee.photos.database.Album#size()
368 return _entries.get().size();
374 * @see org.wamblee.photos.database.PhotoEntry#getId()
376 public String getId() {
377 return _dir.getName();
380 public String toString() {
385 * Prints the album with a given indentation.
387 * @param aIndent Indentation string.
388 * @return String representation of the album.
390 private String print(String aIndent) {
391 String res = aIndent + "ALBUM: " + getId() + "\n";
393 for (int i = 0; i < size(); i++) {
394 PhotoEntry entry = getEntry(i);
395 if (entry instanceof FileSystemAlbum) {
396 res += ((FileSystemAlbum) entry).print(aIndent);
398 res += ((FileSystemPhoto) entry).print(aIndent);
408 * @see org.wamblee.photos.database.Album#addImage(java.lang.String,
411 public void addImage(String aId, InputStream aImageStream) throws IOException {
412 File photoDir = getPhotoDir();
413 File thumbnailDir = getThumbnailDir();
415 checkPhotoDirs(aId, photoDir, thumbnailDir);
419 image = JpegUtils.loadJpegImage(aImageStream);
421 catch (InterruptedException e) {
422 throw new IOException("Loading image interruptedS: " + e.getMessage());
424 BufferedImage thumbnailImage = JpegUtils.scaleImage(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, image);
426 File thumbnail = new File(thumbnailDir, aId + THUMBNAIL_ENDING);
427 File photo = new File(photoDir, aId + JPG_EXTENSION);
429 writeImage(photo, image);
430 writeImage(thumbnail, thumbnailImage);
432 Photo photoEntry = new FileSystemPhoto(thumbnail, photo, (getPath().equals("/") ? "" : getPath()) + "/" + aId);
433 addEntry(photoEntry);
437 * Checks if a given entry can be added to the album.
439 * @param aId Id of the new entry.
440 * @param aPhotoDir Photo directory.
441 * @param aThumbnailDir Thumbnail directory.
442 * @throws IOException
444 private void checkPhotoDirs(String aId, File aPhotoDir, File aThumbnailDir) throws IOException {
445 // Create directories if they do not already exist.
447 aThumbnailDir.mkdir();
449 if (!aPhotoDir.isDirectory() || !aThumbnailDir.isDirectory()) {
450 throw new IOException("Album " + getId() +
451 " does not accept new images.");
454 if (getEntry("/" + aId) != null) {
455 throw new IOException("An entry with the name '" + aId +
456 "' already exists in the album " + getPath());
461 * Gets the thumbnail directory for the album.
465 private File getThumbnailDir() {
466 File thumbnailDir = new File(_dir, THUMBNAILS_DIR);
471 * Gets the photo directory for the album.
473 * @return Photo directory
475 private File getPhotoDir() {
476 File photoDir = new File(_dir, PHOTOS_DIR);
481 * Writes an image to a file.
483 * @param aFile File to write to.
484 * @param aImage Image.
485 * @throws IOException
487 private static void writeImage(File aFile, BufferedImage aImage) throws IOException {
488 OutputStream output = new BufferedOutputStream(new FileOutputStream(aFile));
489 JpegUtils.writeJpegImage(output, JPG_QUALITY, aImage);
496 * @see org.wamblee.photos.database.Album#addAlbum(java.lang.String)
498 public void addAlbum(String aId) throws IOException {
500 PhotoEntry entry = find(aId);
502 throw new IOException("Entry already exists in album " + getId() +
505 // Entry not yet found. Try to create it.
506 File albumDir = new File(_dir, aId);
507 if (!albumDir.mkdir()) {
508 throw new IOException("Could not create album: " + aId);
510 File photosDir = new File(albumDir, PHOTOS_DIR);
511 if (!photosDir.mkdir()) {
512 throw new IOException("Could not create photo storage dir: " + photosDir.getPath());
514 File thumbnailsDir = new File(albumDir, THUMBNAILS_DIR);
515 if (!thumbnailsDir.mkdir()) {
516 throw new IOException("Coul dnot create thumbnails storage dir: " + thumbnailsDir.getPath());
518 String newPath = _path + "/" + aId;
519 newPath = newPath.replaceAll("//", "/");
520 FileSystemAlbum album = new FileSystemAlbum(albumDir, newPath, _entries.getCache());
523 _entries.invalidate();
530 * @see org.wamblee.photos.database.Album#removeAlbum(java.lang.String)
532 public void removeEntry(String aId) throws IOException {
534 PhotoEntry entry = find(aId);
536 throw new IOException("Entry " + aId + " not found.");
538 if (entry instanceof FileSystemAlbum) {
540 removeAlbum((FileSystemAlbum) entry);
546 _entries.invalidate();
551 * Removes a photo entry.
553 * @param aEntry Photo entry to remove.
554 * @throws IOException
556 private void removePhoto(PhotoEntry aEntry) throws IOException {
557 File photo = new File(_dir, PHOTOS_DIR);
558 photo = new File(photo, aEntry.getId() + JPG_EXTENSION);
559 File thumbnail = new File(_dir, THUMBNAILS_DIR);
560 thumbnail = new File(thumbnail, aEntry.getId() + THUMBNAIL_ENDING);
561 if (!photo.isFile()) {
562 throw new IOException("Photo file not found: " + photo.getPath());
564 if (!thumbnail.isFile()) {
565 throw new IOException("Thumbnail file not found: " + thumbnail.getPath());
567 if (!thumbnail.delete()) {
568 throw new IOException("Could not delete thumbnail file: " + thumbnail.getPath());
570 _entries.get().remove(aEntry);
571 if (!photo.delete()) {
572 throw new IOException("Could not delete photo file: " + photo.getPath());
577 * Removes the album entry from this album.
579 * @param aAlbum Album to remove
580 * @throws IOException
582 private void removeAlbum(FileSystemAlbum aAlbum) throws IOException {
583 if (aAlbum.size() > 0) {
584 throw new IOException("Album " + aAlbum.getId() + " not empty.");
586 // album is empty, safe to remove.
587 File photosDir = new File(aAlbum._dir, PHOTOS_DIR);
588 File thumbnailsDir = new File(aAlbum._dir, THUMBNAILS_DIR);
589 if (!photosDir.delete() || !thumbnailsDir.delete() ||
590 !aAlbum._dir.delete()) {
591 throw new IOException("Could not delete directories for album:" + aAlbum.getId());
593 _entries.get().remove(aAlbum);
599 * @param aEntry Entry to add.
601 private void addEntry(PhotoEntry aEntry) {
603 List<PhotoEntry> entries = _entries.get();
604 int nentries = entries.size();
605 while (i < nentries && aEntry.compareTo(entries.get(i)) > 0) {
608 entries.add(i, aEntry);
612 * @see java.lang.Object#equals(java.lang.Object)
615 public boolean equals(Object obj) {
616 if (!(obj instanceof Album)) {
619 return toString().equals(obj.toString());
623 * @see java.lang.Object#hashCode()
626 public int hashCode() {
627 return toString().hashCode();
633 * @see java.lang.Comparable#compareTo(java.lang.Object)
635 public int compareTo(PhotoEntry aEntry) {
636 return getId().compareTo(((PhotoEntry) aEntry).getId());
640 * Returns and alphabetically sorted list of files.
643 * @return Sorted list of files.
645 private static File[] listFilesInDir(File aDir) {
646 File[] lResult = aDir.listFiles();
647 Arrays.sort(lResult);
653 * @see org.wamblee.photos.model.Album#findPhotoBefore(java.lang.String)
655 public Photo findPhotoBefore(String aId) {
656 Pair<PhotoEntry, Integer> entry = findInMap(aId);
657 if (entry.getFirst() == null) {
658 throw new IllegalArgumentException("Entry with id '" + aId +
659 "' not found in album '" + getPath() + "'");
662 int i = entry.getSecond() - 1;
663 List<PhotoEntry> entries = _entries.get();
664 while (i >= 0 && i < entries.size() &&
665 !(entries.get(i) instanceof Photo)) {
669 return (Photo) entries.get(i);
676 * @see org.wamblee.photos.model.Album#findPhotoAfter(java.lang.String)
678 public Photo findPhotoAfter(String aId) {
679 Pair<PhotoEntry, Integer> entry = findInMap(aId);
680 if (entry.getFirst() == null) {
681 throw new IllegalArgumentException("Entry with id '" + aId +
682 "' not found in album '" + getPath() + "'");
684 int i = entry.getSecond() + 1;
685 List<PhotoEntry> entries = _entries.get();
686 while (i >= 0 && i < entries.size() &&
687 !(entries.get(i) instanceof Photo)) {
690 if (i < entries.size()) {
691 return (Photo) entries.get(i);