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