1// Copyright 2013 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. 4 5/** 6 * Maps policy names to the root node that they affect. 7 */ 8var policyToNodeId = { 9 'Bookmarks Bar': '1', 10 'Other Bookmarks': '2' 11}; 12 13/** 14 * A function that fixes a URL. Turns e.g. "google.com" into 15 * "http://google.com/". This is used to correctly match against the 16 * canonicalized URLs stored in bookmarks created with the bookmarks API. 17 */ 18var fixURL = (function() { 19 // An "a" element is used to parse the given URL and build the fixed version. 20 var a = document.createElement('a'); 21 return function(url) { 22 // Preserve null, undefined, etc. 23 if (!url) 24 return url; 25 a.href = url; 26 // Handle cases like "google.com", which will be relative to the extension. 27 if (a.protocol === 'chrome-extension:' && 28 url.substr(0, 17) !== 'chrome-extension:') { 29 a.href = 'http://' + url; 30 } 31 return a.href; 32 } 33})(); 34 35/** 36 * A CallbackChain can be used to wrap other callbacks and perform a list of 37 * actions at the end, once all the wrapped callbacks have been invoked. 38 */ 39var CallbackChain = function() { 40 this._count = 0; 41 this._callbacks = []; 42} 43 44CallbackChain.prototype.push = function(callback) { 45 this._callbacks.push(callback); 46} 47 48CallbackChain.prototype.wrap = function(callback) { 49 var self = this; 50 self._count++; 51 return function() { 52 if (callback) 53 callback.apply(null, arguments); 54 self._count--; 55 if (self._count == 0) { 56 for (var i = 0; i < self._callbacks.length; ++i) 57 self._callbacks[i](); 58 } 59 } 60} 61 62/** 63 * Represents a managed bookmark. 64 */ 65var Node = function(nodesMap, id, title, url) { 66 this._nodesMap = nodesMap; 67 this._id = id; 68 this._title = title; 69 if (url !== undefined) 70 this._url = url; 71 else 72 this._children = []; 73 if (id) 74 this._nodesMap[id] = this; 75} 76 77Node.prototype.isRoot = function() { 78 return this._id in [ '0', '1', '2' ]; 79} 80 81Node.prototype.getIndex = function() { 82 return this._nodesMap[this._parentId]._children.indexOf(this); 83} 84 85Node.prototype.appendChild = function(node) { 86 node._parentId = this._id; 87 this._children.push(node); 88} 89 90Node.prototype.droppedFromParent = function() { 91 // Remove |this| and its children from the |nodesMap|. 92 var nodesMap = this._nodesMap; 93 var removeFromNodesMap = function(node) { 94 delete nodesMap[node._id]; 95 (node._children || []).forEach(removeFromNodesMap); 96 } 97 removeFromNodesMap(this); 98 99 if (this._children) 100 chrome.bookmarks.removeTree(this._id); 101 else 102 chrome.bookmarks.remove(this._id); 103} 104 105Node.prototype.matches = function(bookmark) { 106 return this._title === bookmark.title && 107 this._url === bookmark.url && 108 typeof this._children === typeof bookmark.children; 109} 110 111/** 112 * Makes this node's children match |wantedChildren|. 113 */ 114Node.prototype.updateChildren = function(wantedChildren, callbackChain) { 115 // Rebuild the list of children to match |wantedChildren|. 116 var currentChildren = this._children; 117 this._children = []; 118 for (var i = 0; i < wantedChildren.length; ++i) { 119 var currentChild = currentChildren[i]; 120 var wantedChild = wantedChildren[i]; 121 wantedChild.url = fixURL(wantedChild.url); 122 123 if (currentChild && currentChild.matches(wantedChild)) { 124 this.appendChild(currentChild); 125 if (wantedChild.children) 126 currentChild.updateChildren(wantedChild.children, callbackChain); 127 } else { 128 // This child is either missing, invalid or misplaced; drop it and 129 // generate it again. Note that the actual dropping is delayed so that 130 // bookmarks.onRemoved is triggered after the changes have been persisted. 131 if (currentChild) 132 callbackChain.push(currentChild.droppedFromParent.bind(currentChild)); 133 // The "id" comes with the callback from bookmarks.create() but the Node 134 // is created now so that the child is placed at the right position. 135 var newChild = new Node( 136 this._nodesMap, undefined, wantedChild.title, wantedChild.url); 137 this.appendChild(newChild); 138 chrome.bookmarks.create({ 139 'parentId': this._id, 140 'title': newChild._title, 141 'url': newChild._url, 142 'index': i 143 }, callbackChain.wrap((function(wantedChild, newChild, createdNode) { 144 newChild._id = createdNode.id; 145 newChild._nodesMap[newChild._id] = newChild; 146 if (wantedChild.children) 147 newChild.updateChildren(wantedChild.children, callbackChain); 148 }).bind(null, wantedChild, newChild))); 149 } 150 } 151 152 // Drop all additional bookmarks past the end that are not wanted anymore. 153 if (currentChildren.length > wantedChildren.length) { 154 var chainCounter = callbackChain.wrap(); 155 currentChildren.slice(wantedChildren.length).forEach(function(child) { 156 callbackChain.push(child.droppedFromParent.bind(child)); 157 }); 158 // This wrapped nop makes sure that the callbacks appended to the chain 159 // execute if nothing else was wrapped. 160 chainCounter(); 161 } 162} 163 164/** 165 * Creates new nodes in the bookmark model to represent this Node and its 166 * children. 167 */ 168Node.prototype.regenerate = function(parentId, index, callbackChain) { 169 var self = this; 170 chrome.bookmarks.create({ 171 'parentId': parentId, 172 'title': self._title, 173 'url': self._url, 174 'index': index 175 }, callbackChain.wrap(function(newNode) { 176 delete self._nodesMap[self._id]; 177 self._id = newNode.id; 178 self._parentId = newNode.parentId; 179 self._nodesMap[self._id] = self; 180 (self._children || []).forEach(function(child, i) { 181 child.regenerate(self._id, i, callbackChain); 182 }); 183 })); 184} 185 186/** 187 * Moves this node to the correct position in the model. 188 * |currentParentId| and |currentIndex| indicate the current position in 189 * the model, which may not match the expected position. 190 */ 191Node.prototype.moveInModel = function(currentParentId, currentIndex, callback) { 192 var index = this.getIndex(); 193 if (currentParentId == this._parentId) { 194 if (index == currentIndex) { 195 // Nothing to do. 196 callback(); 197 return; 198 } else if (index > currentIndex) { 199 // A bookmark moved is inserted at the new position before it is removed 200 // from the previous position. So when moving forward in the same parent, 201 // the index must be adjusted by one from the desired index. 202 ++index; 203 } 204 } 205 chrome.bookmarks.move(this._id, { 206 'parentId': this._parentId, 207 'index': index 208 }, callback); 209} 210 211/** 212 * Moves any misplaced child nodes into their expected positions. 213 */ 214Node.prototype.reorderChildren = function() { 215 var self = this; 216 chrome.bookmarks.getChildren(self._id, function(currentOrder) { 217 for (var i = 0; i < currentOrder.length; ++i) { 218 var node = currentOrder[i]; 219 var child = self._nodesMap[node.id]; 220 if (child && child.getIndex() != i) { 221 // Check again after moving this child. 222 child.moveInModel( 223 node.parentId, node.index, self.reorderChildren.bind(self)); 224 return; 225 } 226 } 227 }); 228} 229 230var serializeNode = function(node) { 231 var result = { 232 'id': node._id, 233 'title': node._title 234 } 235 if (node._url) 236 result['url'] = node._url; 237 else 238 result['children'] = node._children.map(serializeNode); 239 return result; 240} 241 242var unserializeNode = function(nodesMap, node) { 243 var result = new Node(nodesMap, node['id'], node['title'], node['url']); 244 if (node.children) { 245 node.children.forEach(function(child) { 246 result.appendChild(unserializeNode(nodesMap, child)); 247 }); 248 } 249 return result; 250} 251 252/** 253 * Tracks all the managed bookmarks, and persists the known state so that 254 * managed bookmarks can be updated after restarts. 255 */ 256var ManagedBookmarkTree = function() { 257 // Maps a string id to its Node. Used to lookup an entry by ID. 258 this._nodesMap = {}; 259 this._root = new Node(this._nodesMap, '0', ''); 260 this._root.appendChild(new Node(this._nodesMap, '1', 'Bookmarks Bar')); 261 this._root.appendChild(new Node(this._nodesMap, '2', 'Other Bookmarks')); 262} 263 264ManagedBookmarkTree.prototype.store = function() { 265 chrome.storage.local.set({ 266 'ManagedBookmarkTree': serializeNode(this._root) 267 }); 268} 269 270ManagedBookmarkTree.prototype.load = function(callback) { 271 var self = this; 272 chrome.storage.local.get('ManagedBookmarkTree', function(result) { 273 if (result.hasOwnProperty('ManagedBookmarkTree')) { 274 self._nodesMap = {}; 275 self._root = unserializeNode(self._nodesMap, 276 result['ManagedBookmarkTree']); 277 } 278 callback(); 279 }); 280} 281 282ManagedBookmarkTree.prototype.getById = function(id) { 283 return this._nodesMap[id]; 284} 285 286ManagedBookmarkTree.prototype.update = function(rootNodeId, currentPolicy) { 287 // Note that the |callbackChain| is only invoked if a callback is wrapped, 288 // otherwise its callbacks are never invoked. So store() is called only if 289 // bookmarks.create() is actually used. 290 var callbackChain = new CallbackChain(); 291 callbackChain.push(this.store.bind(this)); 292 this._nodesMap[rootNodeId].updateChildren(currentPolicy || [], callbackChain); 293} 294 295var tree = new ManagedBookmarkTree(); 296 297chrome.runtime.onInstalled.addListener(function() { 298 // Enforce the initial policy. 299 // This load() should be empty on the first install, but is useful during 300 // development to handle reloads. 301 tree.load(function() { 302 chrome.storage.managed.get(function(policy) { 303 Object.keys(policyToNodeId).forEach(function(policyName) { 304 tree.update(policyToNodeId[policyName], policy[policyName]); 305 }); 306 }); 307 }); 308}); 309 310// Start observing policy changes. The tree is reloaded since this may be 311// called back while the page was inactive. 312chrome.storage.onChanged.addListener(function(changes, namespace) { 313 if (namespace !== 'managed') 314 return; 315 tree.load(function() { 316 Object.keys(changes).forEach(function(policyName) { 317 tree.update(policyToNodeId[policyName], changes[policyName].newValue); 318 }); 319 }); 320}); 321 322// Observe bookmark modifications and revert any modifications made to managed 323// bookmarks. The tree is always reloaded in case the events happened while the 324// page was inactive. 325 326chrome.bookmarks.onMoved.addListener(function(id, info) { 327 tree.load(function() { 328 var managedNode = tree.getById(id); 329 if (managedNode && !managedNode.isRoot()) { 330 managedNode.moveInModel(info.parentId, info.index, function(){}); 331 } else { 332 // Check if the parent node has managed children that need to move. 333 // Example: moving a non-managed bookmark in front of the managed 334 // bookmarks. 335 var parentNode = tree.getById(info.parentId); 336 if (parentNode) 337 parentNode.reorderChildren(); 338 } 339 }); 340}); 341 342chrome.bookmarks.onChanged.addListener(function(id, info) { 343 tree.load(function() { 344 var managedNode = tree.getById(id); 345 if (!managedNode || managedNode.isRoot()) 346 return; 347 chrome.bookmarks.update(id, { 348 'title': managedNode._title, 349 'url': managedNode._url 350 }); 351 }); 352}); 353 354chrome.bookmarks.onRemoved.addListener(function(id, info) { 355 tree.load(function() { 356 var managedNode = tree.getById(id); 357 if (!managedNode || managedNode.isRoot()) 358 return; 359 // A new tree.store() is needed at the end because the regenerated nodes 360 // will have new IDs. 361 var callbackChain = new CallbackChain(); 362 callbackChain.push(tree.store.bind(tree)); 363 managedNode.regenerate(info.parentId, info.index, callbackChain); 364 }); 365}); 366