changeset 0:c2248f662a2c version-1

Initial revision
author Guido Berhoerster <guido+booket@berhoerster.name>
date Sat, 06 Sep 2014 18:18:29 +0200
parents
children 6559033d9996
files booket.css booket.html booket.js
diffstat 3 files changed, 1817 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/booket.css	Sat Sep 06 18:18:29 2014 +0200
@@ -0,0 +1,285 @@
+/*
+ * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+html {
+    color: #000000;
+    background-color: #ffffff;
+    font-family: "DejaVu Sans", Arial, Helvetica, sans-serif;
+    max-width: 70em;
+    margin: 0 auto;
+}
+
+fieldset {
+    border: none;
+    border-top: 1px solid #888a85;
+}
+
+legend {
+    font-size: .75em;
+    font-weight: bold;
+}
+
+label,
+input[type="text"],
+input[type="file"],
+input[type="url"] {
+    display: block;
+}
+
+label {
+    font-weight: bold;
+    font-size: .75em;
+}
+
+kbd {
+    display: inline-block;
+    font-family: Courier, monospace;
+    background-color: #fdfdfb;
+    border: thin solid #babdb6;
+    box-shadow: inset 0 1px 0 0 #ffffff, inset 0 -1px 0 0 #babdb6;
+    border-radius: .25em;
+    padding: .125em .5em;
+    white-space: nowrap;
+}
+
+h1 {
+    font-size: 2em;
+    margin: .67em 0
+}
+
+h2 {
+    font-size: 1.5em;
+    margin: .75em 0
+}
+
+h3 {
+    font-size: 1.17em;
+    margin: .83em 0
+}
+
+h1, h2, h3 {
+    font-weight: bolder
+}
+
+section,
+main,
+footer {
+    clear: both;
+}
+
+footer {
+    clear: both;
+    margin: 1em 0 0 0;
+    padding: .5em 0 0 0;
+    border-top: 1px solid #888a85;
+    font-size: .75em;
+}
+
+address {
+    font-style: inherit;
+    color: #555753;
+}
+
+address :link,
+address :visited {
+    text-decoration: underline;
+    color: inherit;
+}
+
+header h1 {
+    display: inline-block;
+    margin: 0 .25em 0 0;
+}
+
+header h1 ~ p {
+    display: inline-block;
+    margin: 0;
+    font-weight: bold;
+}
+
+#actions {
+    margin: 1em 0 0 0;
+}
+
+#actions > h2 {
+    display: none;
+}
+
+#actions form ~ form {
+    margin: 1em 0 0 0;
+}
+
+#keyboard-shortcuts {
+    float: right;
+    border: 1px solid #d3d7cf;
+    border-radius: .5em;
+    background-color: #fbfbf9;
+    padding: .5em;
+    margin: 0 0 1em 1em;
+    font-size: .75em;
+}
+
+#keyboard-shortcuts h3 {
+    font-size: 1em;
+    text-align: center;
+    margin: 0;
+}
+
+#keyboard-shortcuts dl {
+    margin: 1em 0 0 0;
+}
+
+#keyboard-shortcuts dd {
+    margin: .25em 0 0 0;
+}
+
+#keyboard-shortcuts dd ~ dt {
+    margin: .5em 0 0 0;
+}
+
+#bookmarks {
+    margin: 1em 0 0 0;
+}
+
+#bookmarks h2 {
+    margin: 0;
+}
+
+#tags,
+#search,
+#bookmark-message,
+#bookmark-list {
+    margin: .5em 0 0 0;
+}
+
+#tags h3,
+#search h3 {
+    display: none;
+}
+
+ul.tag-input-list,
+ul.tag-list {
+    margin: 0;
+    padding: 0;
+}
+
+ul#bookmark-list {
+    padding: 0;
+}
+
+ul.tag-input-list li,
+ul.tag-list li,
+ul#bookmark-list > li {
+    list-style-type: none;
+    padding: 0;
+    margin: 0;
+}
+
+ul.tag-list li {
+    display: inline-block;
+    border: 1px solid #c4a000;
+    border-radius: .25em;
+    padding: .1em;
+    background-color: #fce94f;
+    margin: .25em .25em 0 0;
+    white-space: nowrap;
+    font-size: .75em;
+}
+
+ul.tag-list button {
+    color: #000000;
+    background-color: transparent;
+    border: thin solid transparent;
+    border-radius: .1em;
+    padding: .1em;
+    margin: 0 .1em;
+    cursor: pointer;
+}
+
+ul.tag-list button:hover,
+ul.tag-list button:focus,
+ul.tag-list button:active {
+    border: thin solid #deba1a;
+    background-color: #ffff69;
+}
+
+ul.tag-list li.active-filter-tag {
+    border: thin solid #4e9a06;
+    background-color: #8ae234;
+}
+
+ul.tag-list li.active-filter-tag button:hover,
+ul.tag-list li.active-filter-tag button:focus,
+ul.tag-list li.active-filter-tag button:active {
+    border: thin solid #68b420;
+    background-color: #a4fc4e;
+}
+
+ul#bookmark-list > li {
+    border-top: 1px solid #888a85;
+    padding: .25em 0 0 0;    
+}
+
+ul#bookmark-list > li ~ li {
+    margin: .25em 0 0 0;    
+}
+
+ul#bookmark-list ul.tag-list {
+    max-width: 33%;
+    float: right;
+    margin: 0 0 .25em .25em;
+}
+
+ul#bookmark-list ul.tag-list > li {
+    float: right;
+}
+
+ul#bookmark-list > li::after {
+    display: block;
+    content: '';
+    clear: right;
+}
+
+ul#bookmark-list .bookmark-editor-form {
+    margin: .5em;
+}
+
+ul#bookmark-list .bookmark-editor-form fieldset {
+    border-top: 1px solid #d3d7cf;
+}
+
+a.bookmark-link:link,
+a.bookmark-link:visited {
+    color: #001754;
+    font-weight: bold;
+    text-decoration: underline;
+}
+
+a.bookmark-link:link:hover,
+a.bookmark-link:link:focus,
+a.bookmark-link:link:active,
+a.bookmark-link:visited:hover,
+a.bookmark-link:visited:focus,
+a.bookmark-link:visited:active {
+    color: #07316e;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/booket.html	Sat Sep 06 18:18:29 2014 +0200
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<!--
+  Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
+
+  Permission is hereby granted, free of charge, to any person obtaining
+  a copy of this software and associated documentation files (the
+  "Software"), to deal in the Software without restriction, including
+  without limitation the rights to use, copy, modify, merge, publish,
+  distribute, sublicense, and/or sell copies of the Software, and to
+  permit persons to whom the Software is furnished to do so, subject to
+  the following conditions:
+
+  The above copyright notice and this permission notice shall be included
+  in all copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+  TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+-->
+<html>
+  <head>
+    <meta charset="utf-8"></meta>
+    <title>Booket</title>
+    <link rel="stylesheet" type="text/css" href="booket.css"></link>
+    <script src="booket.js"></script>
+  </head>
+  <body>
+  <header>
+    <h1>Booket</h1>
+    <p>Version 1</p>
+  </header>
+
+  <template id="tag-input-template">
+    <li><label>Tag <input type="text" name="tag" pattern="[^,;]*"
+    size="20" placeholder="tag"></input>
+    </label></li>
+  </template>
+
+  <template id="bookmark-editor-template">
+    <form class="bookmark-editor-form">
+      <fieldset>
+        <legend></legend>
+        <input type="hidden" name="original-url"></input>
+        <label>URL <input type="url" required="required"
+        name="url" size="60" placeholder="http://example.com/"></input></label>
+        <label>Title <input type="text" name="title" size="60"
+        placeholder="A Title"></input></label>
+        <div>
+          <ul class="tag-input-list"></ul>
+          <button type="button" name="more-tags">Add more tags</button>
+        </div>
+        <button type="reset" name="cancel">Cancel</button><button type="submit"
+        name="save-bookmark">Save</button>
+      </fieldset>
+    </form>
+  </template>
+
+  <section id="actions">
+    <h2>Actions</h2>
+    <aside id="keyboard-shortcuts">
+      <h3>Keyboard Shortcuts</h3>
+      <dl>
+        <dt><kbd>Prefix</kbd>+<kbd>i</kbd></dt>
+        <dd>Select bookmark file to load</dd>
+        <dt><kbd>Prefix</kbd>+<kbd>l</kbd></dt>
+        <dd>Load selected bookmark file</dd>
+        <dt><kbd>Prefix</kbd>+<kbd>s</kbd></dt>
+        <dd>Save bookmark file</dd>
+        <dt><kbd>Prefix</kbd>+<kbd>a</kbd></dt>
+        <dd>Focus bookmark editor</dd>
+        <dt><kbd>Prefix</kbd>+<kbd>f</kbd></dt>
+        <dd>Focus search field</dd>
+      </dl>
+    </aside>
+    <form id="load-form">
+      <fieldset>
+        <legend>Load Bookmarks</legend>
+        <label accesskey="i">File <input type="file" accept="application/json"
+        required="required" name="file"></input></label>
+        <button type="submit" name="load-file" accesskey="l">Load</button>
+      </fieldset>
+    </form>
+
+    <form id="save-form">
+      <fieldset>
+        <legend>Save Bookmarks</legend>
+        <a href="#" id="save-link" hidden="hidden"
+        download="bookmarks.json"></a>
+        <button type="submit" name="save-file"
+        accesskey="s">Save&#8230;</button>
+      </fieldset>
+    </form>
+  </section>
+
+  <main>
+    <section id="bookmarks">
+      <h2>Bookmarks</h2>
+
+      <aside id="tags">
+        <h3>Tags</h3>
+
+        <ul class="tag-list">
+          <template id="tag-template">
+            <li><button type="button" name="set-tag"></button><span
+            class="tag-count"></span><button type="button"
+            name="toggle-tag"></button></li>
+          </template>
+        </ul>
+      </aside>
+
+      <aside id="search">
+        <h3>Search</h3>
+
+        <form id="search-form">
+          <input type="search" name="search-term" size="20" placeholder="Search"
+          accesskey="f"></input>
+          <button type="submit" name="search">Search</button><button
+          type="reset" name="clear">Clear</button>
+        </form>
+      </aside>
+
+      <p id="bookmark-message"></p>
+
+      <ul id="bookmark-list">
+        <template id="bookmark-tag-template">
+          <li><button type="button" name="set-tag"></button><button
+          type="button" name="toggle-tag"></button></li>
+        </template>
+        <template id="bookmark-template">
+          <li>
+            <a class="bookmark-link" target="_blank"></a><ul
+            class="tag-list"></ul>
+            <div class="bookmark-actions">
+              <button type="button" name="edit-bookmark">Edit</button><button
+              type="button" name="delete-bookmark">Delete</button>
+            </div>
+          </li>
+        </template>
+      </ul>
+    </section>
+  </main>
+
+  <footer><address>Copyright 2014
+    <a href="mailto:guido+booket@berhoerster.name"
+    title="guido+booket@berhoerster.name">Guido
+    Berhörster</a></address>
+  </footer>
+  </body>
+</html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/booket.js	Sat Sep 06 18:18:29 2014 +0200
@@ -0,0 +1,1379 @@
+/*
+ * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining
+ * a copy of this software and associated documentation files (the
+ * "Software"), to deal in the Software without restriction, including
+ * without limitation the rights to use, copy, modify, merge, publish,
+ * distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to
+ * the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included
+ * in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+ * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+ * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+(function () {
+'use strict';
+
+/*
+ * utility stuff
+ */
+
+function isNumber(number) {
+    return (Object.prototype.toString.call(number) === '[object Number]');
+}
+
+function isString(number) {
+    return (Object.prototype.toString.call(number) === '[object String]');
+}
+
+function arrayEqual(array1, array2) {
+    if (!Array.isArray(array1)) {
+        throw new TypeError(typeof array1 + ' is not an array');
+    } else if (!Array.isArray(array2)) {
+        throw new TypeError(typeof array2 + ' is not an array');
+    }
+
+    if (array1.length !== array2.length) {
+        return false;
+    } else if (array1.length === 0 && array2.length === 0) {
+        return true;
+    }
+
+    return array1.slice().sort().every(function (value, i) {
+        return value === array2[i];
+    });
+}
+
+function parseHash(url) {
+    var hashData;
+    var pos;
+    var hash;
+    var hashParts;
+    var key;
+    var value;
+    var i;
+
+    hashData = new StringMap();
+    pos = url.indexOf('#');
+    hash = (pos > -1) ? url.substr(pos + 1) : '';
+    // hash parts are seperated by a ';'
+    hashParts = hash.split(';');
+    for (i = 0; i < hashParts.length; i++) {
+        // key and value pairs are seperated by a '=', an empty value will
+        // cause the key to be ignored
+        pos = hashParts[i].indexOf('=');
+        if (pos > -1) {
+            key = decodeURIComponent(hashParts[i].substr(0, pos));
+            value = decodeURIComponent(hashParts[i].substr(pos + 1));
+            hashData.set(key, value);
+        }
+    }
+
+    return hashData;
+}
+
+function serializeHash(url, hashData) {
+    var hashParts = [];
+    var pos;
+
+    pos = url.indexOf('#');
+    if (pos > -1) {
+        url = url.substr(0, pos);
+    }
+
+    hashData.forEach(function (value, key) {
+        if (value !== '') {
+            hashParts.push(encodeURIComponent(key) + '=' +
+                encodeURIComponent(value));
+        }
+    });
+
+    // only append a '#' if there are any hash parts
+    return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : '');
+}
+
+function getAncestorElementDatasetItem(node, item) {
+    while ((node = node.parentNode) !== null) {
+        if (node.dataset && node.dataset[item] !== undefined) {
+            return node.dataset[item];
+        }
+    }
+
+    return undefined;
+}
+
+// for use with Node.querySelector() and Node.querySelectorAll()
+function createDatasetSelector(name, value) {
+    return  '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]';
+}
+
+function extend(targetObject, sourceObject) {
+    var propertyName;
+
+    for (propertyName in sourceObject.prototype) {
+        if (!Object.prototype.hasOwnProperty.call(targetObject.prototype,
+                propertyName)) {
+            targetObject.prototype[propertyName] =
+                sourceObject.prototype[propertyName];
+        }
+    }
+}
+
+
+var ObservableMixin = function () {
+    this._eventsObservers = {};
+};
+
+ObservableMixin.prototype.addObserver = function (eventName, observer) {
+    var i;
+
+    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
+            eventName)) {
+        this._eventsObservers[eventName] = [];
+    }
+
+    // prevent observers for an event from being called more than once
+    for (i = 0; i < this._eventsObservers[eventName].length; i++) {
+        if (this._eventsObservers[eventName][i] === observer) {
+            return;
+        }
+    }
+    this._eventsObservers[eventName].push(observer);
+};
+
+ObservableMixin.prototype.deleteObserver = function (eventName, observer) {
+    var i = 0;
+
+    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
+            eventName)) {
+        return;
+    }
+
+    while (i < this._eventsObservers[eventName].length) {
+        if (this._eventsObservers[eventName][i] === observer) {
+            this._eventsObservers[eventName].splice(i, 1);
+        }
+    }
+};
+
+ObservableMixin.prototype.notify = function (eventName) {
+    var origArguments;
+
+    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
+            eventName)) {
+        return;
+    }
+
+    origArguments = Array.prototype.slice.call(arguments, 1);
+    this._eventsObservers[eventName].forEach(function (observer, i) {
+        // call the observer function and pass on any additional arguments
+        observer.apply(undefined, origArguments);
+    });
+};
+
+
+var StringMap = function (iter) {
+    this._stringMap = Object.create(null);
+
+    if (iter !== undefined) {
+        if (Array.isArray(iter)) {
+            iter.forEach(function (pair) {
+                if (Array.isArray(pair)) {
+                    this.set(pair[0], pair[1]);
+                } else {
+                    throw new TypeError(typeof pair + ' is not an array');
+                }
+            }, this);
+        } else {
+            throw new TypeError(typeof iter + ' is not iterable');
+        }
+    }
+};
+
+Object.defineProperty(StringMap.prototype, 'size', {
+    get: function () {
+        var size = 0;
+        var key;
+
+        for (key in this._stringMap) {
+            if (key.charAt(0) === '@') {
+                size++;
+            }
+        }
+
+        return size;
+    }
+});
+
+StringMap.prototype.set = function (key, value) {
+    this._stringMap['@' + key] = value;
+
+    return this;
+};
+
+StringMap.prototype.get = function (key) {
+    return this._stringMap['@' + key];
+};
+
+StringMap.prototype.has = function (key) {
+    return (('@' + key) in this._stringMap);
+};
+
+StringMap.prototype.delete = function (key) {
+    if (this.has(key)) {
+        delete this._stringMap['@' + key];
+
+        return true;
+    }
+
+    return false;
+};
+
+StringMap.prototype.forEach = function (callbackFn, thisArg) {
+    Object.keys(this._stringMap).forEach(function (key) {
+        if (key.charAt(0) === '@') {
+            key = key.substr(1);
+            callbackFn.call(thisArg, this.get(key), key, this);
+        }
+    }, this);
+};
+
+StringMap.prototype.keys = function () {
+    return Object.keys(this._stringMap).map(function (key) {
+        return key.substr(1);
+    });
+};
+
+StringMap.prototype.toJSON = function () {
+    return this._stringMap;
+};
+
+StringMap.prototype.toString = function () {
+    return Object.prototype.toString.call(this._stringMap);
+};
+
+
+var StringSet = function (iter) {
+    this._stringArray = [];
+    this._stringMap = new StringMap();
+    if (iter !== undefined) {
+        if (Array.isArray(iter) || iter instanceof StringSet) {
+            iter.forEach(function (string) {
+                this.add(string);
+            }, this);
+        } else {
+            throw new TypeError(typeof iter + ' is not iterable');
+        }
+    }
+};
+
+Object.defineProperty(StringSet.prototype, 'size', {
+    get: function () {
+        return this._stringArray.length;
+    }
+});
+
+StringSet.prototype.has = function (string) {
+    return this._stringMap.has(string);
+};
+
+StringSet.prototype.add = function (string) {
+    if (!this.has(string)) {
+        this._stringMap.set(string, true);
+        this._stringArray.push(string);
+    }
+    return this;
+};
+
+StringSet.prototype.delete = function (string) {
+    if (this.has(string)) {
+        this._stringMap.delete(string);
+        this._stringArray.splice(this._stringArray.indexOf(string), 1);
+        return true;
+    }
+    return false;
+};
+
+StringSet.prototype.forEach = function (callbackFn, thisArg) {
+    this._stringArray.forEach(function (key) {
+        callbackFn.call(thisArg, key, key, this);
+    });
+};
+
+StringSet.prototype.keys = function () {
+    return this._stringArray.slice();
+};
+
+StringSet.prototype.values = function () {
+    return this._stringArray.slice();
+};
+
+StringSet.prototype.clear = function () {
+    this._stringMap = new StringMap();
+    this._stringArray = [];
+};
+
+StringSet.prototype.toJSON = function () {
+    return this._stringArray;
+};
+
+StringSet.prototype.toString = function () {
+    return this._stringArray.toString();
+};
+
+
+/*
+ * model
+ */
+
+var Bookmark = function (url, title, tags, ctime, mtime) {
+    var parsedTime;
+
+    if (!isString(url)) {
+        throw new TypeError(typeof url + ' is not a string');
+    }
+    this.url = url;
+
+    this.title = (isString(title) && title !== '') ? title : url;
+
+    if (Array.isArray(tags)) {
+        // remove duplicates, non-string or empty tags and tags containing
+        // commas
+        this.tags = new StringSet(tags.filter(function (tag) {
+            return (isString(tag) && tag !== '' && tag.indexOf(',') === -1);
+        }).sort());
+    } else {
+        this.tags = new StringSet();
+    }
+
+    if (isNumber(ctime) || isString(ctime)) {
+        parsedTime = new Date(ctime);
+        this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date();
+    } else {
+        this.ctime = new Date();
+    }
+
+    if (isNumber(mtime) || isString(mtime)) {
+        parsedTime = new Date(mtime);
+        // modification time must be greater than creation time
+        this.mtime = (!isNaN(parsedTime.getTime()) ||
+            parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime);
+    } else {
+        this.mtime = new Date(this.ctime);
+    }
+};
+
+
+var BookmarkModel = function () {
+    ObservableMixin.call(this);
+
+    this.unsavedChanges = false;
+    this._bookmarks = new StringMap();
+    this._tagCount = new StringMap();
+    this._filterTags = new StringSet();
+    this._searchTerm = '';
+    this._filteredBookmarks = new StringSet();
+    this._searchedBookmarks = new StringSet();
+};
+
+extend(BookmarkModel, ObservableMixin);
+
+BookmarkModel.prototype.add = function (bookmarks) {
+    var addedBookmarkUrls = new StringSet();
+
+    // argument can be a single bookmark or a list of bookmarks
+    if (!Array.isArray(bookmarks)) {
+        bookmarks = [bookmarks];
+    }
+
+    bookmarks.forEach(function (bookmark) {
+        // delete any existing bookmark for the given URL before adding the new
+        // one in order to update views
+        this.delete(bookmark.url);
+        this._bookmarks.set(bookmark.url, bookmark);
+        addedBookmarkUrls.add(bookmark.url);
+        this.unsavedChanges = true;
+        this.notify('bookmark-added', bookmark);
+
+        // update tag count
+        bookmark.tags.forEach(function (tag) {
+            var tagCount;
+
+            if (this._tagCount.has(tag)) {
+                tagCount = this._tagCount.get(tag) + 1;
+                this._tagCount.set(tag, tagCount);
+                this.notify('tag-count-changed', tag, tagCount);
+            } else {
+                this._tagCount.set(tag, 1);
+                this.notify('tag-added', tag);
+            }
+        }, this);
+    }, this);
+
+    // apply tag filter and search added bookmarks
+    this.updateFilteredSearchedBookmarks(addedBookmarkUrls);
+    this.notify('filter-tags-search-changed', this._searchedBookmarks,
+        this._filterTags, this._searchTerm);
+};
+
+BookmarkModel.prototype.has = function (url) {
+    return this._bookmarks.has(url);
+};
+
+BookmarkModel.prototype.get = function (url) {
+    return this._bookmarks.get(url);
+};
+
+BookmarkModel.prototype.delete = function (urls) {
+    var needUpdateFilterTags = false;
+
+    // argument can be a single bookmark or a list of bookmarks
+    if (!Array.isArray(urls)) {
+        urls = [urls];
+    }
+
+    urls.forEach(function (url) {
+        var bookmark;
+        var tagCount;
+
+        if (this._bookmarks.has(url)) {
+            bookmark = this._bookmarks.get(url);
+            this._bookmarks.delete(url);
+            this.unsavedChanges = true;
+            this.notify('bookmark-deleted', bookmark.url);
+
+            // update tag count
+            bookmark.tags.forEach(function (tag) {
+                if (this._tagCount.has(tag)) {
+                    tagCount = this._tagCount.get(tag);
+                    if (tagCount > 1) {
+                        tagCount--;
+                        this._tagCount.set(tag, tagCount);
+                        this.notify('tag-count-changed', tag, tagCount);
+                    } else {
+                        this._tagCount.delete(tag);
+                        this.notify('tag-deleted', tag);
+
+                        if (this._filterTags.has(tag)) {
+                            this._filterTags.delete(tag);
+                            needUpdateFilterTags = true;
+                        }
+                    }
+                }
+            }, this);
+
+            // update filtered and searched bookmarks
+            if (this._filteredBookmarks.has(url)) {
+                this._filteredBookmarks.delete(url);
+                if (this._searchedBookmarks.has(url)) {
+                    this._searchedBookmarks.delete(url);
+                }
+            }
+        }
+    }, this);
+
+    if (needUpdateFilterTags) {
+        this.updateFilteredSearchedBookmarks();
+        this.notify('filter-tags-search-changed', this._searchedBookmarks,
+            this._filterTags, this._searchTerm);
+    }
+};
+
+BookmarkModel.prototype.forEach =  function (callbackFn, thisArg) {
+    this._bookmarks.keys().forEach(function (key) {
+        callbackFn.call(thisArg, this._bookmarks.get(key), key, this);
+    }, this);
+};
+
+BookmarkModel.prototype.hasTag = function (tag) {
+    return this._tagCount.has(tag);
+};
+
+BookmarkModel.prototype.getTagCount = function (tag) {
+    return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined;
+};
+
+BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) {
+    var searchUrls;
+
+    // additive search if urlsSubset is given
+    if (urlsSubset !== undefined) {
+        searchUrls = urlsSubset;
+    } else {
+        this._searchedBookmarks = new StringSet();
+
+        searchUrls = this._filteredBookmarks.values();
+    }
+
+    // search for the search term in title and URL
+    searchUrls.forEach(function (url) {
+        var bookmark;
+
+        bookmark = this.get(url);
+        if (this._searchTerm === '' ||
+                bookmark.title.indexOf(this._searchTerm) !== -1 ||
+                bookmark.url.indexOf(this._searchTerm) !== -1) {
+            this._searchedBookmarks.add(url);
+        }
+    }, this);
+};
+
+BookmarkModel.prototype.updateFilteredSearchedBookmarks =
+        function (urlsSubset) {
+    var filterUrls;
+    var searchUrls;
+
+    // additive filtering if urlsSubset is given
+    if (urlsSubset !== undefined) {
+        filterUrls = urlsSubset;
+        searchUrls = [];
+    } else {
+        this._filteredBookmarks = new StringSet();
+
+        filterUrls = this._bookmarks.keys();
+        searchUrls = undefined;
+    }
+
+    // apply tag filter
+    filterUrls.forEach(function (url) {
+        var bookmark;
+        var matchingTagCount = 0;
+
+        bookmark = this.get(url);
+
+        bookmark.tags.forEach(function (tag) {
+            if (this._filterTags.has(tag)) {
+                matchingTagCount++;
+            }
+        }, this);
+
+        if (matchingTagCount === this._filterTags.size) {
+            this._filteredBookmarks.add(url);
+            if (urlsSubset !== undefined) {
+                searchUrls.push(url);
+            }
+        }
+    }, this);
+
+    // search the filter results
+    this.updateSearchedBookmarks(searchUrls);
+};
+
+BookmarkModel.prototype.toggleFilterTag = function (tag) {
+    if (this._filterTags.has(tag)) {
+        this._filterTags.delete(tag);
+    } else {
+        this._filterTags.add(tag);
+    }
+    this.updateFilteredSearchedBookmarks();
+    this.notify('filter-tags-search-changed', this._searchedBookmarks,
+        this._filterTags, this._searchTerm);
+};
+
+BookmarkModel.prototype.setFilterTags = function (filterTags) {
+    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
+        this._filterTags = new StringSet(filterTags);
+        this.updateFilteredSearchedBookmarks();
+        this.notify('filter-tags-search-changed', this._searchedBookmarks,
+            this._filterTags, this._searchTerm);
+    }
+};
+
+BookmarkModel.prototype.setSearchTerm = function (searchTerm) {
+    if (searchTerm !== this._searchTerm) {
+        this._searchTerm = searchTerm;
+        this.updateSearchedBookmarks();
+        this.notify('filter-tags-search-changed', this._searchedBookmarks,
+            this._filterTags, this._searchTerm);
+    }
+};
+
+BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags,
+        searchTerm) {
+    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
+        this._filterTags = new StringSet(filterTags);
+        this._searchTerm = searchTerm;
+        this.updateFilteredSearchedBookmarks();
+        this.notify('filter-tags-search-changed', this._searchedBookmarks,
+            this._filterTags, this._searchTerm);
+    } else if (searchTerm !== this._searchTerm) {
+        this._searchTerm = searchTerm;
+        this.updateSearchedBookmarks();
+        this.notify('filter-tags-search-changed', this._searchedBookmarks,
+            this._filterTags, this._searchTerm);
+    }
+};
+
+BookmarkModel.prototype.parseLoadedBookmarks = function (data) {
+    var parsedData;
+    var bookmarks = [];
+
+    try {
+        parsedData = JSON.parse(data);
+    } catch (e) {
+        this.notify('load-file-error', e.message);
+        return;
+    }
+
+    if (!Array.isArray(parsedData.bookmarks)) {
+        this.notify('parse-file-error',
+            'This file does not contain bookmarks.');
+        return;
+    }
+
+    // create a temporary list of valid bookmarks
+    parsedData.bookmarks.forEach(function (bookmark) {
+        if (isString(bookmark.url) && bookmark.url !== '') {
+            bookmarks.push(new Bookmark(bookmark.url, bookmark.title,
+                bookmark.tags, bookmark.ctime, bookmark.mtime));
+        }
+    }, this);
+
+    // add each bookmark to the model ordered by the last modification time
+    this.add(bookmarks.sort(function (bookmark1, bookmark2) {
+        return bookmark1.ctime - bookmark2.ctime;
+    }));
+    this.unsavedChanges = false;
+};
+
+BookmarkModel.prototype.loadFile = function (bookmarkFile) {
+    var bookmarkFileReader;
+
+    // delete all existing bookmarks first
+    this.delete(this._bookmarks.keys());
+    this.unsavedChanges = false;
+
+    bookmarkFileReader = new FileReader();
+    bookmarkFileReader.addEventListener('error', this);
+    bookmarkFileReader.addEventListener('load', this);
+    bookmarkFileReader.readAsText(bookmarkFile);
+};
+
+BookmarkModel.prototype.saveFile = function () {
+    var jsonBlob;
+    var bookmarkData = {
+        'bookmarks': []
+    };
+
+    this._bookmarks.forEach(function (bookmark) {
+        bookmarkData.bookmarks.push(bookmark);
+    }, this);
+
+    jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type:
+        'application/json'});
+    this.notify('save-file', jsonBlob);
+    this.unsavedChanges = false;
+};
+
+BookmarkModel.prototype.handleEvent = function (e) {
+    if (e.type === 'load') {
+        this.parseLoadedBookmarks(e.target.result);
+    } else if (e.type === 'error') {
+        this.notify('load-file-error', e.target.error.message);
+    }
+};
+
+
+/*
+ * view
+ */
+
+var TagView = function () {
+    ObservableMixin.call(this);
+
+    this.tagListElement = document.querySelector('#tags ul.tag-list');
+    this.tagListElement.addEventListener('click', this);
+
+    this.tagTemplate = document.querySelector('#tag-template');
+};
+
+extend(TagView, ObservableMixin);
+
+TagView.prototype.onTagAdded = function (tag) {
+    var newNode;
+    var tagElement;
+    var setTagButton;
+    var toggleTagButton;
+    var tagElements;
+    var i;
+    var referenceTag = '';
+    var referenceNode;
+
+    // create new tag element from template
+    newNode = document.importNode(this.tagTemplate.content, true);
+
+    tagElement = newNode.querySelector('li');
+    tagElement.dataset.tag = tag;
+
+    setTagButton = tagElement.querySelector('button[name="set-tag"]');
+    setTagButton.textContent = tag;
+    setTagButton.title = 'Set filter to "' + tag + '"';
+
+    toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]');
+    toggleTagButton.textContent = '+';
+    toggleTagButton.title = 'Add "' + tag + '" to filter';
+
+    // maintain alphabetical order when inserting the tag element
+    tagElements = this.tagListElement.querySelectorAll('li');
+    for (i = 0; i < tagElements.length; i ++) {
+        if (tagElements[i].dataset.tag > referenceTag &&
+                tagElements[i].dataset.tag < tag) {
+            referenceTag = tagElements[i].dataset.tag;
+            referenceNode = tagElements[i];
+        }
+    }
+    this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ?
+        referenceNode.nextSibling : this.tagListElement.firstChild);
+
+    // initialize tag count
+    this.onTagCountChanged(tag, 1);
+};
+
+TagView.prototype.onTagCountChanged = function (tag, tagCount) {
+    this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) +
+        ' .tag-count').textContent = '(' + tagCount + ')';
+};
+
+TagView.prototype.onTagDeleted = function (tag) {
+    var tagElement;
+
+    tagElement = this.tagListElement.querySelector('li' +
+        createDatasetSelector('tag', tag));
+    if (tagElement !== null) {
+        tagElement.parentNode.removeChild(tagElement);
+    }
+};
+
+TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
+        newFilterTags, newSearchTerm) {
+    var tagElements;
+    var i;
+    var tag;
+    var toggleTagButton;
+
+    tagElements = this.tagListElement.querySelectorAll('li');
+    for (i = 0; i < tagElements.length; i++) {
+        tag = tagElements[i].dataset.tag;
+        toggleTagButton =
+            tagElements[i].querySelector('button[name="toggle-tag"]');
+        if (newFilterTags.has(tag)) {
+            tagElements[i].classList.add('active-filter-tag');
+            toggleTagButton.textContent = '\u2212';
+            toggleTagButton.title = 'Remove "' + tag + '" from filter';
+        } else {
+            tagElements[i].classList.remove('active-filter-tag');
+            toggleTagButton.textContent = '+';
+            toggleTagButton.title = 'Add "' + tag + '" to filter';
+        }
+    }
+};
+
+TagView.prototype.handleEvent = function (e) {
+    if (e.type === 'click' && (e.target.name === 'set-tag' ||
+            e.target.name === 'toggle-tag')) {
+        e.target.blur();
+
+        this.notify(e.target.name, getAncestorElementDatasetItem(e.target,
+            'tag'));
+    }
+};
+
+
+var ActionsView = function () {
+    var saveFormElement;
+    var loadFormElement;
+    var newNode;
+    var editorFormElement;
+
+    ObservableMixin.call(this);
+
+    this.tagInputTemplate = document.querySelector('#tag-input-template');
+    saveFormElement = document.querySelector('form#save-form');
+    saveFormElement.addEventListener('submit', this);
+
+    this.saveLinkElement = saveFormElement.querySelector('a#save-link');
+
+    loadFormElement = document.querySelector('form#load-form');
+    loadFormElement.addEventListener('submit', this);
+
+    // create new editor form from template
+    newNode = document.importNode(
+        document.querySelector('#bookmark-editor-template').content, true);
+
+    editorFormElement = newNode.querySelector('form.bookmark-editor-form');
+    editorFormElement.querySelector('legend').textContent = 'Add Bookmark';
+    editorFormElement.querySelector('input:not([type="hidden"])').accessKey =
+        'a';
+    editorFormElement.addEventListener('click', this);
+    editorFormElement.addEventListener('submit', this);
+    editorFormElement.addEventListener('reset', this);
+
+    this.editTagListElement =
+        editorFormElement.querySelector('ul.tag-input-list');
+    this.editTagListElement.appendChild(this.createTagInputElement(''));
+
+    saveFormElement.parentNode.insertBefore(newNode,
+        saveFormElement.nextSibling);
+};
+
+extend(ActionsView, ObservableMixin);
+
+ActionsView.prototype.createTagInputElement = function (tag) {
+    var newNode;
+
+    newNode = document.importNode(this.tagInputTemplate.content, true);
+    newNode.querySelector('input[name="tag"]').value = tag;
+
+    return newNode;
+};
+
+ActionsView.prototype.handleEvent = function (e) {
+    var tags = [];
+    var i;
+
+    switch (e.type) {
+    case 'click':
+        if (e.target.name === 'more-tags') {
+            e.preventDefault();
+            e.target.blur();
+
+            this.editTagListElement.appendChild(this.createTagInputElement(''));
+        }
+        break;
+    case 'submit':
+        if (e.target.id === 'save-form') {
+            e.preventDefault();
+            e.target.blur();
+
+            this.notify('save-file');
+        } else if (e.target.id === 'load-form') {
+            e.preventDefault();
+            e.target.blur();
+
+            this.notify('load-file', e.target.file.files[0]);
+            e.target.reset();
+        } else if (e.target.classList.contains('bookmark-editor-form')) {
+            e.preventDefault();
+            e.target.blur();
+
+            if (e.target.tag.length) {
+                for (i = 0; i < e.target.tag.length; i++) {
+                    tags.push(e.target.tag[i].value.trim());
+                }
+            } else {
+                tags.push(e.target.tag.value.trim());
+            }
+
+            this.notify('save-bookmark', e.target.url.value,
+                e.target.title.value, tags);
+
+            e.target.reset();
+        }
+        break;
+    case 'reset':
+        if (e.target.classList.contains('bookmark-editor-form')) {
+            e.target.blur();
+
+            // remove all but one tag input element
+            while (this.editTagListElement.firstChild !== null) {
+                this.editTagListElement.removeChild(
+                    this.editTagListElement.firstChild);
+            }
+            this.editTagListElement.appendChild(this.createTagInputElement(''));
+        }
+        break;
+    }
+};
+
+ActionsView.prototype.onSaveFile = function (jsonBlob) {
+    this.saveLinkElement.href = URL.createObjectURL(jsonBlob);
+    this.saveLinkElement.click();
+};
+
+ActionsView.prototype.confirmLoadFile = function () {
+    return window.confirm('There are unsaved changes to your bookmarks.\n' +
+        'Proceed loading the bookmark file?');
+};
+
+ActionsView.prototype.onLoadFileError = function (message) {
+    window.alert('Failed to load bookmark file:\n' + message);
+};
+
+ActionsView.prototype.onParseFileError = function (message) {
+    window.alert('Failed to parse bookmark file:\n' + message);
+};
+
+
+var BookmarkView = function () {
+    var searchFormElement;
+
+    ObservableMixin.call(this);
+
+    this.bookmarkTemplate = document.querySelector('#bookmark-template');
+    this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template');
+    this.bookmarkEditorTemplate =
+        document.querySelector('#bookmark-editor-template');
+    this.tagInputTemplate = document.querySelector('#tag-input-template');
+
+    this.bookmarkListElement = document.querySelector('ul#bookmark-list');
+    this.bookmarkListElement.addEventListener('click', this);
+    this.bookmarkListElement.addEventListener('submit', this);
+    this.bookmarkListElement.addEventListener('reset', this);
+
+    searchFormElement = document.querySelector('#search-form');
+    searchFormElement.addEventListener('submit', this);
+    searchFormElement.addEventListener('reset', this);
+
+    this.searchTermInputElement = searchFormElement['search-term'];
+
+    this.bookmarkMessageElement = document.querySelector('#bookmark-message');
+
+    this.updateBookmarkMessage();
+};
+
+extend(BookmarkView, ObservableMixin);
+
+BookmarkView.prototype.handleEvent = function (e) {
+    var i;
+    var tags = [];
+    var node;
+
+    switch (e.type) {
+    case 'click':
+        switch (e.target.name) {
+        case 'edit-bookmark':
+            e.target.blur();
+            // fallthrough
+        case 'delete-bookmark':
+            this.notify(e.target.name,
+                getAncestorElementDatasetItem(e.target, 'bookmarkUrl'));
+            break;
+        case 'more-tags':
+            e.target.blur();
+
+            e.target.form.querySelector('ul.tag-input-list').appendChild(
+                this.createTagInputElement(''));
+            break;
+        case 'set-tag':
+        case 'toggle-tag':
+            e.target.blur();
+
+            this.notify(e.target.name,
+                getAncestorElementDatasetItem(e.target, 'tag'));
+            break;
+        }
+        break;
+    case 'submit':
+        if (e.target.classList.contains('bookmark-editor-form')) {
+            // save bookmark-editor-form form contents
+            e.preventDefault();
+
+            if (e.target.tag.length) {
+                for (i = 0; i < e.target.tag.length; i++) {
+                    tags.push(e.target.tag[i].value.trim());
+                }
+            } else {
+                tags.push(e.target.tag.value.trim());
+            }
+
+            this.notify('save-bookmark', e.target.url.value,
+                e.target.title.value, tags, e.target['original-url'].value);
+        } else if (e.target.id === 'search-form') {
+            // search
+            e.preventDefault();
+            e.target.blur();
+
+            this.notify('search', e.target['search-term'].value);
+        }
+        break;
+    case 'reset':
+        if (e.target.classList.contains('bookmark-editor-form')) {
+            // cancel bookmark-editor-form form
+            e.preventDefault();
+
+            // re-enable edit button
+            this.bookmarkListElement.querySelector('li' +
+                createDatasetSelector('bookmark-url',
+                e.target['original-url'].value) +
+                ' button[name="edit-bookmark"]').disabled = false;
+
+            e.target.parentNode.removeChild(e.target);
+        } else if (e.target.id === 'search-form') {
+            // clear search
+            e.preventDefault();
+            e.target.blur();
+
+            this.notify('search', '');
+        }
+        break;
+    }
+};
+
+BookmarkView.prototype.updateBookmarkMessage = function () {
+    this.bookmarkMessageElement.textContent = 'Showing ' +
+        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
+        'li:not([hidden])').length + ' of ' +
+        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
+        'li').length + ' bookmarks.';
+};
+
+BookmarkView.prototype.onBookmarkAdded = function (bookmark) {
+    var newNode;
+    var bookmarkElement;
+    var linkElement;
+    var tagListElement;
+
+    newNode = document.importNode(this.bookmarkTemplate.content, true);
+
+    bookmarkElement = newNode.querySelector('li');
+    bookmarkElement.dataset.bookmarkUrl = bookmark.url;
+
+    linkElement = bookmarkElement.querySelector('a.bookmark-link');
+    linkElement.textContent = linkElement.title = bookmark.title;
+    linkElement.href = bookmark.url;
+
+    tagListElement = bookmarkElement.querySelector('ul.tag-list');
+    bookmark.tags.forEach(function (tag) {
+        var newNode;
+        var tagElement;
+        var setTagButton;
+        var toggleTagButton;
+
+        newNode = document.importNode(this.bookmarkTagTemplate.content, true);
+
+        tagElement = newNode.querySelector('li');
+        tagElement.dataset.tag = tag;
+
+        setTagButton = newNode.querySelector('button[name="set-tag"]');
+        setTagButton.textContent = tag;
+        setTagButton.title = 'Set filter to "' + tag + '"';
+
+        toggleTagButton = newNode.querySelector('button[name="toggle-tag"]');
+        toggleTagButton.textContent = '+';
+        toggleTagButton.title = 'Add "' + tag + '" to filter';
+
+        tagListElement.appendChild(newNode);
+    }, this);
+
+    // insert new or last modified bookmark on top of the list
+    this.bookmarkListElement.insertBefore(newNode,
+        this.bookmarkListElement.firstChild);
+
+    this.updateBookmarkMessage();
+};
+
+BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) {
+    var bookmarkElement;
+
+    bookmarkElement = this.bookmarkListElement.querySelector('li' +
+        createDatasetSelector('bookmark-url', bookmarkUrl));
+    if (bookmarkElement !== null) {
+        this.bookmarkListElement.removeChild(bookmarkElement);
+
+        this.updateBookmarkMessage();
+    }
+};
+
+BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
+        newFilterTags, newSearchTerm) {
+    var bookmarkElements;
+    var i;
+    var tagElements;
+    var toggleTagButton;
+    var j;
+    var tag;
+
+    this.searchTermInputElement.value = newSearchTerm;
+
+    bookmarkElements =
+        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li');
+    for (i = 0; i < bookmarkElements.length; i++) {
+        // update visibility of bookmarks
+        if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) {
+            // update tag elements of visible bookmarks
+            tagElements =
+                bookmarkElements[i].querySelectorAll('ul.tag-list > li');
+            for (j = 0; j < tagElements.length; j++) {
+                tag = tagElements[j].dataset.tag;
+                toggleTagButton =
+                    tagElements[j].querySelector('button[name="toggle-tag"]');
+                if (newFilterTags.has(tag)) {
+                    tagElements[j].classList.add('active-filter-tag');
+                    toggleTagButton.textContent = '\u2212';
+                    toggleTagButton.title = 'Remove "' + tag + '" from filter';
+                } else {
+                    tagElements[j].classList.remove('active-filter-tag');
+                    toggleTagButton.textContent = '+';
+                    toggleTagButton.title = 'Add "' + tag + '" to filter';
+                }
+            }
+            bookmarkElements[i].hidden = false;
+        } else {
+            bookmarkElements[i].hidden = true;
+        }
+    }
+
+    this.updateBookmarkMessage();
+};
+
+BookmarkView.prototype.createTagInputElement = function (tag) {
+    var newNode;
+
+    newNode = document.importNode(this.tagInputTemplate.content, true);
+    newNode.querySelector('input[name="tag"]').value = tag;
+
+    return newNode;
+};
+
+BookmarkView.prototype.displayBookmarkEditor = function (bookmark) {
+    var bookmarkElement;
+    var newNode;
+    var formElement;
+    var editTagListElement;
+
+    bookmarkElement =
+        this.bookmarkListElement.querySelector('ul#bookmark-list > li' +
+        createDatasetSelector('bookmark-url', bookmark.url));
+
+    // disable edit button while editing
+    bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled =
+        true;
+
+    // create new editor form from template
+    newNode = document.importNode(this.bookmarkEditorTemplate.content, true);
+
+    // fill with data of given bookmark
+    formElement = newNode.querySelector('form.bookmark-editor-form');
+    formElement.querySelector('legend').textContent = 'Edit Bookmark';
+    formElement['original-url'].value = bookmark.url;
+    formElement.url.value = bookmark.url;
+    formElement.title.value = bookmark.title;
+
+    editTagListElement = formElement.querySelector('ul.tag-input-list');
+    bookmark.tags.forEach(function (tag) {
+        editTagListElement.appendChild(this.createTagInputElement(tag));
+    }, this);
+    editTagListElement.appendChild(this.createTagInputElement(''));
+
+    // insert editor form into bookmark item
+    bookmarkElement.appendChild(newNode);
+
+    // focus first input element
+    formElement.querySelector('input').focus();
+};
+
+BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) {
+    return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' +
+        bookmark.url + ']?');
+};
+
+BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) {
+    return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' +
+        bookmark.url + ']?');
+};
+
+
+/*
+ * controller
+ */
+
+var BooketController = function(bookmarkModel, actionsView, tagView,
+        bookmarkView) {
+    this.bookmarkModel = bookmarkModel;
+    this.actionsView = actionsView;
+    this.tagView = tagView;
+    this.bookmarkView = bookmarkView;
+
+    /* connect the views to the model */
+    this.bookmarkModel.addObserver('bookmark-added',
+        this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView));
+    this.bookmarkModel.addObserver('bookmark-deleted',
+        this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView));
+    this.bookmarkModel.addObserver('filter-tags-search-changed',
+        this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView));
+    this.bookmarkModel.addObserver('load-file-error',
+        this.actionsView.onLoadFileError.bind(this.actionsView));
+    this.bookmarkModel.addObserver('parse-file-error',
+        this.actionsView.onParseFileError.bind(this.actionsView));
+    this.bookmarkModel.addObserver('save-file',
+        this.actionsView.onSaveFile.bind(this.actionsView));
+    this.bookmarkModel.addObserver('tag-added',
+        this.tagView.onTagAdded.bind(this.tagView));
+    this.bookmarkModel.addObserver('tag-count-changed',
+        this.tagView.onTagCountChanged.bind(this.tagView));
+    this.bookmarkModel.addObserver('tag-deleted',
+        this.tagView.onTagDeleted.bind(this.tagView));
+    this.bookmarkModel.addObserver('filter-tags-search-changed',
+        this.tagView.onFilterTagsSearchChanged.bind(this.tagView));
+    this.bookmarkModel.addObserver('filter-tags-search-changed',
+        this.onFilterTagsSearchChanged.bind(this));
+
+    /* handle input */
+    window.addEventListener('hashchange', this.onHashChange.bind(this));
+    window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
+    this.actionsView.addObserver('save-file',
+        this.bookmarkModel.saveFile.bind(this.bookmarkModel));
+    this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
+    this.actionsView.addObserver('save-bookmark',
+        this.onSaveBookmark.bind(this));
+    this.bookmarkView.addObserver('edit-bookmark',
+        this.onEditBookmark.bind(this));
+    this.bookmarkView.addObserver('save-bookmark',
+        this.onSaveBookmark.bind(this));
+    this.bookmarkView.addObserver('delete-bookmark',
+        this.onDeleteBookmark.bind(this));
+    this.bookmarkView.addObserver('toggle-tag',
+        this.onToggleFilterTag.bind(this));
+    this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this));
+    this.bookmarkView.addObserver('search', this.onSearch.bind(this));
+    this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this));
+    this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this));
+};
+
+BooketController.prototype.parseTagsParameter = function (tagsString) {
+    var tags;
+
+    tags = tagsString.split(',').filter(function (tag) {
+        return (tag !== '') && this.bookmarkModel.hasTag(tag);
+    }, this).sort();
+
+    return new StringSet(tags);
+};
+
+BooketController.prototype.onHashChange = function (e) {
+    var hashData;
+    var filterTags;
+    var searchTerm;
+
+    hashData = parseHash(window.location.href);
+
+    filterTags = hashData.has('tags') ?
+        this.parseTagsParameter(hashData.get('tags')) : new StringSet();
+
+    searchTerm = hashData.has('search') ? hashData.get('search') : '';
+
+    this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm);
+};
+
+BooketController.prototype.onBeforeUnload = function (e) {
+    var confirmationMessage = 'There are unsaved changes to your bookmarks.';
+
+    if (this.bookmarkModel.unsavedChanges) {
+        if (e) {
+            e.returnValue = confirmationMessage;
+        }
+        if (window.event) {
+            window.event.returnValue = confirmationMessage;
+        }
+        return confirmationMessage;
+    }
+};
+
+BooketController.prototype.onFilterTagsSearchChanged =
+        function (filteredBookmarks, newFilterTags, newSearchTerm) {
+    var url = window.location.href;
+    var hashData;
+
+    // serialize tag filter and search term and update window.location
+    hashData = parseHash(url);
+    hashData.set('tags', newFilterTags.values().join(','));
+    hashData.set('search', newSearchTerm);
+    history.pushState(null, null, serializeHash(url, hashData));
+};
+
+BooketController.prototype.onLoadFile = function (bookmarkFile) {
+    if (this.bookmarkModel.unsavedChanges) {
+        if (!this.actionsView.confirmLoadFile()) {
+            return;
+        }
+        this.bookmarkModel.unsavedChanges = false;
+    }
+
+    this.bookmarkModel.loadFile(bookmarkFile);
+};
+
+BooketController.prototype.onEditBookmark = function (bookmarkUrl) {
+    this.bookmarkView.displayBookmarkEditor(
+        this.bookmarkModel.get(bookmarkUrl));
+};
+
+BooketController.prototype.onSaveBookmark = function (url, title, tags,
+        originalUrl) {
+    var ctime;
+
+    if (originalUrl === undefined) {
+        // saving new bookmark, get confirmation before replacing existing one
+        if (this.bookmarkModel.has(url)) {
+            if (this.bookmarkView.confirmReplaceBookmark(
+                    this.bookmarkModel.get(url))) {
+                this.bookmarkModel.delete(url);
+            } else {
+                return;
+            }
+        }
+
+        ctime = new Date();
+    } else {
+        // saving edited bookmark, preserve creation time of any replaced
+        // bookmark
+        ctime = (this.bookmarkModel.has(url)) ?
+            this.bookmarkModel.get(url).ctime : new Date();
+
+        this.bookmarkModel.delete(originalUrl);
+    }
+    this.bookmarkModel.add(new Bookmark(url, title, tags, ctime));
+};
+
+BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) {
+    if (this.bookmarkView.confirmDeleteBookmark(
+            this.bookmarkModel.get(bookmarkUrl))) {
+        this.bookmarkModel.delete(bookmarkUrl);
+    }
+};
+
+BooketController.prototype.onToggleFilterTag = function (tag) {
+    this.bookmarkModel.toggleFilterTag(tag);
+};
+
+BooketController.prototype.onSetTagFilter = function (tag) {
+    this.bookmarkModel.setFilterTags(new StringSet([tag]));
+};
+
+BooketController.prototype.onSearch = function (searchTerm) {
+    this.bookmarkModel.setSearchTerm(searchTerm);
+};
+
+
+document.addEventListener('DOMContentLoaded', function (e) {
+    var controller;
+    var bookmarkModel;
+    var actionsView;
+    var tagView;
+    var bookmarkView;
+    var hashChangeEvent;
+
+    bookmarkModel = new BookmarkModel();
+    tagView = new TagView();
+    actionsView = new ActionsView();
+    bookmarkView = new BookmarkView();
+    controller = new BooketController(bookmarkModel, actionsView,
+        tagView, bookmarkView);
+
+    // initialize state from the current URL
+    hashChangeEvent = new Event('hashchange');
+    hashChangeEvent.oldURL = window.location.href;
+    hashChangeEvent.newURL = window.location.href;
+    window.dispatchEvent(hashChangeEvent);
+});
+}());
+