Added the old unit tests from the old Photos application again.
[photos] / src / main / java / org / wamblee / photos / model / filesystem / FileSystemAlbum.java
1 /*
2  * Copyright 2005 the original author or authors.
3  * 
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
7  * 
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  * 
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.
15  */
16 package org.wamblee.photos.model.filesystem;
17
18 import java.awt.image.BufferedImage;
19 import java.io.BufferedOutputStream;
20 import java.io.File;
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;
29
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;
40
41 /**
42  * Represents a photo album stored in a directory structure on the file system.
43  */
44 public class FileSystemAlbum implements Album {
45
46     private static final Log LOG = LogFactory.getLog(FileSystemAlbum.class);
47
48     /**
49      * Subdirectory where the thumbnails are stored.
50      */
51     public static final String THUMBNAILS_DIR = "thumbnails";
52
53     /**
54      * Subdirectory where the photos are stored in their full size.
55      */
56     public static final String PHOTOS_DIR = "fotos";
57
58     /**
59      * Extension used for JPEG pictures.
60      */
61     private static final String JPG_EXTENSION = ".jpg";
62
63     /**
64      * Last part of the file name that a thumbnail must end with.
65      */
66     private static final String THUMBNAIL_ENDING = "_thumb.jpg";
67
68     private static final int THUMBNAIL_WIDTH = 100;
69
70     private static final int THUMBNAIL_HEIGHT = 100;
71
72     private static final int JPG_QUALITY = 75;
73
74     /**
75      * Array of photo entries.
76      */
77     private CachedObject<String, ArrayList<PhotoEntry>> _entries;
78
79     /**
80      * Storage directory for this album.
81      */
82     private File _dir;
83
84     /**
85      * Relative path with respect to the root album.
86      */
87     private String _path;
88
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);
92             aEntries.add(entry);
93             return true;
94         }
95
96         public boolean albumFound(List<PhotoEntry> aEntries, File aAlbum, String aPath) throws IOException {
97             PhotoEntry entry = new FileSystemAlbum(aAlbum, aPath, _entries.getCache());
98             aEntries.add(entry);
99             return true;
100         }
101     }
102
103     private static final class AlbumComputation
104             implements CachedObject.Computation<String, ArrayList<PhotoEntry>>, Serializable {
105         private FileSystemAlbum album;
106
107         public AlbumComputation(FileSystemAlbum aAlbum) {
108             album = aAlbum;
109         }
110
111         @Override
112         public ArrayList<PhotoEntry> getObject(String aObjectKey) throws Exception {
113             return album.compute();
114         }
115     }
116
117     /**
118      * Creates the album.
119      *
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
124      */
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.");
129         }
130         _dir = aDir;
131         _path = aPath;
132         _entries = new CachedObject<String, ArrayList<PhotoEntry>>(aCache, aPath, new AlbumComputation(this));
133     }
134
135     /**
136      * Computes the photo entries for this album based on the file system.
137      *
138      * @return List of photo entries.
139      */
140     private ArrayList<PhotoEntry> compute() {
141         ArrayList<PhotoEntry> result = new ArrayList<PhotoEntry>();
142         try {
143             LOG.info("Initializing album for directory " + _dir);
144             traverse(getPath(), result, _dir, new CreateEntryCallback());
145         }
146         catch (IOException e) {
147             LOG.fatal("IOException occurred: " + e.getMessage(), e);
148         }
149         return result;
150     }
151
152     /**
153      * Initializes the album.
154      *
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
159      */
160     static boolean traverse(String aPath, List<PhotoEntry> aEntries, File aDir, EntryFoundCallback aCallback)
161             throws IOException {
162         File[] files = listFilesInDir(aDir);
163         for (int i = 0; i < files.length; i++) {
164             File file = files[i];
165             if (file.isDirectory()) {
166
167                 if (file.getName().equals(THUMBNAILS_DIR)) {
168                     if (!traverseThumbnails(aPath, aEntries, aDir, aCallback, file)) {
169                         return false;
170                     }
171                 } else if (file.getName().equals(PHOTOS_DIR)) {
172                     // Skip the photos directory.
173                 } else {
174                     // A nested album.
175                     String newPath = aPath + "/" + file.getName();
176                     newPath = newPath.replaceAll("//", "/");
177                     if (!aCallback.albumFound(aEntries, file, newPath)) {
178                         return false; // finished.
179                     }
180                 }
181             }
182         }
183         return true;
184     }
185
186     /**
187      * Traverse the thumnails directory.
188      *
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.
194      */
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.
199
200         // Check if the corresponding photos directory
201         // exists
202         File photosDir = new File(aDir, PHOTOS_DIR);
203         if (photosDir.isDirectory()) {
204             if (!buildAlbum(aPath, aEntries, aFile, photosDir, aCallback)) {
205                 return false;
206             }
207         } else {
208             LOG.info("Thumbnails director " + aFile.getPath() +
209                     " exists but corresponding photo directory " +
210                     photosDir.getPath() + " not found");
211         }
212         return true;
213     }
214
215     /**
216      * Builds up the photo album for a given thumbnails and photo directory.
217      *
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.
222      */
223     private static boolean buildAlbum(String aPath, List<PhotoEntry> aEntries, File aThumbnailsDir, File aPhotosDir,
224             EntryFoundCallback aCallback) {
225
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.");
232                 continue;
233             }
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.");
238                 continue;
239             }
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)) {
245                 return false;
246             }
247         }
248         return true; // continue.
249     }
250
251     /*
252      * (non-Javadoc)
253      * 
254      * @see org.wamblee.photos.database.PhotoEntry#getPath()
255      */
256     public String getPath() {
257         return _path;
258     }
259
260     /**
261      * Checks if the file represents a thumbnail.
262      *
263      * @param aFile File to check.
264      * @return True iff the file is a thumbnail.
265      */
266     private static boolean isThumbNail(File aFile) {
267         return aFile.getName().endsWith(THUMBNAIL_ENDING);
268     }
269
270     /**
271      * Constructs the photo name based on the thumbnail name.
272      *
273      * @param aThumbnailName Thumbnail name.
274      * @return Photo name.
275      */
276     private static String photoName(String aThumbnailName) {
277         return aThumbnailName.substring(0, aThumbnailName.length() - THUMBNAIL_ENDING.length()) + JPG_EXTENSION;
278     }
279
280     /*
281      * (non-Javadoc)
282      * 
283      * @see org.wamblee.photos.database.Album#getEntry(java.lang.String)
284      */
285     public PhotoEntry getEntry(String aPath) {
286         if (!aPath.startsWith("/")) {
287             throw new IllegalArgumentException("Path must start with / character");
288         }
289         if (aPath.equals("/")) {
290             return this;
291         }
292         String[] fields = aPath.substring(1).split("/");
293         return getEntry(fields, 0);
294     }
295
296     /**
297      * Gets the entry for the given path.
298      */
299     public PhotoEntry getEntry(Path aPath) {
300         return getEntry(aPath.toString());
301     }
302
303     /**
304      * Gets the entry at a given path.
305      *
306      * @param aPath  Array of components of the path.
307      * @param aLevel Current level in the path.
308      * @return Entry.
309      */
310     private PhotoEntry getEntry(String[] aPath, int aLevel) {
311         String id = aPath[aLevel];
312         PhotoEntry entry = find(id);
313         if (entry == null) {
314             return entry;
315         }
316         if (aLevel < aPath.length - 1) {
317             if (!(entry instanceof Album)) {
318                 return null;
319             }
320             return ((FileSystemAlbum) entry).getEntry(aPath, aLevel + 1);
321         } else {
322             return entry; // end of the path.
323         }
324     }
325
326     /**
327      * Finds an entry in the album with the given id.
328      *
329      * @param aId Photo entry id.
330      * @return Photo entry.
331      */
332     private PhotoEntry find(String aId) {
333         return findInMap(aId).getFirst();
334     }
335
336     /**
337      * Finds a photo entry in the map.
338      *
339      * @param aId Id of the photo entry.
340      * @return Pair of a photo entry and the index of the item.
341      */
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);
348             }
349         }
350         return new Pair<PhotoEntry, Integer>(null, -1);
351     }
352
353     /*
354      * (non-Javadoc)
355      * 
356      * @see org.wamblee.photos.database.Album#getEntry(int)
357      */
358     public PhotoEntry getEntry(int aIndex) {
359         return _entries.get().get(aIndex);
360     }
361
362     /*
363      * (non-Javadoc)
364      * 
365      * @see org.wamblee.photos.database.Album#size()
366      */
367     public int size() {
368         return _entries.get().size();
369     }
370
371     /*
372      * (non-Javadoc)
373      * 
374      * @see org.wamblee.photos.database.PhotoEntry#getId()
375      */
376     public String getId() {
377         return _dir.getName();
378     }
379
380     public String toString() {
381         return print("");
382     }
383
384     /**
385      * Prints the album with a given indentation.
386      *
387      * @param aIndent Indentation string.
388      * @return String representation of the album.
389      */
390     private String print(String aIndent) {
391         String res = aIndent + "ALBUM: " + getId() + "\n";
392         aIndent += "  ";
393         for (int i = 0; i < size(); i++) {
394             PhotoEntry entry = getEntry(i);
395             if (entry instanceof FileSystemAlbum) {
396                 res += ((FileSystemAlbum) entry).print(aIndent);
397             } else {
398                 res += ((FileSystemPhoto) entry).print(aIndent);
399             }
400             res += "\n";
401         }
402         return res;
403     }
404
405     /*
406      * (non-Javadoc)
407      * 
408      * @see org.wamblee.photos.database.Album#addImage(java.lang.String,
409      *      java.awt.Image)
410      */
411     public void addImage(String aId, InputStream aImageStream) throws IOException {
412         File photoDir = getPhotoDir();
413         File thumbnailDir = getThumbnailDir();
414
415         checkPhotoDirs(aId, photoDir, thumbnailDir);
416
417         BufferedImage image;
418         try {
419             image = JpegUtils.loadJpegImage(aImageStream);
420         }
421         catch (InterruptedException e) {
422             throw new IOException("Loading image interruptedS: " + e.getMessage());
423         }
424         BufferedImage thumbnailImage = JpegUtils.scaleImage(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, image);
425
426         File thumbnail = new File(thumbnailDir, aId + THUMBNAIL_ENDING);
427         File photo = new File(photoDir, aId + JPG_EXTENSION);
428
429         writeImage(photo, image);
430         writeImage(thumbnail, thumbnailImage);
431
432         Photo photoEntry = new FileSystemPhoto(thumbnail, photo, (getPath().equals("/") ? "" : getPath()) + "/" + aId);
433         addEntry(photoEntry);
434     }
435
436     /**
437      * Checks if a given entry can be added to the album.
438      *
439      * @param aId           Id of the new entry.
440      * @param aPhotoDir     Photo directory.
441      * @param aThumbnailDir Thumbnail directory.
442      * @throws IOException
443      */
444     private void checkPhotoDirs(String aId, File aPhotoDir, File aThumbnailDir) throws IOException {
445         // Create directories if they do not already exist.
446         aPhotoDir.mkdir();
447         aThumbnailDir.mkdir();
448
449         if (!aPhotoDir.isDirectory() || !aThumbnailDir.isDirectory()) {
450             throw new IOException("Album " + getId() +
451                     " does not accept new images.");
452         }
453
454         if (getEntry("/" + aId) != null) {
455             throw new IOException("An entry with the name '" + aId +
456                     "' already exists in the album " + getPath());
457         }
458     }
459
460     /**
461      * Gets the thumbnail directory for the album.
462      *
463      * @return Directory.
464      */
465     private File getThumbnailDir() {
466         File thumbnailDir = new File(_dir, THUMBNAILS_DIR);
467         return thumbnailDir;
468     }
469
470     /**
471      * Gets the photo directory for the album.
472      *
473      * @return Photo directory
474      */
475     private File getPhotoDir() {
476         File photoDir = new File(_dir, PHOTOS_DIR);
477         return photoDir;
478     }
479
480     /**
481      * Writes an image to a file.
482      *
483      * @param aFile  File to write to.
484      * @param aImage Image.
485      * @throws IOException
486      */
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);
490         output.close();
491     }
492
493     /*
494      * (non-Javadoc)
495      * 
496      * @see org.wamblee.photos.database.Album#addAlbum(java.lang.String)
497      */
498     public void addAlbum(String aId) throws IOException {
499         PhotoEntry entry = find(aId);
500         if (entry != null) {
501             throw new IOException("Entry already exists in album " + getId() +
502                     " : " + aId);
503         }
504         // Entry not yet found. Try to create it.
505         File albumDir = new File(_dir, aId);
506         if (!albumDir.mkdir()) {
507             throw new IOException("Could not create album: " + aId);
508         }
509         File photosDir = new File(albumDir, PHOTOS_DIR);
510         if (!photosDir.mkdir()) {
511             throw new IOException("Could  not create photo storage dir: " + photosDir.getPath());
512         }
513         File thumbnailsDir = new File(albumDir, THUMBNAILS_DIR);
514         if (!thumbnailsDir.mkdir()) {
515             throw new IOException("Coul dnot create thumbnails storage dir: " + thumbnailsDir.getPath());
516         }
517         String newPath = _path + "/" + aId;
518         newPath = newPath.replaceAll("//", "/");
519         FileSystemAlbum album = new FileSystemAlbum(albumDir, newPath, _entries.getCache());
520         addEntry(album);
521     }
522
523     /*
524      * (non-Javadoc)
525      * 
526      * @see org.wamblee.photos.database.Album#removeAlbum(java.lang.String)
527      */
528     public void removeEntry(String aId) throws IOException {
529         PhotoEntry entry = find(aId);
530         if (entry == null) {
531             throw new IOException("Entry " + aId + " not found.");
532         }
533         if (entry instanceof FileSystemAlbum) {
534             // album.
535             removeAlbum((FileSystemAlbum) entry);
536         } else {
537             // regular photo
538             removePhoto(entry);
539         }
540     }
541
542     /**
543      * Removes a photo entry.
544      *
545      * @param aEntry Photo entry to remove.
546      * @throws IOException
547      */
548     private void removePhoto(PhotoEntry aEntry) throws IOException {
549         File photo = new File(_dir, PHOTOS_DIR);
550         photo = new File(photo, aEntry.getId() + JPG_EXTENSION);
551         File thumbnail = new File(_dir, THUMBNAILS_DIR);
552         thumbnail = new File(thumbnail, aEntry.getId() + THUMBNAIL_ENDING);
553         if (!photo.isFile()) {
554             throw new IOException("Photo file not found: " + photo.getPath());
555         }
556         if (!thumbnail.isFile()) {
557             throw new IOException("Thumbnail file not found: " + thumbnail.getPath());
558         }
559         if (!thumbnail.delete()) {
560             throw new IOException("Could not delete thumbnail file: " + thumbnail.getPath());
561         }
562         _entries.get().remove(aEntry);
563         if (!photo.delete()) {
564             throw new IOException("Could not delete photo file: " + photo.getPath());
565         }
566     }
567
568     /**
569      * Removes the album entry from this album.
570      *
571      * @param aAlbum Album to remove
572      * @throws IOException
573      */
574     private void removeAlbum(FileSystemAlbum aAlbum) throws IOException {
575         if (aAlbum.size() > 0) {
576             throw new IOException("Album " + aAlbum.getId() + " not empty.");
577         }
578         // album is empty, safe to remove.
579         File photosDir = new File(aAlbum._dir, PHOTOS_DIR);
580         File thumbnailsDir = new File(aAlbum._dir, THUMBNAILS_DIR);
581         if (!photosDir.delete() || !thumbnailsDir.delete() ||
582                 !aAlbum._dir.delete()) {
583             throw new IOException("Could not delete directories for album:" + aAlbum.getId());
584         }
585         _entries.get().remove(aAlbum);
586     }
587
588     /**
589      * Adds an entry.
590      *
591      * @param aEntry Entry to add.
592      */
593     private void addEntry(PhotoEntry aEntry) {
594         int i = 0;
595         List<PhotoEntry> entries = _entries.get();
596         int nentries = entries.size();
597         while (i < nentries && aEntry.compareTo(entries.get(i)) > 0) {
598             i++;
599         }
600         entries.add(i, aEntry);
601     }
602
603     /* (non-Javadoc)
604      * @see java.lang.Object#equals(java.lang.Object)
605      */
606     @Override
607     public boolean equals(Object obj) {
608         if (!(obj instanceof Album)) {
609             return false;
610         }
611         return toString().equals(obj.toString());
612     }
613
614     /* (non-Javadoc)
615      * @see java.lang.Object#hashCode()
616      */
617     @Override
618     public int hashCode() {
619         return toString().hashCode();
620     }
621
622     /*
623      * (non-Javadoc)
624      * 
625      * @see java.lang.Comparable#compareTo(java.lang.Object)
626      */
627     public int compareTo(PhotoEntry aEntry) {
628         return getId().compareTo(((PhotoEntry) aEntry).getId());
629     }
630
631     /**
632      * Returns and alphabetically sorted list of files.
633      *
634      * @param aDir
635      * @return Sorted list of files.
636      */
637     private static File[] listFilesInDir(File aDir) {
638         File[] lResult = aDir.listFiles();
639         Arrays.sort(lResult);
640         return lResult;
641     }
642
643     /*
644      *  (non-Javadoc)
645      * @see org.wamblee.photos.model.Album#findPhotoBefore(java.lang.String)
646      */
647     public Photo findPhotoBefore(String aId) {
648         Pair<PhotoEntry, Integer> entry = findInMap(aId);
649         if (entry.getFirst() == null) {
650             throw new IllegalArgumentException("Entry with id '" + aId +
651                     "' not found in album '" + getPath() + "'");
652         }
653
654         int i = entry.getSecond() - 1;
655         List<PhotoEntry> entries = _entries.get();
656         while (i >= 0 && i < entries.size() &&
657                 !(entries.get(i) instanceof Photo)) {
658             i--;
659         }
660         if (i >= 0) {
661             return (Photo) entries.get(i);
662         }
663         return null;
664     }
665
666     /*
667      *  (non-Javadoc)
668      * @see org.wamblee.photos.model.Album#findPhotoAfter(java.lang.String)
669      */
670     public Photo findPhotoAfter(String aId) {
671         Pair<PhotoEntry, Integer> entry = findInMap(aId);
672         if (entry.getFirst() == null) {
673             throw new IllegalArgumentException("Entry with id '" + aId +
674                     "' not found in album '" + getPath() + "'");
675         }
676         int i = entry.getSecond() + 1;
677         List<PhotoEntry> entries = _entries.get();
678         while (i >= 0 && i < entries.size() &&
679                 !(entries.get(i) instanceof Photo)) {
680             i++;
681         }
682         if (i < entries.size()) {
683             return (Photo) entries.get(i);
684         }
685         return null;
686     }
687 }