'use strict'; /* globals Promise, asyncStorage, Service, BaseModule */ /* exported Places */ (function() { const DEBOUNCE_TIME = 2000; const SCREENSHOT_TIMEOUT = 5000; /** * Places is the browser history, bookmark and icon management system for * B2G. Places monitors app events and syncs information with the Places * datastore for consumption by apps like Search. * @requires BaseModule * @class Places */ function Places() {} Places.SUB_MODULES = [ 'BrowserSettings' ]; Places.SERVICES = [ 'clearHistory' ]; BaseModule.create(Places, { name: 'Places', /** * The places store name. * @memberof Places.prototype * @type {String} */ STORE_NAME: 'places', /** * A reference to the places datastore. * @memberof Places.prototype * @type {Object} */ dataStore: null, /** * Set when we are editing a place record in the datastore. * @memberof Places.prototype * @type {Boolean} */ writeInProgress: false, /** * A queue of screenshot URLs that we are loading. * @memberof Places.prototype * @type {Array} */ screenshotQueue: {}, /** * Maximum number of top sites we display * @memberof Places.prototype * @type {Integer} */ MAX_TOP_SITES: 6, topSites: [], /** * A list of debounced changes to places, keyed by URL. * @memberof Places.prototype * @type {Object} */ _placeChanges: {}, /** * Maps URLs to debounce save timeouts. The place is saved after the * timeout is reached, or on appload. * @memberof Places.prototype * @type {Object} */ _timeouts: {}, /** * Starts places. * Adds necessary event listeners and gets the datastore. * @param {Function} callback * @memberof Places.prototype */ _start: function() { return new Promise(resolve => { window.addEventListener('applocationchange', this); window.addEventListener('apptitlechange', this); window.addEventListener('appiconchange', this); window.addEventListener('appmetachange', this); window.addEventListener('apploaded', this); asyncStorage.getItem('top-sites', results => { this.topSites = this._removeDupes(results || []); resolve(); }); }); }, /** * Remove duplicated entries. * @param {Array} topSites array of places object from asyncStorage() * @return {Array} of places object without duplicated entries */ _removeDupes: function(ts) { var copy = []; var copied = {}; ts.forEach(function(place) { // Copy everything except for places we already did the copy. Since // |checkTopSites()| does the ordering by decreasing frecency before // saving to asyncStorage, then we know the first one we will copy will // be the biggest frecency value. if (place.url && !(place.url in copied)) { copied[place.url] = true; copy.push(place); } }); return copy; }, getStore: function() { return new Promise(resolve => { if (this.dataStore) { return resolve(this.dataStore); } navigator.getDataStores(this.STORE_NAME).then(stores => { this.dataStore = stores[0]; return resolve(this.dataStore); }); }); }, /** * General event handler interface. * @param {Event} evt The event. * @memberof Places.prototype */ handleEvent: function(evt) { var app = evt.detail; // If the app is not a browser, do not track places as tracking places // currently has a non-trivial startup cost. if (app && !app.isBrowser()) { return; } // Do not persist information for private browsers. if (app && app.isPrivateBrowser()) { return; } switch (evt.type) { case 'applocationchange': this.onLocationChange(app.config.url); break; case 'apptitlechange': this.onTitleChange(app.config.url, app.title); break; case 'appiconchange': this.onIconChange(app.config.url, app.favicons); break; case 'appmetachange': this.onMetaChange(app.config.url, app.meta); break; case 'apploaded': if (app.config.url in this.screenshotQueue) { this.takeScreenshot(app.config.url); } this.debouncePlaceChanges(app.config.url); break; } }, /** * Requests a screenshot of a URL. * @param {String} url The URL of a page. * @memberof Places.prototype */ screenshotRequested: function(url) { var app = Service.query('getAppByURL', url); if (!app || app.loading) { this.screenshotQueue[url] = setTimeout(() => { this.takeScreenshot(url); }, SCREENSHOT_TIMEOUT); } else { this.takeScreenshot(url); } }, takeScreenshot: function(url) { if (url in this.screenshotQueue) { clearTimeout(this.screenshotQueue[url]); delete this.screenshotQueue[url]; } var app = Service.query('getAppByURL', url); if (!app) { console.error('Couldnt find app for:', url); return false; } app.getBottomMostWindow().getScreenshot(screenshot => { if (screenshot) { this.saveScreenshot(url, screenshot); } }, null, null, null, true); }, /** * Formats a URL as a place object. * @param {String} url The URL of a place. * @return {Object} * @memberof Places.prototype */ defaultPlace: function(url) { return { url: url, title: url, icons: {}, meta : {}, frecency: 0, // An array containing previous visits to this url visits: [], screenshot: null, themeColor: null }; }, /** * Helper function to edit a place record in the datastore. * @param {String} url The URL of a place. * @param {Function} fun Handles place updates. * @memberof Places.prototype */ editPlace: function(url, fun) { return new Promise(resolve => { this.getStore().then(store => { var rev = store.revisionId; store.get(url).then(place => { place = place || this.defaultPlace(url); fun(place, newPlace => { if (this.writeInProgress || store.revisionId !== rev) { return this.editPlace(url, fun); } this.writeInProgress = true; store.put(newPlace, url).then(() => { this.writeInProgress = false; resolve(); }); }); }); }); }); }, /** * Manually set the previous visits array of timestamps, used for * migrations */ setVisits: function(url, visits) { return this.editPlace(url, (place, cb) => { place.visits = place.visits || []; place.visits = place.visits.concat(visits); place.visits.sort((a, b) => { return b - a; }); cb(place); }); }, /** * Pin/unpin a page. * * @param {String} url The URL of the page to pin. * @param {Boolean} value true for pin, false for unpin. * @returns {Promise} Promise of a response. */ setPinned: function(url, value) { return this.editPlace(url, (place, callback) => { place.pinned = value; if (value) { place.pinTime = Date.now(); } callback(place); }); }, /** * Is a page currently pinned? * * @param {String} url The URL of the page to check. * @returns {Promise} Promise of a response. */ isPinned: function(url) { return new Promise((resolve, reject) => { return this.getStore() .then(store => { return store.get(url); }) .then(place => { return resolve(!!place.pinned); }) .catch(e => { console.error(`Error getting the page details: ${e}`); return reject(e); }); }); }, /* * Add a recorded visit to the history, we prune them to the last * TRUNCATE_VISITS number of visits and store them in a low enough * resolution to render the view (one per day) */ TRUNCATE_VISITS: 10, addToVisited: function(place) { place.visits = place.visits || []; if (!place.visits.length) { place.visits.unshift(place.visited); return place; } // If the last visit was within the last 24 hours, remove // it as we only need a resolution of one day var lastVisit = place.visits[0]; if (lastVisit > (Date.now() - 60 * 60 * 24 * 1000)) { place.visits.shift(); } place.visits.unshift(place.visited); if (place.visits.length > this.TRUNCATE_VISITS) { place.visits.length = this.TRUNCATE_VISITS; } return place; }, /** * Check if we need to render a screenshot of the current visit * in the case that it is in the top most visited sites */ checkTopSites: function(place) { var numTopSites = this.topSites.length; var lastTopSite = this.topSites[numTopSites - 1]; if (numTopSites < this.MAX_TOP_SITES || place.frecency > lastTopSite.frecency) { // Remove any pre-existing entry from topSites that matches that // specific place URL to avoid duplicates. We will push the new place // after. var newTopSites = []; this.topSites.forEach(e => { if (e.url !== place.url) { newTopSites.push(e); } }); this.topSites = newTopSites; this.topSites.push(place); this.screenshotRequested(place.url); this.topSites.sort(function(a, b) { return b.frecency - a.frecency; }); if (this.topSites.length > this.MAX_TOP_SITES) { this.topSites.length = this.MAX_TOP_SITES; } asyncStorage.setItem('top-sites', this.topSites); } }, saveScreenshot: function(url, screenshot) { return this.editPlace(url, function(place, cb) { place.screenshot = screenshot; cb(place); }); }, /** * Update the theme color of a page in the places db. * * @param {String} url The URL of the page * @param {String} color The CSS color */ saveThemeColor: function(url, color) { return this.editPlace(url, function(place, cb) { place.themeColor = color; cb(place); }); }, /** * Clear all the visits in the store but the pinned pages. * * @return Promise */ clearHistory: function() { return new Promise((resolve, reject) => { return this.getStore().then(store => { store.getLength().then((storeLength) => { if (!storeLength) { return resolve(); } new Promise((resolveInner, rejectInner) => { var urls = new Map(); var cursor = store.sync(); function cursorResolve(task) { switch (task.operation) { case 'update': case 'add': urls.set(task.id, task.data); break; case 'remove': urls.delete(task.id, task.data); break; case 'clear': urls.clear(); break; case 'done': return resolveInner(urls); } cursor.next().then(cursorResolve, rejectInner); } cursor.next().then(cursorResolve, rejectInner); }) .then((urls) => { var promises = []; urls.forEach((val, key) => { if (val.pinned) { // Clear the visit history of pinned pages. promises.push(this.editPlace(key, function(place, cb) { place.visits = []; cb(place); })); } else { // Remove all other pages from history. promises.push(store.remove(key)); } }); Promise.all(promises) .then(() => { console.log('Browsing history successfully cleared.'); resolve(); }); }) .catch((e) => { console.error(`Error trying to clear browsing history: ${e}`); reject(e); }); }); }); }); }, /** * Add visit. * * Updates our place cache. Currently this just increments frecency, but * eventually there should be a separate 'visits' DataStore to store a * record for every visit in order to render a history view. * * @param {String} url URL of visit to record. * @memberof Places.prototype */ onLocationChange: function(url) { this._placeChanges[url] = this._placeChanges[url] || this.defaultPlace(); this._placeChanges[url].visited = Date.now(); this._placeChanges[url].frecency += 1; this.debounce(url); }, /** * Set place title. * * @param {String} url URL of place to update. * @param {String} title Title of place to set. * @memberof Places.prototype */ onTitleChange: function(url, title) { this._placeChanges[url] = this._placeChanges[url] || this.defaultPlace(); this._placeChanges[url].title = title; this.debounce(url); }, /** * Set place icon. * * @param {String} url URL of place to update. * @param {String} icon The icon object * @memberof Places.prototype */ onIconChange: function(url, icons) { this._placeChanges[url] = this._placeChanges[url] || this.defaultPlace(); for (var iconUri in icons) { this._placeChanges[url].icons[iconUri] = icons[iconUri]; } this.debounce(url); }, /** * Set place meta. * * @param {String} url URL of place to update. * @param {Object} meta The meta object * @memberof Places.prototype */ onMetaChange: function(url, meta) { this._placeChanges[url] = this._placeChanges[url] || this.defaultPlace(); this._placeChanges[url].meta = meta; this.debounce(url); }, /** * Creates a timeout to save place data. * * @param {String} url URL of place. * @memberof Places.prototype */ debounce: function(url) { clearTimeout(this._timeouts[url]); this._timeouts[url] = setTimeout(() => { this.debouncePlaceChanges(url); }, DEBOUNCE_TIME); }, /** * Saves place data to datastore after the apploaded event, or a timeout. * * @param {String} url URL of place to update. * @param {String} icon The icon object * @memberof Places.prototype */ debouncePlaceChanges: function(url) { clearTimeout(this._timeouts[url]); this.editPlace(url, (place, cb) => { var edits = this._placeChanges[url]; if (!edits) { return; } // Update the title if it's not the default (matches the URL) if (edits.title && edits.title !== url) { place.title = edits.title; } if (edits.visited) { place.visited = edits.visited; } if (!place.frecency) { place.frecency = 0; } place.frecency += edits.frecency; if (!place.icons) { place.icons = {}; } place.meta = edits.meta || {}; for (var iconUri in edits.icons) { place.icons[iconUri] = edits.icons[iconUri]; } place = this.addToVisited(place); this.checkTopSites(place); cb(place); }).then(() => { // Remove pending changes after successfully saving delete this._placeChanges[url]; }); } }); }());