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 */
17Gallery.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 */
79Gallery.Item.prototype.getEntry = function() { return this.entry_; };
80
81/**
82 * @return {EntryLocation} Entry location information.
83 */
84Gallery.Item.prototype.getLocationInfo = function() {
85  return this.locationInfo_;
86};
87
88/**
89 * @return {Object} Metadata.
90 */
91Gallery.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 */
100Gallery.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 */
118Gallery.Item.prototype.setMetadata = function(metadata) {
119  this.metadata_ = Object.freeze(metadata);
120};
121
122/**
123 * @return {string} File name.
124 */
125Gallery.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 */
132Gallery.Item.prototype.isOriginal = function() { return this.original_; };
133
134/**
135 * Obtains the last accessed date.
136 * @return {number} Last accessed date.
137 */
138Gallery.Item.prototype.getLastAccessedDate = function() {
139  return this.lastAccessed_;
140};
141
142/**
143 * Updates the last accessed date.
144 */
145Gallery.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 */
153Gallery.Item.COPY_SIGNATURE = ' - Edited';
154
155/**
156 * Regular expression to match '... - Edited'.
157 * @type {RegExp}
158 */
159Gallery.Item.REGEXP_COPY_0 =
160    new RegExp('^(.+)' + Gallery.Item.COPY_SIGNATURE + '$');
161
162/**
163 * Regular expression to match '... - Edited (N)'.
164 * @type {RegExp}
165 */
166Gallery.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 */
176Gallery.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 */
238Gallery.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 */
330Gallery.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