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.
9 * @param {!RecordStorage} storage
11 function ImportHistory(storage) {
13 /** @private {!RecordStorage} */
14 this.storage_ = storage;
16 /** @private {!Object.<string, !Array.<string>>} */
19 /** @private {!Promise.<!ImportHistory>} */
20 this.whenReady_ = this.refresh_();
24 * Loads history from disk and merges in any previously existing entries
25 * that are not present in the newly loaded data. Should be called
26 * when the file is changed by an external source.
28 * @return {!Promise.<!ImportHistory>} Resolves when history has been refreshed.
31 ImportHistory.prototype.refresh_ = function() {
32 var oldEntries = this.entries_;
34 return this.storage_.readAll()
35 .then(this.updateHistoryRecords_.bind(this))
36 .then(this.mergeEntries_.bind(this, oldEntries))
39 * @return {!ImportHistory}
40 * @this {ImportHistory}
48 * Adds all entries not already present in history.
50 * @param {!Object.<string, !Array.<string>>} entries
51 * @return {!Promise.<?>} Resolves once all updates are completed.
54 ImportHistory.prototype.mergeEntries_ = function(entries) {
56 Object.keys(entries).forEach(
59 * @this {ImportHistory}
65 * @this {ImportHistory}
67 function(destination) {
68 if (this.getDestinations_(key).indexOf(destination) >= 0) {
69 this.updateHistoryRecord_(key, destination);
70 promises.push(this.storage_.write([key, destination]));
74 return Promise.all(promises);
78 * Reloads history from disk. Should be called when the file
79 * is changed by an external source.
81 * @return {!Promise.<!ImportHistory>} Resolves when history has been refreshed.
83 ImportHistory.prototype.refresh = function() {
84 this.whenReady_ = this.refresh_();
85 return this.whenReady_;
89 * @return {!Promise.<!ImportHistory>}
91 ImportHistory.prototype.whenReady = function() {
92 return this.whenReady_;
96 * Adds a history entry to the in-memory history model.
97 * @param {!Array.<!Array.<*>>} records
100 ImportHistory.prototype.updateHistoryRecords_ = function(records) {
103 * @param {!Array.<*>} entry
104 * @this {ImportHistory}
107 this.updateHistoryRecord_(record[0], record[1]);
112 * Adds a history entry to the in-memory history model.
113 * @param {string} key
114 * @param {string} destination
117 ImportHistory.prototype.updateHistoryRecord_ = function(key, destination) {
118 if (key in this.entries_) {
119 this.entries_[key].push(destination);
121 this.entries_[key] = [destination];
126 * @param {!FileEntry} entry
127 * @param {string} destination
128 * @return {!Promise.<boolean>} Resolves with true if the FileEntry
129 * was previously imported to the specified destination.
131 ImportHistory.prototype.wasImported = function(entry, destination) {
132 return this.whenReady_
133 .then(this.createKey_.bind(this, entry))
136 * @param {string} key
137 * @return {!Promise.<boolean>}
138 * @this {ImportHistory}
141 return this.getDestinations_(key).indexOf(destination) >= 0;
146 * @param {!FileEntry} entry
147 * @param {string} destination
148 * @return {!Promise.<?>} Resolves when the operation is completed.
150 ImportHistory.prototype.markImported = function(entry, destination) {
151 return this.whenReady_
152 .then(this.createKey_.bind(this, entry))
155 * @param {string} key
156 * @return {!Promise.<?>}
157 * @this {ImportHistory}
160 return this.addDestination_(destination, key);
165 * @param {string} destination
166 * @param {string} key
167 * @return {!Promise.<?>} Resolves once the write has been completed.
170 ImportHistory.prototype.addDestination_ = function(destination, key) {
171 this.updateHistoryRecord_(key, destination);
172 return this.storage_.write([key, destination]);
176 * @param {string} key
177 * @return {!Array.<string>} The list of previously noted
178 * destinations, or an empty array, if none.
181 ImportHistory.prototype.getDestinations_ = function(key) {
182 return key in this.entries_ ? this.entries_[key] : [];
186 * @param {!FileEntry} fileEntry
187 * @return {!Promise.<string>} Resolves with a the key is available.
190 ImportHistory.prototype.createKey_ = function(fileEntry) {
191 var entry = new PromisaryFileEntry(fileEntry);
194 * @param {function()} resolve
195 * @param {function()} reject
196 * @this {ImportHistory}
198 function(resolve, reject) {
202 * @param {!Object} metadata
203 * @return {!Promise.<string>}
204 * @this {ImportHistory}
207 if (!('modificationTime' in metadata)) {
208 reject('File entry missing "modificationTime" field.');
209 } else if (!('size' in metadata)) {
210 reject('File entry missing "size" field.');
213 metadata['modificationTime'] + '_' + metadata['size']);
220 * Provider of lazy loaded ImportHistory. This is the main
221 * access point for a fully prepared {@code ImportHistory} object.
225 function HistoryLoader() {}
228 * Instantiates an {@code ImportHistory} object and manages any
229 * necessary ongoing maintenance of the object with respect to
230 * its external dependencies.
232 * @see SynchronizedHistoryLoader for an example.
234 * @return {!Promise.<!ImportHistory>} Resolves when history instance is ready.
236 HistoryLoader.prototype.loadHistory;
239 * Class responsible for lazy loading of {@code ImportHistory},
240 * and reloading when the underlying data is updated (via sync).
243 * @implements {HistoryLoader}
246 * @param {!SyncFileEntryProvider} fileProvider
248 function SynchronizedHistoryLoader(fileProvider) {
250 /** @private {!SyncFileEntryProvider} */
251 this.fileProvider_ = fileProvider;
253 /** @private {!ImportHistory|undefined} */
258 SynchronizedHistoryLoader.prototype.loadHistory = function() {
260 return this.history_.whenReady();
263 this.fileProvider_.addSyncListener(
264 this.onSyncedDataChanged_.bind(this));
266 return this.fileProvider_.getSyncFileEntry()
269 * @param {!FileEntry} fileEntry
270 * @return {!Promise.<!ImportHistory>}
271 * @this {SynchronizedHistoryLoader}
273 function(fileEntry) {
274 var storage = new FileEntryRecordStorage(fileEntry);
275 var history = new ImportHistory(storage);
276 return history.refresh().then(
278 * @return {!ImportHistory}
279 * @this {SynchronizedHistoryLoader}
282 this.history_ = history;
289 * Handles file sync events, by simply reloading history. The presumption
290 * is that 99% of the time these events will basically be happening when
291 * there is no active import process.
295 SynchronizedHistoryLoader.prototype.onSyncedDataChanged_ = function() {
297 this.history_.refresh(); // Reload history entries.
302 * Factory interface for creating/accessing synced {@code FileEntry}
303 * instances and listening to sync events on those files.
307 function SyncFileEntryProvider() {}
310 * Adds a listener to be notified when the the FileEntry owned/managed
311 * by this class is updated via sync.
313 * @param {function()} syncListener
315 SyncFileEntryProvider.prototype.addSyncListener;
318 * Provides accsess to the sync FileEntry owned/managed by this class.
320 * @return {!Promise.<!FileEntry>}
322 SyncFileEntryProvider.prototype.getSyncFileEntry;
325 * Factory for synchronized files based on chrome.syncFileSystem.
328 * @implements {SyncFileEntryProvider}
331 function ChromeSyncFileEntryProvider() {
333 /** @private {!Array.<function()>} */
334 this.syncListeners_ = [];
336 /** @private {!Promise.<!FileEntry>|undefined} */
337 this.fileEntryPromise_;
340 /** @private @const {string} */
341 ChromeSyncFileEntryProvider.FILE_NAME_ = 'import-history.data';
344 * Wraps chrome.syncFileSystem.onFileStatusChanged
345 * so that we can report to our listeners when our file has changed.
348 ChromeSyncFileEntryProvider.prototype.monitorSyncEvents_ = function() {
349 chrome.syncFileSystem.onFileStatusChanged.addListener(
350 this.handleSyncEvent_.bind(this));
354 ChromeSyncFileEntryProvider.prototype.addSyncListener = function(listener) {
355 if (this.syncListeners_.indexOf(listener) === -1) {
356 this.syncListeners_.push(listener);
361 ChromeSyncFileEntryProvider.prototype.getSyncFileEntry = function() {
362 if (this.fileEntryPromise_) {
363 return this.fileEntryPromise_;
366 this.fileEntryPromise_ = this.getFileSystem_()
369 * @param {!FileSystem} fileSystem
370 * @return {!Promise.<!FileEntry>}
371 * @this {ChromeSyncFileEntryProvider}
373 function(fileSystem) {
374 return this.getFileEntry_(fileSystem);
377 return this.fileEntryPromise_;
381 * Wraps chrome.syncFileSystem in a Promise.
383 * @return {!Promise.<!FileSystem>}
386 ChromeSyncFileEntryProvider.prototype.getFileSystem_ = function() {
389 * @param {function()} resolve
390 * @param {function()} reject
391 * @this {ChromeSyncFileEntryProvider}
393 function(resolve, reject) {
394 chrome.syncFileSystem.requestFileSystem(
396 * @param {FileSystem} fileSystem
397 * @this {ChromeSyncFileEntryProvider}
399 function(fileSystem) {
400 if (chrome.runtime.lastError) {
401 reject(chrome.runtime.lastError.message);
403 resolve(/** @type {!FileSystem} */ (fileSystem));
410 * @param {!FileSystem} fileSystem
411 * @return {!Promise.<!FileEntry>}
414 ChromeSyncFileEntryProvider.prototype.getFileEntry_ = function(fileSystem) {
417 * @param {function()} resolve
418 * @param {function()} reject
419 * @this {ChromeSyncFileEntryProvider}
421 function(resolve, reject) {
422 fileSystem.root.getFile(
423 ChromeSyncFileEntryProvider.FILE_NAME_,
434 * Handles sync events. Checks to see if the event is for the file
435 * we track, and sync-direction, and if so, notifies syncListeners.
437 * @see https://developer.chrome.com/apps/syncFileSystem
438 * #event-onFileStatusChanged
440 * @param {!Object} event Having a structure not unlike: {
443 * action: (string|undefined),
444 * direction: (string|undefined)}
448 ChromeSyncFileEntryProvider.prototype.handleSyncEvent_ = function(event) {
449 if (!this.fileEntryPromise_) {
453 this.fileEntryPromise_.then(
455 * @param {!FileEntry} fileEntry
456 * @this {ChromeSyncFileEntryProvider}
458 function(fileEntry) {
459 if (event['fileEntry'].fullPath !== fileEntry.fullPath) {
463 if (event.direction && event.direction !== 'remote_to_local') {
467 if (event.action && event.action !== 'updated') {
469 'Unexpected sync event action for history file: ' + event.action);
473 this.syncListeners_.forEach(
475 * @param {function()} listener
476 * @this {ChromeSyncFileEntryProvider}
479 // Notify by way of a promise so that it is fully asynchronous
480 // (which can rationalize testing).
481 Promise.resolve().then(listener);
487 * An simple record storage mechanism.
491 function RecordStorage() {}
496 * @param {!Array.<*>} record
497 * @return {!Promise.<?>} Resolves when record is added.
499 RecordStorage.prototype.write;
504 * @return {!Promise.<!Array.<!Array.<*>>>}
506 RecordStorage.prototype.readAll;
509 * A {@code RecordStore} that persists data in a {@code FileEntry}.
511 * @param {!FileEntry} fileEntry
514 * @implements {RecordStorage}
517 function FileEntryRecordStorage(fileEntry) {
518 /** @private {!PromisaryFileEntry} */
519 this.fileEntry_ = new PromisaryFileEntry(fileEntry);
523 FileEntryRecordStorage.prototype.write = function(record) {
524 // TODO(smckay): should we make an effort to reuse a file writer?
525 return this.fileEntry_.createWriter()
526 .then(this.writeRecord_.bind(this, record));
530 * Appends a new record to the end of the file.
532 * @param {!Object} record
533 * @param {!FileWriter} writer
534 * @return {!Promise.<?>} Resolves when write is complete.
537 FileEntryRecordStorage.prototype.writeRecord_ = function(record, writer) {
540 * @param {function()} resolve
541 * @param {function()} reject
542 * @this {FileEntryRecordStorage}
544 function(resolve, reject) {
546 [JSON.stringify(record) + ',\n'],
547 {type: 'text/plain; charset=UTF-8'});
549 writer.onwriteend = resolve;
550 writer.onerror = reject;
552 writer.seek(writer.length);
558 FileEntryRecordStorage.prototype.readAll = function() {
559 return this.fileEntry_.file()
561 this.readFileAsText_.bind(this),
564 * @this {FileEntryRecordStorage}
567 console.error('Unable to read from history file.');
572 * @param {string} fileContents
573 * @this {FileEntryRecordStorage}
575 function(fileContents) {
576 return this.parse_(fileContents);
581 * Reads the entire entry as a single string value.
583 * @param {!File} file
584 * @return {!Promise.<string>}
587 FileEntryRecordStorage.prototype.readFileAsText_ = function(file) {
590 * @param {function()} resolve
591 * @param {function()} reject
592 * @this {FileEntryRecordStorage}
594 function(resolve, reject) {
595 var reader = new FileReader();
597 reader.onloadend = function() {
599 console.error(reader.error);
602 resolve(reader.result);
606 reader.onerror = function(error) {
607 console.error(error);
611 reader.readAsText(file);
618 * @param {string} text
619 * @return {!Promise.<!Array.<!Array.<*>>>}
622 FileEntryRecordStorage.prototype.parse_ = function(text) {
625 * @param {function()} resolve
626 * @param {function()} reject
627 * @this {FileEntryRecordStorage}
629 function(resolve, reject) {
630 if (text.length === 0) {
633 // Dress up the contents of the file like an array,
634 // so the JSON object can parse it using JSON.parse.
635 // That means we need to both:
636 // 1) Strip the trailing ',\n' from the last record
637 // 2) Surround the whole string in brackets.
638 // NOTE: JSON.parse is WAY faster than parsing this
639 // ourselves in javascript.
640 var json = '[' + text.substring(0, text.length - 2) + ']';
641 resolve(JSON.parse(json));
647 * A wrapper for FileEntry that provides Promises.
649 * @param {!FileEntry} fileEntry
654 function PromisaryFileEntry(fileEntry) {
655 /** @private {!FileEntry} */
656 this.fileEntry_ = fileEntry;
660 * A "Promisary" wrapper around entry.getWriter.
661 * @return {!Promise.<!FileWriter>}
663 PromisaryFileEntry.prototype.createWriter = function() {
664 return new Promise(this.fileEntry_.createWriter.bind(this.fileEntry_));
668 * A "Promisary" wrapper around entry.file.
669 * @return {!Promise.<!File>}
671 PromisaryFileEntry.prototype.file = function() {
672 return new Promise(this.fileEntry_.file.bind(this.fileEntry_));
676 * @return {!Promise.<!Object>}
678 PromisaryFileEntry.prototype.getMetadata = function() {
679 return new Promise(this.fileEntry_.getMetadata.bind(this.fileEntry_));