Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
586 lines
16 KiB
JavaScript
586 lines
16 KiB
JavaScript
'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];
|
|
});
|
|
}
|
|
});
|
|
}());
|