Merge Chromium + Blink git repositories
[chromium-blink-merge.git] / ui / file_manager / gallery / js / gallery_item.js
blobb8b354f8b267d83ea7debcf7f22b0872f7fbc1f1
1 // Copyright 2014 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * Object representing an image item (a photo).
7  *
8  * @param {!FileEntry} entry Image entry.
9  * @param {!EntryLocation} locationInfo Entry location information.
10  * @param {MetadataItem} metadataItem
11  * @param {ThumbnailMetadataItem} thumbnailMetadataItem
12  * @param {boolean} original Whether the entry is original or edited.
13  * @constructor
14  * @struct
15  */
16 Gallery.Item = function(
17     entry, locationInfo, metadataItem, thumbnailMetadataItem, original) {
18   /**
19    * @private {!FileEntry}
20    */
21   this.entry_ = entry;
23   /**
24    * @private {!EntryLocation}
25    */
26   this.locationInfo_ = locationInfo;
28   /**
29    * @private {MetadataItem}
30    */
31   this.metadataItem_ = metadataItem;
33   /**
34    * @private {ThumbnailMetadataItem}
35    */
36   this.thumbnailMetadataItem_ = metadataItem;
38   // TODO(yawano): Change this.contentImage and this.screenImage to private
39   // fields and provide utility methods for them (e.g. revokeFullImageCache).
40   /**
41    * The content cache is used for prefetching the next image when going through
42    * the images sequentially. The real life photos can be large (18Mpix = 72Mb
43    * pixel array) so we want only the minimum amount of caching.
44    * @type {(HTMLCanvasElement|HTMLImageElement)}
45    */
46   this.contentImage = null;
48   /**
49    * We reuse previously generated screen-scale images so that going back to a
50    * recently loaded image looks instant even if the image is not in the content
51    * cache any more. Screen-scale images are small (~1Mpix) so we can afford to
52    * cache more of them.
53    * @type {HTMLCanvasElement}
54    */
55   this.screenImage = null;
57   /**
58    * Last accessed date to be used for selecting items whose cache are evicted.
59    * @type {number}
60    * @private
61    */
62   this.lastAccessed_ = Date.now();
64   /**
65    * @type {boolean}
66    * @private
67    */
68   this.original_ = original;
71 /**
72  * @return {!FileEntry} Image entry.
73  */
74 Gallery.Item.prototype.getEntry = function() { return this.entry_; };
76 /**
77  * @return {!EntryLocation} Entry location information.
78  */
79 Gallery.Item.prototype.getLocationInfo = function() {
80   return this.locationInfo_;
83 /**
84  * @return {MetadataItem} Metadata.
85  */
86 Gallery.Item.prototype.getMetadataItem = function() {
87   return this.metadataItem_;
90 /**
91  * @param {!MetadataItem} metadata
92  */
93 Gallery.Item.prototype.setMetadataItem = function(metadata) {
94   this.metadataItem_ = metadata;
97 /**
98  * @return {ThumbnailMetadataItem} Thumbnail metadata item.
99  */
100 Gallery.Item.prototype.getThumbnailMetadataItem = function() {
101   return this.thumbnailMetadataItem_;
105  * @param {!ThumbnailMetadataItem} item Thumbnail metadata item.
106  */
107 Gallery.Item.prototype.setThumbnailMetadataItem = function(item) {
108   this.thumbnailMetadataItem_ = item;
112  * @return {string} File name.
113  */
114 Gallery.Item.prototype.getFileName = function() {
115   return this.entry_.name;
119  * @return {boolean} True if this image has not been created in this session.
120  */
121 Gallery.Item.prototype.isOriginal = function() { return this.original_; };
124  * Sets an item as original.
125  */
126 Gallery.Item.prototype.setAsOriginal = function() {
127   this.original_ = true;
131  * Obtains the last accessed date.
132  * @return {number} Last accessed date.
133  */
134 Gallery.Item.prototype.getLastAccessedDate = function() {
135   return this.lastAccessed_;
139  * Updates the last accessed date.
140  */
141 Gallery.Item.prototype.touch = function() {
142   this.lastAccessed_ = Date.now();
145 // TODO: Localize?
147  * @type {string} Suffix for a edited copy file name.
148  * @const
149  */
150 Gallery.Item.COPY_SIGNATURE = ' - Edited';
153  * Regular expression to match '... - Edited'.
154  * @type {!RegExp}
155  * @const
156  */
157 Gallery.Item.REGEXP_COPY_0 =
158     new RegExp('^(.+)' + Gallery.Item.COPY_SIGNATURE + '$');
161  * Regular expression to match '... - Edited (N)'.
162  * @type {!RegExp}
163  * @const
164  */
165 Gallery.Item.REGEXP_COPY_N =
166     new RegExp('^(.+)' + Gallery.Item.COPY_SIGNATURE + ' \\((\\d+)\\)$');
169  * Creates a name for an edited copy of the file.
171  * @param {!DirectoryEntry} dirEntry Entry.
172  * @param {string} newMimeType Mime type of new image.
173  * @param {function(string)} callback Callback.
174  * @private
175  */
176 Gallery.Item.prototype.createCopyName_ = function(
177     dirEntry, newMimeType, callback) {
178   var name = this.getFileName();
180   // If the item represents a file created during the current Gallery session
181   // we reuse it for subsequent saves instead of creating multiple copies.
182   if (!this.original_) {
183     callback(name);
184     return;
185   }
187   var baseName = name.replace(/\.[^\.\/]+$/, '');
188   var ext = newMimeType === 'image/jpeg' ? '.jpg' : '.png';
190   function tryNext(tries) {
191     // All the names are used. Let's overwrite the last one.
192     if (tries == 0) {
193       setTimeout(callback, 0, baseName + ext);
194       return;
195     }
197     // If the file name contains the copy signature add/advance the sequential
198     // number.
199     var matchN = Gallery.Item.REGEXP_COPY_N.exec(baseName);
200     var match0 = Gallery.Item.REGEXP_COPY_0.exec(baseName);
201     if (matchN && matchN[1] && matchN[2]) {
202       var copyNumber = parseInt(matchN[2], 10) + 1;
203       baseName = matchN[1] + Gallery.Item.COPY_SIGNATURE +
204           ' (' + copyNumber + ')';
205     } else if (match0 && match0[1]) {
206       baseName = match0[1] + Gallery.Item.COPY_SIGNATURE + ' (1)';
207     } else {
208       baseName += Gallery.Item.COPY_SIGNATURE;
209     }
211     dirEntry.getFile(baseName + ext, {create: false, exclusive: false},
212         tryNext.bind(null, tries - 1),
213         callback.bind(null, baseName + ext));
214   }
216   tryNext(10);
220  * Returns true if the original format is writable format of Gallery.
221  * @return {boolean} True if the original format is writable format.
222  */
223 Gallery.Item.prototype.isWritableFormat = function() {
224   var type = FileType.getType(this.entry_);
225   return type.type === 'image' &&
226       (type.subtype === 'JPEG' || type.subtype === 'PNG')
230  * Returns true if the entry of item is writable.
231  * @param {!VolumeManagerWrapper} volumeManager Volume manager.
232  * @return {boolean} True if the entry of item is writable.
233  */
234 Gallery.Item.prototype.isWritableFile = function(volumeManager) {
235   return this.isWritableFormat() &&
236       !this.locationInfo_.isReadOnly &&
237       !GalleryUtil.isOnMTPVolume(this.entry_, volumeManager);
241  * Returns mime type for saving an edit of this item.
242  * @return {string} Mime type.
243  * @private
244  */
245 Gallery.Item.prototype.getNewMimeType_ = function() {
246   return this.getFileName().match(/\.jpe?g$/i) || FileType.isRaw(this.entry_) ?
247       'image/jpeg' : 'image/png';
251  * Return copy name of this item.
252  * @param {!DirectoryEntry} dirEntry Parent directory entry of copied item.
253  * @return {!Promise<string>} A promise which will be fulfilled with copy name.
254  */
255 Gallery.Item.prototype.getCopyName = function(dirEntry) {
256   return new Promise(this.createCopyName_.bind(
257       this, dirEntry, this.getNewMimeType_()));
261  * Writes the new item content to either the existing or a new file.
263  * @param {!VolumeManagerWrapper} volumeManager Volume manager instance.
264  * @param {!MetadataModel} metadataModel
265  * @param {DirectoryEntry} fallbackDir Fallback directory in case the current
266  *     directory is read only.
267  * @param {!HTMLCanvasElement} canvas Source canvas.
268  * @param {boolean} overwrite Set true to overwrite original if it's possible.
269  * @param {function(boolean)} callback Callback accepting true for success.
270  */
271 Gallery.Item.prototype.saveToFile = function(
272     volumeManager, metadataModel, fallbackDir, canvas, overwrite, callback) {
273   ImageUtil.metrics.startInterval(ImageUtil.getMetricName('SaveTime'));
275   var name = this.getFileName();
276   var newMimeType = this.getNewMimeType_();
278   var onSuccess = function(entry) {
279     var locationInfo = volumeManager.getLocationInfo(entry);
280     if (!locationInfo) {
281       // Reuse old location info if it fails to obtain location info.
282       locationInfo = this.locationInfo_;
283     }
284     ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('SaveResult'), 1, 2);
285     ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('SaveTime'));
287     this.entry_ = entry;
288     this.locationInfo_ = locationInfo;
290     // Updates the metadata.
291     metadataModel.notifyEntriesChanged([this.entry_]);
292     Promise.all([
293       metadataModel.get([entry], Gallery.PREFETCH_PROPERTY_NAMES),
294       new ThumbnailModel(metadataModel).get([entry])
295     ]).then(function(metadataLists) {
296       this.metadataItem_ = metadataLists[0][0];
297       this.thumbnailMetadataItem_ = metadataLists[1][0];
298       callback(true);
299     }.bind(this), function() {
300       callback(false);
301     });
302   }.bind(this);
304   var onError = function(error) {
305     console.error('Error saving from gallery', name, error);
306     ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('SaveResult'), 0, 2);
307     if (callback)
308       callback(false);
309   };
311   var doSave = function(newFile, fileEntry) {
312     var blob;
313     var fileWriter;
315     metadataModel.get(
316         [fileEntry],
317         ['mediaMimeType', 'contentMimeType', 'ifd', 'exifLittleEndian']
318     ).then(function(metadataItems) {
319       // Create the blob of new image.
320       var metadataItem = metadataItems[0];
321       metadataItem.modificationTime = new Date();
322       metadataItem.mediaMimeType = newMimeType;
323       var metadataEncoder = ImageEncoder.encodeMetadata(
324           metadataItem, canvas, /* quality for thumbnail*/ 0.8);
325       // Contrary to what one might think 1.0 is not a good default. Opening
326       // and saving an typical photo taken with consumer camera increases
327       // its file size by 50-100%. Experiments show that 0.9 is much better.
328       // It shrinks some photos a bit, keeps others about the same size, but
329       // does not visibly lower the quality.
330       blob = ImageEncoder.getBlob(canvas, metadataEncoder, 0.9);
331     }.bind(this)).then(function() {
332       // Create writer.
333       return new Promise(function(fullfill, reject) {
334         fileEntry.createWriter(fullfill, reject);
335       });
336     }).then(function(writer) {
337       fileWriter = writer;
339       // Truncates the file to 0 byte if it overwrites.
340       return new Promise(function(fulfill, reject) {
341         if (!newFile) {
342           fileWriter.onerror = reject;
343           fileWriter.onwriteend = fulfill;
344           fileWriter.truncate(0);
345         } else {
346           fulfill(null);
347         }
348       });
349     }).then(function() {
350       // Writes the blob of new image.
351       return new Promise(function(fulfill, reject) {
352         fileWriter.onerror = reject;
353         fileWriter.onwriteend = fulfill;
354         fileWriter.write(blob);
355       });
356     }).then(onSuccess.bind(null, fileEntry))
357     .catch(function(error) {
358       onError(error);
359       if (fileWriter) {
360         // Disable all callbacks on the first error.
361         fileWriter.onerror = null;
362         fileWriter.onwriteend = null;
363       }
364     });
365   }.bind(this);
367   var getFile = function(dir, newFile) {
368     dir.getFile(name, {create: newFile, exclusive: newFile},
369         function(fileEntry) {
370           doSave(newFile, fileEntry);
371         }.bind(this), onError);
372   }.bind(this);
374   var checkExistence = function(dir) {
375     dir.getFile(name, {create: false, exclusive: false},
376         getFile.bind(null, dir, false /* existing file */),
377         getFile.bind(null, dir, true /* create new file */));
378   };
380   var saveToDir = function(dir) {
381     if (overwrite &&
382         !this.locationInfo_.isReadOnly &&
383         this.isWritableFormat()) {
384       checkExistence(dir);
385       return;
386     }
388     this.createCopyName_(dir, newMimeType, function(copyName) {
389       this.original_ = false;
390       name = copyName;
391       checkExistence(dir);
392     }.bind(this));
393   }.bind(this);
395   // Since in-place editing is not supported on MTP volume, Gallery.app handles
396   // MTP volume as read only volume.
397   if (this.locationInfo_.isReadOnly ||
398       GalleryUtil.isOnMTPVolume(this.entry_, volumeManager)) {
399     saveToDir(fallbackDir);
400   } else {
401     this.entry_.getParent(saveToDir, onError);
402   }
406  * Renames the item.
408  * @param {string} displayName New display name (without the extension).
409  * @return {!Promise} Promise fulfilled with when renaming completes, or
410  *     rejected with the error message.
411  */
412 Gallery.Item.prototype.rename = function(displayName) {
413   var newFileName = this.entry_.name.replace(
414       ImageUtil.getDisplayNameFromName(this.entry_.name), displayName);
416   if (newFileName === this.entry_.name)
417     return Promise.reject('NOT_CHANGED');
419   if (/^\s*$/.test(displayName))
420     return Promise.reject(str('ERROR_WHITESPACE_NAME'));
422   var parentDirectoryPromise = new Promise(
423       this.entry_.getParent.bind(this.entry_));
424   return parentDirectoryPromise.then(function(parentDirectory) {
425     var nameValidatingPromise =
426         util.validateFileName(parentDirectory, newFileName, true);
427     return nameValidatingPromise.then(function() {
428       var existingFilePromise = new Promise(parentDirectory.getFile.bind(
429           parentDirectory, newFileName, {create: false, exclusive: false}));
430       return existingFilePromise.then(function() {
431         return Promise.reject(str('GALLERY_FILE_EXISTS'));
432       }, function() {
433         return new Promise(
434             this.entry_.moveTo.bind(this.entry_, parentDirectory, newFileName));
435       }.bind(this));
436     }.bind(this));
437   }.bind(this)).then(function(entry) {
438     this.entry_ = entry;
439   }.bind(this));