'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 = [
'clear'
];
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('apploaded', this);
asyncStorage.getItem('top-sites', results => {
this.topSites = results || [];
resolve();
});
});
},
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 '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: {},
frecency: 0,
// An array containing previous visits to this url
visits: [],
screenshot: 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);
});
},
/*
* 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) {
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);
});
},
/**
* Clear all the visits in the store.
* @memberof Places.prototype
*/
clear: function() {
return this.getStore().then(store => {
store.clear();
});
},
/**
* 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);
},
/**
* 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 !== 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 = {};
}
for (var iconUri in edits.icons) {
place.icons[iconUri] = edits.icons[iconUri];
}
place = this.addToVisited(place);
this.checkTopSites(place);
delete this._placeChanges[url];
cb(place);
});
}
});
}());