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