blob: 0778b7fcc0a4682734ecdbf619971fb78bdad261 [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Maps policy names to the root node that they affect.
*/
var policyToNodeId = {
'Bookmarks Bar': '1',
'Other Bookmarks': '2'
};
/**
* A function that fixes a URL. Turns e.g. "google.com" into
* "http://google.com/". This is used to correctly match against the
* canonicalized URLs stored in bookmarks created with the bookmarks API.
*/
var fixURL = (function() {
// An "a" element is used to parse the given URL and build the fixed version.
var a = document.createElement('a');
return function(url) {
// Preserve null, undefined, etc.
if (!url)
return url;
a.href = url;
// Handle cases like "google.com", which will be relative to the extension.
if (a.protocol === 'chrome-extension:' &&
url.substr(0, 17) !== 'chrome-extension:') {
a.href = 'http://' + url;
}
return a.href;
}
})();
/**
* A CallbackChain can be used to wrap other callbacks and perform a list of
* actions at the end, once all the wrapped callbacks have been invoked.
*/
var CallbackChain = function() {
this._count = 0;
this._callbacks = [];
}
CallbackChain.prototype.push = function(callback) {
this._callbacks.push(callback);
}
CallbackChain.prototype.wrap = function(callback) {
var self = this;
self._count++;
return function() {
if (callback)
callback.apply(null, arguments);
self._count--;
if (self._count == 0) {
for (var i = 0; i < self._callbacks.length; ++i)
self._callbacks[i]();
}
}
}
/**
* Represents a managed bookmark.
*/
var Node = function(nodesMap, id, title, url) {
this._nodesMap = nodesMap;
this._id = id;
this._title = title;
if (url !== undefined)
this._url = url;
else
this._children = [];
if (id)
this._nodesMap[id] = this;
}
Node.prototype.isRoot = function() {
return this._id in [ '0', '1', '2' ];
}
Node.prototype.getIndex = function() {
return this._nodesMap[this._parentId]._children.indexOf(this);
}
Node.prototype.appendChild = function(node) {
node._parentId = this._id;
this._children.push(node);
}
Node.prototype.droppedFromParent = function() {
// Remove |this| and its children from the |nodesMap|.
var nodesMap = this._nodesMap;
var removeFromNodesMap = function(node) {
delete nodesMap[node._id];
(node._children || []).forEach(removeFromNodesMap);
}
removeFromNodesMap(this);
if (this._children)
chrome.bookmarks.removeTree(this._id);
else
chrome.bookmarks.remove(this._id);
}
Node.prototype.matches = function(bookmark) {
return this._title === bookmark.title &&
this._url === bookmark.url &&
typeof this._children === typeof bookmark.children;
}
/**
* Makes this node's children match |wantedChildren|.
*/
Node.prototype.updateChildren = function(wantedChildren, callbackChain) {
// Rebuild the list of children to match |wantedChildren|.
var currentChildren = this._children;
this._children = [];
for (var i = 0; i < wantedChildren.length; ++i) {
var currentChild = currentChildren[i];
var wantedChild = wantedChildren[i];
wantedChild.url = fixURL(wantedChild.url);
if (currentChild && currentChild.matches(wantedChild)) {
this.appendChild(currentChild);
if (wantedChild.children)
currentChild.updateChildren(wantedChild.children, callbackChain);
} else {
// This child is either missing, invalid or misplaced; drop it and
// generate it again. Note that the actual dropping is delayed so that
// bookmarks.onRemoved is triggered after the changes have been persisted.
if (currentChild)
callbackChain.push(currentChild.droppedFromParent.bind(currentChild));
// The "id" comes with the callback from bookmarks.create() but the Node
// is created now so that the child is placed at the right position.
var newChild = new Node(
this._nodesMap, undefined, wantedChild.title, wantedChild.url);
this.appendChild(newChild);
chrome.bookmarks.create({
'parentId': this._id,
'title': newChild._title,
'url': newChild._url,
'index': i
}, callbackChain.wrap((function(wantedChild, newChild, createdNode) {
newChild._id = createdNode.id;
newChild._nodesMap[newChild._id] = newChild;
if (wantedChild.children)
newChild.updateChildren(wantedChild.children, callbackChain);
}).bind(null, wantedChild, newChild)));
}
}
// Drop all additional bookmarks past the end that are not wanted anymore.
if (currentChildren.length > wantedChildren.length) {
var chainCounter = callbackChain.wrap();
currentChildren.slice(wantedChildren.length).forEach(function(child) {
callbackChain.push(child.droppedFromParent.bind(child));
});
// This wrapped nop makes sure that the callbacks appended to the chain
// execute if nothing else was wrapped.
chainCounter();
}
}
/**
* Creates new nodes in the bookmark model to represent this Node and its
* children.
*/
Node.prototype.regenerate = function(parentId, index, callbackChain) {
var self = this;
chrome.bookmarks.create({
'parentId': parentId,
'title': self._title,
'url': self._url,
'index': index
}, callbackChain.wrap(function(newNode) {
delete self._nodesMap[self._id];
self._id = newNode.id;
self._parentId = newNode.parentId;
self._nodesMap[self._id] = self;
(self._children || []).forEach(function(child, i) {
child.regenerate(self._id, i, callbackChain);
});
}));
}
/**
* Moves this node to the correct position in the model.
* |currentParentId| and |currentIndex| indicate the current position in
* the model, which may not match the expected position.
*/
Node.prototype.moveInModel = function(currentParentId, currentIndex, callback) {
var index = this.getIndex();
if (currentParentId == this._parentId) {
if (index == currentIndex) {
// Nothing to do.
callback();
return;
} else if (index > currentIndex) {
// A bookmark moved is inserted at the new position before it is removed
// from the previous position. So when moving forward in the same parent,
// the index must be adjusted by one from the desired index.
++index;
}
}
chrome.bookmarks.move(this._id, {
'parentId': this._parentId,
'index': index
}, callback);
}
/**
* Moves any misplaced child nodes into their expected positions.
*/
Node.prototype.reorderChildren = function() {
var self = this;
chrome.bookmarks.getChildren(self._id, function(currentOrder) {
for (var i = 0; i < currentOrder.length; ++i) {
var node = currentOrder[i];
var child = self._nodesMap[node.id];
if (child && child.getIndex() != i) {
// Check again after moving this child.
child.moveInModel(
node.parentId, node.index, self.reorderChildren.bind(self));
return;
}
}
});
}
var serializeNode = function(node) {
var result = {
'id': node._id,
'title': node._title
}
if (node._url)
result['url'] = node._url;
else
result['children'] = node._children.map(serializeNode);
return result;
}
var unserializeNode = function(nodesMap, node) {
var result = new Node(nodesMap, node['id'], node['title'], node['url']);
if (node.children) {
node.children.forEach(function(child) {
result.appendChild(unserializeNode(nodesMap, child));
});
}
return result;
}
/**
* Tracks all the managed bookmarks, and persists the known state so that
* managed bookmarks can be updated after restarts.
*/
var ManagedBookmarkTree = function() {
// Maps a string id to its Node. Used to lookup an entry by ID.
this._nodesMap = {};
this._root = new Node(this._nodesMap, '0', '');
this._root.appendChild(new Node(this._nodesMap, '1', 'Bookmarks Bar'));
this._root.appendChild(new Node(this._nodesMap, '2', 'Other Bookmarks'));
}
ManagedBookmarkTree.prototype.store = function() {
chrome.storage.local.set({
'ManagedBookmarkTree': serializeNode(this._root)
});
}
ManagedBookmarkTree.prototype.load = function(callback) {
var self = this;
chrome.storage.local.get('ManagedBookmarkTree', function(result) {
if (result.hasOwnProperty('ManagedBookmarkTree')) {
self._nodesMap = {};
self._root = unserializeNode(self._nodesMap,
result['ManagedBookmarkTree']);
}
callback();
});
}
ManagedBookmarkTree.prototype.getById = function(id) {
return this._nodesMap[id];
}
ManagedBookmarkTree.prototype.update = function(rootNodeId, currentPolicy) {
// Note that the |callbackChain| is only invoked if a callback is wrapped,
// otherwise its callbacks are never invoked. So store() is called only if
// bookmarks.create() is actually used.
var callbackChain = new CallbackChain();
callbackChain.push(this.store.bind(this));
this._nodesMap[rootNodeId].updateChildren(currentPolicy || [], callbackChain);
}
var tree = new ManagedBookmarkTree();
chrome.runtime.onInstalled.addListener(function() {
// Enforce the initial policy.
// This load() should be empty on the first install, but is useful during
// development to handle reloads.
tree.load(function() {
chrome.storage.managed.get(function(policy) {
Object.keys(policyToNodeId).forEach(function(policyName) {
tree.update(policyToNodeId[policyName], policy[policyName]);
});
});
});
});
// Start observing policy changes. The tree is reloaded since this may be
// called back while the page was inactive.
chrome.storage.onChanged.addListener(function(changes, namespace) {
if (namespace !== 'managed')
return;
tree.load(function() {
Object.keys(changes).forEach(function(policyName) {
tree.update(policyToNodeId[policyName], changes[policyName].newValue);
});
});
});
// Observe bookmark modifications and revert any modifications made to managed
// bookmarks. The tree is always reloaded in case the events happened while the
// page was inactive.
chrome.bookmarks.onMoved.addListener(function(id, info) {
tree.load(function() {
var managedNode = tree.getById(id);
if (managedNode && !managedNode.isRoot()) {
managedNode.moveInModel(info.parentId, info.index, function(){});
} else {
// Check if the parent node has managed children that need to move.
// Example: moving a non-managed bookmark in front of the managed
// bookmarks.
var parentNode = tree.getById(info.parentId);
if (parentNode)
parentNode.reorderChildren();
}
});
});
chrome.bookmarks.onChanged.addListener(function(id, info) {
tree.load(function() {
var managedNode = tree.getById(id);
if (!managedNode || managedNode.isRoot())
return;
chrome.bookmarks.update(id, {
'title': managedNode._title,
'url': managedNode._url
});
});
});
chrome.bookmarks.onRemoved.addListener(function(id, info) {
tree.load(function() {
var managedNode = tree.getById(id);
if (!managedNode || managedNode.isRoot())
return;
// A new tree.store() is needed at the end because the regenerated nodes
// will have new IDs.
var callbackChain = new CallbackChain();
callbackChain.push(tree.store.bind(tree));
managedNode.regenerate(info.parentId, info.index, callbackChain);
});
});