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. 4 5 'use strict'; 6 7 /** 8 * Object representing an image item (a photo). 9 * 10 * @param {FileEntry} entry Image entry. 11 * @param {EntryLocation} locationInfo Entry location information. 12 * @param {Object} metadata Metadata for the entry. 13 * @param {MetadataCache} metadataCache Metadata cache instance. 14 * @param {boolean} original Whether the entry is original or edited. 15 * @constructor 16 */ 17 Gallery.Item = function( 18 entry, locationInfo, metadata, metadataCache, original) { 19 /** 20 * @type {FileEntry} 21 * @private 22 */ 23 this.entry_ = entry; 24 25 /** 26 * @type {EntryLocation} 27 * @private 28 */ 29 this.locationInfo_ = locationInfo; 30 31 /** 32 * @type {Object} 33 * @private 34 */ 35 this.metadata_ = Object.freeze(metadata); 36 37 /** 38 * @type {MetadataCache} 39 * @private 40 */ 41 this.metadataCache_ = metadataCache; 42 43 /** 44 * The content cache is used for prefetching the next image when going through 45 * the images sequentially. The real life photos can be large (18Mpix = 72Mb 46 * pixel array) so we want only the minimum amount of caching. 47 * @type {Canvas} 48 */ 49 this.screenImage = null; 50 51 /** 52 * We reuse previously generated screen-scale images so that going back to a 53 * recently loaded image looks instant even if the image is not in the content 54 * cache any more. Screen-scale images are small (~1Mpix) so we can afford to 55 * cache more of them. 56 * @type {Canvas} 57 */ 58 this.contentImage = null; 59 60 /** 61 * Last accessed date to be used for selecting items whose cache are evicted. 62 * @type {number} 63 * @private 64 */ 65 this.lastAccessed_ = Date.now(); 66 67 /** 68 * @type {boolean} 69 * @private 70 */ 71 this.original_ = original; 72 73 Object.seal(this); 74 }; 75 76 /** 77 * @return {FileEntry} Image entry. 78 */ 79 Gallery.Item.prototype.getEntry = function() { return this.entry_; }; 80 81 /** 82 * @return {EntryLocation} Entry location information. 83 */ 84 Gallery.Item.prototype.getLocationInfo = function() { 85 return this.locationInfo_; 86 }; 87 88 /** 89 * @return {Object} Metadata. 90 */ 91 Gallery.Item.prototype.getMetadata = function() { return this.metadata_; }; 92 93 /** 94 * Obtains the latest media metadata. 95 * 96 * This is a heavy operation since it forces to load the image data to obtain 97 * the metadata. 98 * @return {Promise} Promise to be fulfilled with fetched metadata. 99 */ 100 Gallery.Item.prototype.getFetchedMedia = function() { 101 return new Promise(function(fulfill, reject) { 102 this.metadataCache_.getLatest( 103 [this.entry_], 104 'fetchedMedia', 105 function(metadata) { 106 if (metadata[0]) 107 fulfill(metadata[0]); 108 else 109 reject('Failed to load metadata.'); 110 }); 111 }.bind(this)); 112 }; 113 114 /** 115 * Sets the metadata. 116 * @param {Object} metadata New metadata. 117 */ 118 Gallery.Item.prototype.setMetadata = function(metadata) { 119 this.metadata_ = Object.freeze(metadata); 120 }; 121 122 /** 123 * @return {string} File name. 124 */ 125 Gallery.Item.prototype.getFileName = function() { 126 return this.entry_.name; 127 }; 128 129 /** 130 * @return {boolean} True if this image has not been created in this session. 131 */ 132 Gallery.Item.prototype.isOriginal = function() { return this.original_; }; 133 134 /** 135 * Obtains the last accessed date. 136 * @return {number} Last accessed date. 137 */ 138 Gallery.Item.prototype.getLastAccessedDate = function() { 139 return this.lastAccessed_; 140 }; 141 142 /** 143 * Updates the last accessed date. 144 */ 145 Gallery.Item.prototype.touch = function() { 146 this.lastAccessed_ = Date.now(); 147 }; 148 149 // TODO: Localize? 150 /** 151 * @type {string} Suffix for a edited copy file name. 152 */ 153 Gallery.Item.COPY_SIGNATURE = ' - Edited'; 154 155 /** 156 * Regular expression to match '... - Edited'. 157 * @type {RegExp} 158 */ 159 Gallery.Item.REGEXP_COPY_0 = 160 new RegExp('^(.+)' + Gallery.Item.COPY_SIGNATURE + '$'); 161 162 /** 163 * Regular expression to match '... - Edited (N)'. 164 * @type {RegExp} 165 */ 166 Gallery.Item.REGEXP_COPY_N = 167 new RegExp('^(.+)' + Gallery.Item.COPY_SIGNATURE + ' \\((\\d+)\\)$'); 168 169 /** 170 * Creates a name for an edited copy of the file. 171 * 172 * @param {DirectoryEntry} dirEntry Entry. 173 * @param {function} callback Callback. 174 * @private 175 */ 176 Gallery.Item.prototype.createCopyName_ = function(dirEntry, callback) { 177 var name = this.getFileName(); 178 179 // If the item represents a file created during the current Gallery session 180 // we reuse it for subsequent saves instead of creating multiple copies. 181 if (!this.original_) { 182 callback(name); 183 return; 184 } 185 186 var ext = ''; 187 var index = name.lastIndexOf('.'); 188 if (index != -1) { 189 ext = name.substr(index); 190 name = name.substr(0, index); 191 } 192 193 if (!ext.match(/jpe?g/i)) { 194 // Chrome can natively encode only two formats: JPEG and PNG. 195 // All non-JPEG images are saved in PNG, hence forcing the file extension. 196 ext = '.png'; 197 } 198 199 function tryNext(tries) { 200 // All the names are used. Let's overwrite the last one. 201 if (tries == 0) { 202 setTimeout(callback, 0, name + ext); 203 return; 204 } 205 206 // If the file name contains the copy signature add/advance the sequential 207 // number. 208 var matchN = Gallery.Item.REGEXP_COPY_N.exec(name); 209 var match0 = Gallery.Item.REGEXP_COPY_0.exec(name); 210 if (matchN && matchN[1] && matchN[2]) { 211 var copyNumber = parseInt(matchN[2], 10) + 1; 212 name = matchN[1] + Gallery.Item.COPY_SIGNATURE + ' (' + copyNumber + ')'; 213 } else if (match0 && match0[1]) { 214 name = match0[1] + Gallery.Item.COPY_SIGNATURE + ' (1)'; 215 } else { 216 name += Gallery.Item.COPY_SIGNATURE; 217 } 218 219 dirEntry.getFile(name + ext, {create: false, exclusive: false}, 220 tryNext.bind(null, tries - 1), 221 callback.bind(null, name + ext)); 222 } 223 224 tryNext(10); 225 }; 226 227 /** 228 * Writes the new item content to either the existing or a new file. 229 * 230 * @param {VolumeManager} volumeManager Volume manager instance. 231 * @param {string} fallbackDir Fallback directory in case the current directory 232 * is read only. 233 * @param {boolean} overwrite Whether to overwrite the image to the item or not. 234 * @param {HTMLCanvasElement} canvas Source canvas. 235 * @param {ImageEncoder.MetadataEncoder} metadataEncoder MetadataEncoder. 236 * @param {function(boolean)=} opt_callback Callback accepting true for success. 237 */ 238 Gallery.Item.prototype.saveToFile = function( 239 volumeManager, fallbackDir, overwrite, canvas, metadataEncoder, 240 opt_callback) { 241 ImageUtil.metrics.startInterval(ImageUtil.getMetricName('SaveTime')); 242 243 var name = this.getFileName(); 244 245 var onSuccess = function(entry, locationInfo) { 246 ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('SaveResult'), 1, 2); 247 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('SaveTime')); 248 249 this.entry_ = entry; 250 this.locationInfo_ = locationInfo; 251 252 this.metadataCache_.clear([this.entry_], 'fetchedMedia'); 253 if (opt_callback) 254 opt_callback(true); 255 }.bind(this); 256 257 var onError = function(error) { 258 console.error('Error saving from gallery', name, error); 259 ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('SaveResult'), 0, 2); 260 if (opt_callback) 261 opt_callback(false); 262 } 263 264 var doSave = function(newFile, fileEntry) { 265 fileEntry.createWriter(function(fileWriter) { 266 function writeContent() { 267 fileWriter.onwriteend = onSuccess.bind(null, fileEntry); 268 fileWriter.write(ImageEncoder.getBlob(canvas, metadataEncoder)); 269 } 270 fileWriter.onerror = function(error) { 271 onError(error); 272 // Disable all callbacks on the first error. 273 fileWriter.onerror = null; 274 fileWriter.onwriteend = null; 275 }; 276 if (newFile) { 277 writeContent(); 278 } else { 279 fileWriter.onwriteend = writeContent; 280 fileWriter.truncate(0); 281 } 282 }, onError); 283 } 284 285 var getFile = function(dir, newFile) { 286 dir.getFile(name, {create: newFile, exclusive: newFile}, 287 function(fileEntry) { 288 var locationInfo = volumeManager.getLocationInfo(fileEntry); 289 // If the volume is gone, then abort the saving operation. 290 if (!locationInfo) { 291 onError('NotFound'); 292 return; 293 } 294 doSave(newFile, fileEntry, locationInfo); 295 }.bind(this), onError); 296 }.bind(this); 297 298 var checkExistence = function(dir) { 299 dir.getFile(name, {create: false, exclusive: false}, 300 getFile.bind(null, dir, false /* existing file */), 301 getFile.bind(null, dir, true /* create new file */)); 302 } 303 304 var saveToDir = function(dir) { 305 if (overwrite && !this.locationInfo_.isReadOnly) { 306 checkExistence(dir); 307 } else { 308 this.createCopyName_(dir, function(copyName) { 309 this.original_ = false; 310 name = copyName; 311 checkExistence(dir); 312 }.bind(this)); 313 } 314 }.bind(this); 315 316 if (this.locationInfo_.isReadOnly) { 317 saveToDir(fallbackDir); 318 } else { 319 this.entry_.getParent(saveToDir, onError); 320 } 321 }; 322 323 /** 324 * Renames the item. 325 * 326 * @param {string} displayName New display name (without the extension). 327 * @return {Promise} Promise fulfilled with when renaming completes, or rejected 328 * with the error message. 329 */ 330 Gallery.Item.prototype.rename = function(displayName) { 331 var newFileName = this.entry_.name.replace( 332 ImageUtil.getDisplayNameFromName(this.entry_.name), displayName); 333 334 if (newFileName === this.entry_.name) 335 return Promise.reject('NOT_CHANGED'); 336 337 if (/^\s*$/.test(displayName)) 338 return Promise.reject(str('ERROR_WHITESPACE_NAME')); 339 340 var parentDirectoryPromise = new Promise( 341 this.entry_.getParent.bind(this.entry_)); 342 return parentDirectoryPromise.then(function(parentDirectory) { 343 var nameValidatingPromise = 344 util.validateFileName(parentDirectory, newFileName, true); 345 return nameValidatingPromise.then(function() { 346 var existingFilePromise = new Promise(parentDirectory.getFile.bind( 347 parentDirectory, newFileName, {create: false, exclusive: false})); 348 return existingFilePromise.then(function() { 349 return Promise.reject(str('GALLERY_FILE_EXISTS')); 350 }, function() { 351 return new Promise( 352 this.entry_.moveTo.bind(this.entry_, parentDirectory, newFileName)); 353 }.bind(this)); 354 }.bind(this)); 355 }.bind(this)).then(function(entry) { 356 this.entry_ = entry; 357 }.bind(this)); 358 }; 359