projects/booket

changeset 0:c2248f662a2c version-1

Initial revision
author Guido Berhoerster <guido+booket@berhoerster.name>
date Sat Sep 06 18:18:29 2014 +0200 (2014-09-06)
parents
children 6559033d9996
files booket.css booket.html booket.js
line diff
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/booket.css	Sat Sep 06 18:18:29 2014 +0200
     1.3 @@ -0,0 +1,285 @@
     1.4 +/*
     1.5 + * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
     1.6 + *
     1.7 + * Permission is hereby granted, free of charge, to any person obtaining
     1.8 + * a copy of this software and associated documentation files (the
     1.9 + * "Software"), to deal in the Software without restriction, including
    1.10 + * without limitation the rights to use, copy, modify, merge, publish,
    1.11 + * distribute, sublicense, and/or sell copies of the Software, and to
    1.12 + * permit persons to whom the Software is furnished to do so, subject to
    1.13 + * the following conditions:
    1.14 + *
    1.15 + * The above copyright notice and this permission notice shall be included
    1.16 + * in all copies or substantial portions of the Software.
    1.17 + *
    1.18 + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    1.19 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    1.20 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    1.21 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    1.22 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    1.23 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    1.24 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    1.25 + */
    1.26 +
    1.27 +html {
    1.28 +    color: #000000;
    1.29 +    background-color: #ffffff;
    1.30 +    font-family: "DejaVu Sans", Arial, Helvetica, sans-serif;
    1.31 +    max-width: 70em;
    1.32 +    margin: 0 auto;
    1.33 +}
    1.34 +
    1.35 +fieldset {
    1.36 +    border: none;
    1.37 +    border-top: 1px solid #888a85;
    1.38 +}
    1.39 +
    1.40 +legend {
    1.41 +    font-size: .75em;
    1.42 +    font-weight: bold;
    1.43 +}
    1.44 +
    1.45 +label,
    1.46 +input[type="text"],
    1.47 +input[type="file"],
    1.48 +input[type="url"] {
    1.49 +    display: block;
    1.50 +}
    1.51 +
    1.52 +label {
    1.53 +    font-weight: bold;
    1.54 +    font-size: .75em;
    1.55 +}
    1.56 +
    1.57 +kbd {
    1.58 +    display: inline-block;
    1.59 +    font-family: Courier, monospace;
    1.60 +    background-color: #fdfdfb;
    1.61 +    border: thin solid #babdb6;
    1.62 +    box-shadow: inset 0 1px 0 0 #ffffff, inset 0 -1px 0 0 #babdb6;
    1.63 +    border-radius: .25em;
    1.64 +    padding: .125em .5em;
    1.65 +    white-space: nowrap;
    1.66 +}
    1.67 +
    1.68 +h1 {
    1.69 +    font-size: 2em;
    1.70 +    margin: .67em 0
    1.71 +}
    1.72 +
    1.73 +h2 {
    1.74 +    font-size: 1.5em;
    1.75 +    margin: .75em 0
    1.76 +}
    1.77 +
    1.78 +h3 {
    1.79 +    font-size: 1.17em;
    1.80 +    margin: .83em 0
    1.81 +}
    1.82 +
    1.83 +h1, h2, h3 {
    1.84 +    font-weight: bolder
    1.85 +}
    1.86 +
    1.87 +section,
    1.88 +main,
    1.89 +footer {
    1.90 +    clear: both;
    1.91 +}
    1.92 +
    1.93 +footer {
    1.94 +    clear: both;
    1.95 +    margin: 1em 0 0 0;
    1.96 +    padding: .5em 0 0 0;
    1.97 +    border-top: 1px solid #888a85;
    1.98 +    font-size: .75em;
    1.99 +}
   1.100 +
   1.101 +address {
   1.102 +    font-style: inherit;
   1.103 +    color: #555753;
   1.104 +}
   1.105 +
   1.106 +address :link,
   1.107 +address :visited {
   1.108 +    text-decoration: underline;
   1.109 +    color: inherit;
   1.110 +}
   1.111 +
   1.112 +header h1 {
   1.113 +    display: inline-block;
   1.114 +    margin: 0 .25em 0 0;
   1.115 +}
   1.116 +
   1.117 +header h1 ~ p {
   1.118 +    display: inline-block;
   1.119 +    margin: 0;
   1.120 +    font-weight: bold;
   1.121 +}
   1.122 +
   1.123 +#actions {
   1.124 +    margin: 1em 0 0 0;
   1.125 +}
   1.126 +
   1.127 +#actions > h2 {
   1.128 +    display: none;
   1.129 +}
   1.130 +
   1.131 +#actions form ~ form {
   1.132 +    margin: 1em 0 0 0;
   1.133 +}
   1.134 +
   1.135 +#keyboard-shortcuts {
   1.136 +    float: right;
   1.137 +    border: 1px solid #d3d7cf;
   1.138 +    border-radius: .5em;
   1.139 +    background-color: #fbfbf9;
   1.140 +    padding: .5em;
   1.141 +    margin: 0 0 1em 1em;
   1.142 +    font-size: .75em;
   1.143 +}
   1.144 +
   1.145 +#keyboard-shortcuts h3 {
   1.146 +    font-size: 1em;
   1.147 +    text-align: center;
   1.148 +    margin: 0;
   1.149 +}
   1.150 +
   1.151 +#keyboard-shortcuts dl {
   1.152 +    margin: 1em 0 0 0;
   1.153 +}
   1.154 +
   1.155 +#keyboard-shortcuts dd {
   1.156 +    margin: .25em 0 0 0;
   1.157 +}
   1.158 +
   1.159 +#keyboard-shortcuts dd ~ dt {
   1.160 +    margin: .5em 0 0 0;
   1.161 +}
   1.162 +
   1.163 +#bookmarks {
   1.164 +    margin: 1em 0 0 0;
   1.165 +}
   1.166 +
   1.167 +#bookmarks h2 {
   1.168 +    margin: 0;
   1.169 +}
   1.170 +
   1.171 +#tags,
   1.172 +#search,
   1.173 +#bookmark-message,
   1.174 +#bookmark-list {
   1.175 +    margin: .5em 0 0 0;
   1.176 +}
   1.177 +
   1.178 +#tags h3,
   1.179 +#search h3 {
   1.180 +    display: none;
   1.181 +}
   1.182 +
   1.183 +ul.tag-input-list,
   1.184 +ul.tag-list {
   1.185 +    margin: 0;
   1.186 +    padding: 0;
   1.187 +}
   1.188 +
   1.189 +ul#bookmark-list {
   1.190 +    padding: 0;
   1.191 +}
   1.192 +
   1.193 +ul.tag-input-list li,
   1.194 +ul.tag-list li,
   1.195 +ul#bookmark-list > li {
   1.196 +    list-style-type: none;
   1.197 +    padding: 0;
   1.198 +    margin: 0;
   1.199 +}
   1.200 +
   1.201 +ul.tag-list li {
   1.202 +    display: inline-block;
   1.203 +    border: 1px solid #c4a000;
   1.204 +    border-radius: .25em;
   1.205 +    padding: .1em;
   1.206 +    background-color: #fce94f;
   1.207 +    margin: .25em .25em 0 0;
   1.208 +    white-space: nowrap;
   1.209 +    font-size: .75em;
   1.210 +}
   1.211 +
   1.212 +ul.tag-list button {
   1.213 +    color: #000000;
   1.214 +    background-color: transparent;
   1.215 +    border: thin solid transparent;
   1.216 +    border-radius: .1em;
   1.217 +    padding: .1em;
   1.218 +    margin: 0 .1em;
   1.219 +    cursor: pointer;
   1.220 +}
   1.221 +
   1.222 +ul.tag-list button:hover,
   1.223 +ul.tag-list button:focus,
   1.224 +ul.tag-list button:active {
   1.225 +    border: thin solid #deba1a;
   1.226 +    background-color: #ffff69;
   1.227 +}
   1.228 +
   1.229 +ul.tag-list li.active-filter-tag {
   1.230 +    border: thin solid #4e9a06;
   1.231 +    background-color: #8ae234;
   1.232 +}
   1.233 +
   1.234 +ul.tag-list li.active-filter-tag button:hover,
   1.235 +ul.tag-list li.active-filter-tag button:focus,
   1.236 +ul.tag-list li.active-filter-tag button:active {
   1.237 +    border: thin solid #68b420;
   1.238 +    background-color: #a4fc4e;
   1.239 +}
   1.240 +
   1.241 +ul#bookmark-list > li {
   1.242 +    border-top: 1px solid #888a85;
   1.243 +    padding: .25em 0 0 0;    
   1.244 +}
   1.245 +
   1.246 +ul#bookmark-list > li ~ li {
   1.247 +    margin: .25em 0 0 0;    
   1.248 +}
   1.249 +
   1.250 +ul#bookmark-list ul.tag-list {
   1.251 +    max-width: 33%;
   1.252 +    float: right;
   1.253 +    margin: 0 0 .25em .25em;
   1.254 +}
   1.255 +
   1.256 +ul#bookmark-list ul.tag-list > li {
   1.257 +    float: right;
   1.258 +}
   1.259 +
   1.260 +ul#bookmark-list > li::after {
   1.261 +    display: block;
   1.262 +    content: '';
   1.263 +    clear: right;
   1.264 +}
   1.265 +
   1.266 +ul#bookmark-list .bookmark-editor-form {
   1.267 +    margin: .5em;
   1.268 +}
   1.269 +
   1.270 +ul#bookmark-list .bookmark-editor-form fieldset {
   1.271 +    border-top: 1px solid #d3d7cf;
   1.272 +}
   1.273 +
   1.274 +a.bookmark-link:link,
   1.275 +a.bookmark-link:visited {
   1.276 +    color: #001754;
   1.277 +    font-weight: bold;
   1.278 +    text-decoration: underline;
   1.279 +}
   1.280 +
   1.281 +a.bookmark-link:link:hover,
   1.282 +a.bookmark-link:link:focus,
   1.283 +a.bookmark-link:link:active,
   1.284 +a.bookmark-link:visited:hover,
   1.285 +a.bookmark-link:visited:focus,
   1.286 +a.bookmark-link:visited:active {
   1.287 +    color: #07316e;
   1.288 +}
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/booket.html	Sat Sep 06 18:18:29 2014 +0200
     2.3 @@ -0,0 +1,153 @@
     2.4 +<!DOCTYPE html>
     2.5 +<!--
     2.6 +  Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
     2.7 +
     2.8 +  Permission is hereby granted, free of charge, to any person obtaining
     2.9 +  a copy of this software and associated documentation files (the
    2.10 +  "Software"), to deal in the Software without restriction, including
    2.11 +  without limitation the rights to use, copy, modify, merge, publish,
    2.12 +  distribute, sublicense, and/or sell copies of the Software, and to
    2.13 +  permit persons to whom the Software is furnished to do so, subject to
    2.14 +  the following conditions:
    2.15 +
    2.16 +  The above copyright notice and this permission notice shall be included
    2.17 +  in all copies or substantial portions of the Software.
    2.18 +
    2.19 +  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    2.20 +  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    2.21 +  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    2.22 +  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    2.23 +  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    2.24 +  TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    2.25 +  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    2.26 +-->
    2.27 +<html>
    2.28 +  <head>
    2.29 +    <meta charset="utf-8"></meta>
    2.30 +    <title>Booket</title>
    2.31 +    <link rel="stylesheet" type="text/css" href="booket.css"></link>
    2.32 +    <script src="booket.js"></script>
    2.33 +  </head>
    2.34 +  <body>
    2.35 +  <header>
    2.36 +    <h1>Booket</h1>
    2.37 +    <p>Version 1</p>
    2.38 +  </header>
    2.39 +
    2.40 +  <template id="tag-input-template">
    2.41 +    <li><label>Tag <input type="text" name="tag" pattern="[^,;]*"
    2.42 +    size="20" placeholder="tag"></input>
    2.43 +    </label></li>
    2.44 +  </template>
    2.45 +
    2.46 +  <template id="bookmark-editor-template">
    2.47 +    <form class="bookmark-editor-form">
    2.48 +      <fieldset>
    2.49 +        <legend></legend>
    2.50 +        <input type="hidden" name="original-url"></input>
    2.51 +        <label>URL <input type="url" required="required"
    2.52 +        name="url" size="60" placeholder="http://example.com/"></input></label>
    2.53 +        <label>Title <input type="text" name="title" size="60"
    2.54 +        placeholder="A Title"></input></label>
    2.55 +        <div>
    2.56 +          <ul class="tag-input-list"></ul>
    2.57 +          <button type="button" name="more-tags">Add more tags</button>
    2.58 +        </div>
    2.59 +        <button type="reset" name="cancel">Cancel</button><button type="submit"
    2.60 +        name="save-bookmark">Save</button>
    2.61 +      </fieldset>
    2.62 +    </form>
    2.63 +  </template>
    2.64 +
    2.65 +  <section id="actions">
    2.66 +    <h2>Actions</h2>
    2.67 +    <aside id="keyboard-shortcuts">
    2.68 +      <h3>Keyboard Shortcuts</h3>
    2.69 +      <dl>
    2.70 +        <dt><kbd>Prefix</kbd>+<kbd>i</kbd></dt>
    2.71 +        <dd>Select bookmark file to load</dd>
    2.72 +        <dt><kbd>Prefix</kbd>+<kbd>l</kbd></dt>
    2.73 +        <dd>Load selected bookmark file</dd>
    2.74 +        <dt><kbd>Prefix</kbd>+<kbd>s</kbd></dt>
    2.75 +        <dd>Save bookmark file</dd>
    2.76 +        <dt><kbd>Prefix</kbd>+<kbd>a</kbd></dt>
    2.77 +        <dd>Focus bookmark editor</dd>
    2.78 +        <dt><kbd>Prefix</kbd>+<kbd>f</kbd></dt>
    2.79 +        <dd>Focus search field</dd>
    2.80 +      </dl>
    2.81 +    </aside>
    2.82 +    <form id="load-form">
    2.83 +      <fieldset>
    2.84 +        <legend>Load Bookmarks</legend>
    2.85 +        <label accesskey="i">File <input type="file" accept="application/json"
    2.86 +        required="required" name="file"></input></label>
    2.87 +        <button type="submit" name="load-file" accesskey="l">Load</button>
    2.88 +      </fieldset>
    2.89 +    </form>
    2.90 +
    2.91 +    <form id="save-form">
    2.92 +      <fieldset>
    2.93 +        <legend>Save Bookmarks</legend>
    2.94 +        <a href="#" id="save-link" hidden="hidden"
    2.95 +        download="bookmarks.json"></a>
    2.96 +        <button type="submit" name="save-file"
    2.97 +        accesskey="s">Save&#8230;</button>
    2.98 +      </fieldset>
    2.99 +    </form>
   2.100 +  </section>
   2.101 +
   2.102 +  <main>
   2.103 +    <section id="bookmarks">
   2.104 +      <h2>Bookmarks</h2>
   2.105 +
   2.106 +      <aside id="tags">
   2.107 +        <h3>Tags</h3>
   2.108 +
   2.109 +        <ul class="tag-list">
   2.110 +          <template id="tag-template">
   2.111 +            <li><button type="button" name="set-tag"></button><span
   2.112 +            class="tag-count"></span><button type="button"
   2.113 +            name="toggle-tag"></button></li>
   2.114 +          </template>
   2.115 +        </ul>
   2.116 +      </aside>
   2.117 +
   2.118 +      <aside id="search">
   2.119 +        <h3>Search</h3>
   2.120 +
   2.121 +        <form id="search-form">
   2.122 +          <input type="search" name="search-term" size="20" placeholder="Search"
   2.123 +          accesskey="f"></input>
   2.124 +          <button type="submit" name="search">Search</button><button
   2.125 +          type="reset" name="clear">Clear</button>
   2.126 +        </form>
   2.127 +      </aside>
   2.128 +
   2.129 +      <p id="bookmark-message"></p>
   2.130 +
   2.131 +      <ul id="bookmark-list">
   2.132 +        <template id="bookmark-tag-template">
   2.133 +          <li><button type="button" name="set-tag"></button><button
   2.134 +          type="button" name="toggle-tag"></button></li>
   2.135 +        </template>
   2.136 +        <template id="bookmark-template">
   2.137 +          <li>
   2.138 +            <a class="bookmark-link" target="_blank"></a><ul
   2.139 +            class="tag-list"></ul>
   2.140 +            <div class="bookmark-actions">
   2.141 +              <button type="button" name="edit-bookmark">Edit</button><button
   2.142 +              type="button" name="delete-bookmark">Delete</button>
   2.143 +            </div>
   2.144 +          </li>
   2.145 +        </template>
   2.146 +      </ul>
   2.147 +    </section>
   2.148 +  </main>
   2.149 +
   2.150 +  <footer><address>Copyright 2014
   2.151 +    <a href="mailto:guido+booket@berhoerster.name"
   2.152 +    title="guido+booket@berhoerster.name">Guido
   2.153 +    Berhörster</a></address>
   2.154 +  </footer>
   2.155 +  </body>
   2.156 +</html>
     3.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     3.2 +++ b/booket.js	Sat Sep 06 18:18:29 2014 +0200
     3.3 @@ -0,0 +1,1379 @@
     3.4 +/*
     3.5 + * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
     3.6 + *
     3.7 + * Permission is hereby granted, free of charge, to any person obtaining
     3.8 + * a copy of this software and associated documentation files (the
     3.9 + * "Software"), to deal in the Software without restriction, including
    3.10 + * without limitation the rights to use, copy, modify, merge, publish,
    3.11 + * distribute, sublicense, and/or sell copies of the Software, and to
    3.12 + * permit persons to whom the Software is furnished to do so, subject to
    3.13 + * the following conditions:
    3.14 + *
    3.15 + * The above copyright notice and this permission notice shall be included
    3.16 + * in all copies or substantial portions of the Software.
    3.17 + *
    3.18 + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    3.19 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    3.20 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
    3.21 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    3.22 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
    3.23 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
    3.24 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    3.25 + */
    3.26 +
    3.27 +(function () {
    3.28 +'use strict';
    3.29 +
    3.30 +/*
    3.31 + * utility stuff
    3.32 + */
    3.33 +
    3.34 +function isNumber(number) {
    3.35 +    return (Object.prototype.toString.call(number) === '[object Number]');
    3.36 +}
    3.37 +
    3.38 +function isString(number) {
    3.39 +    return (Object.prototype.toString.call(number) === '[object String]');
    3.40 +}
    3.41 +
    3.42 +function arrayEqual(array1, array2) {
    3.43 +    if (!Array.isArray(array1)) {
    3.44 +        throw new TypeError(typeof array1 + ' is not an array');
    3.45 +    } else if (!Array.isArray(array2)) {
    3.46 +        throw new TypeError(typeof array2 + ' is not an array');
    3.47 +    }
    3.48 +
    3.49 +    if (array1.length !== array2.length) {
    3.50 +        return false;
    3.51 +    } else if (array1.length === 0 && array2.length === 0) {
    3.52 +        return true;
    3.53 +    }
    3.54 +
    3.55 +    return array1.slice().sort().every(function (value, i) {
    3.56 +        return value === array2[i];
    3.57 +    });
    3.58 +}
    3.59 +
    3.60 +function parseHash(url) {
    3.61 +    var hashData;
    3.62 +    var pos;
    3.63 +    var hash;
    3.64 +    var hashParts;
    3.65 +    var key;
    3.66 +    var value;
    3.67 +    var i;
    3.68 +
    3.69 +    hashData = new StringMap();
    3.70 +    pos = url.indexOf('#');
    3.71 +    hash = (pos > -1) ? url.substr(pos + 1) : '';
    3.72 +    // hash parts are seperated by a ';'
    3.73 +    hashParts = hash.split(';');
    3.74 +    for (i = 0; i < hashParts.length; i++) {
    3.75 +        // key and value pairs are seperated by a '=', an empty value will
    3.76 +        // cause the key to be ignored
    3.77 +        pos = hashParts[i].indexOf('=');
    3.78 +        if (pos > -1) {
    3.79 +            key = decodeURIComponent(hashParts[i].substr(0, pos));
    3.80 +            value = decodeURIComponent(hashParts[i].substr(pos + 1));
    3.81 +            hashData.set(key, value);
    3.82 +        }
    3.83 +    }
    3.84 +
    3.85 +    return hashData;
    3.86 +}
    3.87 +
    3.88 +function serializeHash(url, hashData) {
    3.89 +    var hashParts = [];
    3.90 +    var pos;
    3.91 +
    3.92 +    pos = url.indexOf('#');
    3.93 +    if (pos > -1) {
    3.94 +        url = url.substr(0, pos);
    3.95 +    }
    3.96 +
    3.97 +    hashData.forEach(function (value, key) {
    3.98 +        if (value !== '') {
    3.99 +            hashParts.push(encodeURIComponent(key) + '=' +
   3.100 +                encodeURIComponent(value));
   3.101 +        }
   3.102 +    });
   3.103 +
   3.104 +    // only append a '#' if there are any hash parts
   3.105 +    return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : '');
   3.106 +}
   3.107 +
   3.108 +function getAncestorElementDatasetItem(node, item) {
   3.109 +    while ((node = node.parentNode) !== null) {
   3.110 +        if (node.dataset && node.dataset[item] !== undefined) {
   3.111 +            return node.dataset[item];
   3.112 +        }
   3.113 +    }
   3.114 +
   3.115 +    return undefined;
   3.116 +}
   3.117 +
   3.118 +// for use with Node.querySelector() and Node.querySelectorAll()
   3.119 +function createDatasetSelector(name, value) {
   3.120 +    return  '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]';
   3.121 +}
   3.122 +
   3.123 +function extend(targetObject, sourceObject) {
   3.124 +    var propertyName;
   3.125 +
   3.126 +    for (propertyName in sourceObject.prototype) {
   3.127 +        if (!Object.prototype.hasOwnProperty.call(targetObject.prototype,
   3.128 +                propertyName)) {
   3.129 +            targetObject.prototype[propertyName] =
   3.130 +                sourceObject.prototype[propertyName];
   3.131 +        }
   3.132 +    }
   3.133 +}
   3.134 +
   3.135 +
   3.136 +var ObservableMixin = function () {
   3.137 +    this._eventsObservers = {};
   3.138 +};
   3.139 +
   3.140 +ObservableMixin.prototype.addObserver = function (eventName, observer) {
   3.141 +    var i;
   3.142 +
   3.143 +    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
   3.144 +            eventName)) {
   3.145 +        this._eventsObservers[eventName] = [];
   3.146 +    }
   3.147 +
   3.148 +    // prevent observers for an event from being called more than once
   3.149 +    for (i = 0; i < this._eventsObservers[eventName].length; i++) {
   3.150 +        if (this._eventsObservers[eventName][i] === observer) {
   3.151 +            return;
   3.152 +        }
   3.153 +    }
   3.154 +    this._eventsObservers[eventName].push(observer);
   3.155 +};
   3.156 +
   3.157 +ObservableMixin.prototype.deleteObserver = function (eventName, observer) {
   3.158 +    var i = 0;
   3.159 +
   3.160 +    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
   3.161 +            eventName)) {
   3.162 +        return;
   3.163 +    }
   3.164 +
   3.165 +    while (i < this._eventsObservers[eventName].length) {
   3.166 +        if (this._eventsObservers[eventName][i] === observer) {
   3.167 +            this._eventsObservers[eventName].splice(i, 1);
   3.168 +        }
   3.169 +    }
   3.170 +};
   3.171 +
   3.172 +ObservableMixin.prototype.notify = function (eventName) {
   3.173 +    var origArguments;
   3.174 +
   3.175 +    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
   3.176 +            eventName)) {
   3.177 +        return;
   3.178 +    }
   3.179 +
   3.180 +    origArguments = Array.prototype.slice.call(arguments, 1);
   3.181 +    this._eventsObservers[eventName].forEach(function (observer, i) {
   3.182 +        // call the observer function and pass on any additional arguments
   3.183 +        observer.apply(undefined, origArguments);
   3.184 +    });
   3.185 +};
   3.186 +
   3.187 +
   3.188 +var StringMap = function (iter) {
   3.189 +    this._stringMap = Object.create(null);
   3.190 +
   3.191 +    if (iter !== undefined) {
   3.192 +        if (Array.isArray(iter)) {
   3.193 +            iter.forEach(function (pair) {
   3.194 +                if (Array.isArray(pair)) {
   3.195 +                    this.set(pair[0], pair[1]);
   3.196 +                } else {
   3.197 +                    throw new TypeError(typeof pair + ' is not an array');
   3.198 +                }
   3.199 +            }, this);
   3.200 +        } else {
   3.201 +            throw new TypeError(typeof iter + ' is not iterable');
   3.202 +        }
   3.203 +    }
   3.204 +};
   3.205 +
   3.206 +Object.defineProperty(StringMap.prototype, 'size', {
   3.207 +    get: function () {
   3.208 +        var size = 0;
   3.209 +        var key;
   3.210 +
   3.211 +        for (key in this._stringMap) {
   3.212 +            if (key.charAt(0) === '@') {
   3.213 +                size++;
   3.214 +            }
   3.215 +        }
   3.216 +
   3.217 +        return size;
   3.218 +    }
   3.219 +});
   3.220 +
   3.221 +StringMap.prototype.set = function (key, value) {
   3.222 +    this._stringMap['@' + key] = value;
   3.223 +
   3.224 +    return this;
   3.225 +};
   3.226 +
   3.227 +StringMap.prototype.get = function (key) {
   3.228 +    return this._stringMap['@' + key];
   3.229 +};
   3.230 +
   3.231 +StringMap.prototype.has = function (key) {
   3.232 +    return (('@' + key) in this._stringMap);
   3.233 +};
   3.234 +
   3.235 +StringMap.prototype.delete = function (key) {
   3.236 +    if (this.has(key)) {
   3.237 +        delete this._stringMap['@' + key];
   3.238 +
   3.239 +        return true;
   3.240 +    }
   3.241 +
   3.242 +    return false;
   3.243 +};
   3.244 +
   3.245 +StringMap.prototype.forEach = function (callbackFn, thisArg) {
   3.246 +    Object.keys(this._stringMap).forEach(function (key) {
   3.247 +        if (key.charAt(0) === '@') {
   3.248 +            key = key.substr(1);
   3.249 +            callbackFn.call(thisArg, this.get(key), key, this);
   3.250 +        }
   3.251 +    }, this);
   3.252 +};
   3.253 +
   3.254 +StringMap.prototype.keys = function () {
   3.255 +    return Object.keys(this._stringMap).map(function (key) {
   3.256 +        return key.substr(1);
   3.257 +    });
   3.258 +};
   3.259 +
   3.260 +StringMap.prototype.toJSON = function () {
   3.261 +    return this._stringMap;
   3.262 +};
   3.263 +
   3.264 +StringMap.prototype.toString = function () {
   3.265 +    return Object.prototype.toString.call(this._stringMap);
   3.266 +};
   3.267 +
   3.268 +
   3.269 +var StringSet = function (iter) {
   3.270 +    this._stringArray = [];
   3.271 +    this._stringMap = new StringMap();
   3.272 +    if (iter !== undefined) {
   3.273 +        if (Array.isArray(iter) || iter instanceof StringSet) {
   3.274 +            iter.forEach(function (string) {
   3.275 +                this.add(string);
   3.276 +            }, this);
   3.277 +        } else {
   3.278 +            throw new TypeError(typeof iter + ' is not iterable');
   3.279 +        }
   3.280 +    }
   3.281 +};
   3.282 +
   3.283 +Object.defineProperty(StringSet.prototype, 'size', {
   3.284 +    get: function () {
   3.285 +        return this._stringArray.length;
   3.286 +    }
   3.287 +});
   3.288 +
   3.289 +StringSet.prototype.has = function (string) {
   3.290 +    return this._stringMap.has(string);
   3.291 +};
   3.292 +
   3.293 +StringSet.prototype.add = function (string) {
   3.294 +    if (!this.has(string)) {
   3.295 +        this._stringMap.set(string, true);
   3.296 +        this._stringArray.push(string);
   3.297 +    }
   3.298 +    return this;
   3.299 +};
   3.300 +
   3.301 +StringSet.prototype.delete = function (string) {
   3.302 +    if (this.has(string)) {
   3.303 +        this._stringMap.delete(string);
   3.304 +        this._stringArray.splice(this._stringArray.indexOf(string), 1);
   3.305 +        return true;
   3.306 +    }
   3.307 +    return false;
   3.308 +};
   3.309 +
   3.310 +StringSet.prototype.forEach = function (callbackFn, thisArg) {
   3.311 +    this._stringArray.forEach(function (key) {
   3.312 +        callbackFn.call(thisArg, key, key, this);
   3.313 +    });
   3.314 +};
   3.315 +
   3.316 +StringSet.prototype.keys = function () {
   3.317 +    return this._stringArray.slice();
   3.318 +};
   3.319 +
   3.320 +StringSet.prototype.values = function () {
   3.321 +    return this._stringArray.slice();
   3.322 +};
   3.323 +
   3.324 +StringSet.prototype.clear = function () {
   3.325 +    this._stringMap = new StringMap();
   3.326 +    this._stringArray = [];
   3.327 +};
   3.328 +
   3.329 +StringSet.prototype.toJSON = function () {
   3.330 +    return this._stringArray;
   3.331 +};
   3.332 +
   3.333 +StringSet.prototype.toString = function () {
   3.334 +    return this._stringArray.toString();
   3.335 +};
   3.336 +
   3.337 +
   3.338 +/*
   3.339 + * model
   3.340 + */
   3.341 +
   3.342 +var Bookmark = function (url, title, tags, ctime, mtime) {
   3.343 +    var parsedTime;
   3.344 +
   3.345 +    if (!isString(url)) {
   3.346 +        throw new TypeError(typeof url + ' is not a string');
   3.347 +    }
   3.348 +    this.url = url;
   3.349 +
   3.350 +    this.title = (isString(title) && title !== '') ? title : url;
   3.351 +
   3.352 +    if (Array.isArray(tags)) {
   3.353 +        // remove duplicates, non-string or empty tags and tags containing
   3.354 +        // commas
   3.355 +        this.tags = new StringSet(tags.filter(function (tag) {
   3.356 +            return (isString(tag) && tag !== '' && tag.indexOf(',') === -1);
   3.357 +        }).sort());
   3.358 +    } else {
   3.359 +        this.tags = new StringSet();
   3.360 +    }
   3.361 +
   3.362 +    if (isNumber(ctime) || isString(ctime)) {
   3.363 +        parsedTime = new Date(ctime);
   3.364 +        this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date();
   3.365 +    } else {
   3.366 +        this.ctime = new Date();
   3.367 +    }
   3.368 +
   3.369 +    if (isNumber(mtime) || isString(mtime)) {
   3.370 +        parsedTime = new Date(mtime);
   3.371 +        // modification time must be greater than creation time
   3.372 +        this.mtime = (!isNaN(parsedTime.getTime()) ||
   3.373 +            parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime);
   3.374 +    } else {
   3.375 +        this.mtime = new Date(this.ctime);
   3.376 +    }
   3.377 +};
   3.378 +
   3.379 +
   3.380 +var BookmarkModel = function () {
   3.381 +    ObservableMixin.call(this);
   3.382 +
   3.383 +    this.unsavedChanges = false;
   3.384 +    this._bookmarks = new StringMap();
   3.385 +    this._tagCount = new StringMap();
   3.386 +    this._filterTags = new StringSet();
   3.387 +    this._searchTerm = '';
   3.388 +    this._filteredBookmarks = new StringSet();
   3.389 +    this._searchedBookmarks = new StringSet();
   3.390 +};
   3.391 +
   3.392 +extend(BookmarkModel, ObservableMixin);
   3.393 +
   3.394 +BookmarkModel.prototype.add = function (bookmarks) {
   3.395 +    var addedBookmarkUrls = new StringSet();
   3.396 +
   3.397 +    // argument can be a single bookmark or a list of bookmarks
   3.398 +    if (!Array.isArray(bookmarks)) {
   3.399 +        bookmarks = [bookmarks];
   3.400 +    }
   3.401 +
   3.402 +    bookmarks.forEach(function (bookmark) {
   3.403 +        // delete any existing bookmark for the given URL before adding the new
   3.404 +        // one in order to update views
   3.405 +        this.delete(bookmark.url);
   3.406 +        this._bookmarks.set(bookmark.url, bookmark);
   3.407 +        addedBookmarkUrls.add(bookmark.url);
   3.408 +        this.unsavedChanges = true;
   3.409 +        this.notify('bookmark-added', bookmark);
   3.410 +
   3.411 +        // update tag count
   3.412 +        bookmark.tags.forEach(function (tag) {
   3.413 +            var tagCount;
   3.414 +
   3.415 +            if (this._tagCount.has(tag)) {
   3.416 +                tagCount = this._tagCount.get(tag) + 1;
   3.417 +                this._tagCount.set(tag, tagCount);
   3.418 +                this.notify('tag-count-changed', tag, tagCount);
   3.419 +            } else {
   3.420 +                this._tagCount.set(tag, 1);
   3.421 +                this.notify('tag-added', tag);
   3.422 +            }
   3.423 +        }, this);
   3.424 +    }, this);
   3.425 +
   3.426 +    // apply tag filter and search added bookmarks
   3.427 +    this.updateFilteredSearchedBookmarks(addedBookmarkUrls);
   3.428 +    this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.429 +        this._filterTags, this._searchTerm);
   3.430 +};
   3.431 +
   3.432 +BookmarkModel.prototype.has = function (url) {
   3.433 +    return this._bookmarks.has(url);
   3.434 +};
   3.435 +
   3.436 +BookmarkModel.prototype.get = function (url) {
   3.437 +    return this._bookmarks.get(url);
   3.438 +};
   3.439 +
   3.440 +BookmarkModel.prototype.delete = function (urls) {
   3.441 +    var needUpdateFilterTags = false;
   3.442 +
   3.443 +    // argument can be a single bookmark or a list of bookmarks
   3.444 +    if (!Array.isArray(urls)) {
   3.445 +        urls = [urls];
   3.446 +    }
   3.447 +
   3.448 +    urls.forEach(function (url) {
   3.449 +        var bookmark;
   3.450 +        var tagCount;
   3.451 +
   3.452 +        if (this._bookmarks.has(url)) {
   3.453 +            bookmark = this._bookmarks.get(url);
   3.454 +            this._bookmarks.delete(url);
   3.455 +            this.unsavedChanges = true;
   3.456 +            this.notify('bookmark-deleted', bookmark.url);
   3.457 +
   3.458 +            // update tag count
   3.459 +            bookmark.tags.forEach(function (tag) {
   3.460 +                if (this._tagCount.has(tag)) {
   3.461 +                    tagCount = this._tagCount.get(tag);
   3.462 +                    if (tagCount > 1) {
   3.463 +                        tagCount--;
   3.464 +                        this._tagCount.set(tag, tagCount);
   3.465 +                        this.notify('tag-count-changed', tag, tagCount);
   3.466 +                    } else {
   3.467 +                        this._tagCount.delete(tag);
   3.468 +                        this.notify('tag-deleted', tag);
   3.469 +
   3.470 +                        if (this._filterTags.has(tag)) {
   3.471 +                            this._filterTags.delete(tag);
   3.472 +                            needUpdateFilterTags = true;
   3.473 +                        }
   3.474 +                    }
   3.475 +                }
   3.476 +            }, this);
   3.477 +
   3.478 +            // update filtered and searched bookmarks
   3.479 +            if (this._filteredBookmarks.has(url)) {
   3.480 +                this._filteredBookmarks.delete(url);
   3.481 +                if (this._searchedBookmarks.has(url)) {
   3.482 +                    this._searchedBookmarks.delete(url);
   3.483 +                }
   3.484 +            }
   3.485 +        }
   3.486 +    }, this);
   3.487 +
   3.488 +    if (needUpdateFilterTags) {
   3.489 +        this.updateFilteredSearchedBookmarks();
   3.490 +        this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.491 +            this._filterTags, this._searchTerm);
   3.492 +    }
   3.493 +};
   3.494 +
   3.495 +BookmarkModel.prototype.forEach =  function (callbackFn, thisArg) {
   3.496 +    this._bookmarks.keys().forEach(function (key) {
   3.497 +        callbackFn.call(thisArg, this._bookmarks.get(key), key, this);
   3.498 +    }, this);
   3.499 +};
   3.500 +
   3.501 +BookmarkModel.prototype.hasTag = function (tag) {
   3.502 +    return this._tagCount.has(tag);
   3.503 +};
   3.504 +
   3.505 +BookmarkModel.prototype.getTagCount = function (tag) {
   3.506 +    return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined;
   3.507 +};
   3.508 +
   3.509 +BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) {
   3.510 +    var searchUrls;
   3.511 +
   3.512 +    // additive search if urlsSubset is given
   3.513 +    if (urlsSubset !== undefined) {
   3.514 +        searchUrls = urlsSubset;
   3.515 +    } else {
   3.516 +        this._searchedBookmarks = new StringSet();
   3.517 +
   3.518 +        searchUrls = this._filteredBookmarks.values();
   3.519 +    }
   3.520 +
   3.521 +    // search for the search term in title and URL
   3.522 +    searchUrls.forEach(function (url) {
   3.523 +        var bookmark;
   3.524 +
   3.525 +        bookmark = this.get(url);
   3.526 +        if (this._searchTerm === '' ||
   3.527 +                bookmark.title.indexOf(this._searchTerm) !== -1 ||
   3.528 +                bookmark.url.indexOf(this._searchTerm) !== -1) {
   3.529 +            this._searchedBookmarks.add(url);
   3.530 +        }
   3.531 +    }, this);
   3.532 +};
   3.533 +
   3.534 +BookmarkModel.prototype.updateFilteredSearchedBookmarks =
   3.535 +        function (urlsSubset) {
   3.536 +    var filterUrls;
   3.537 +    var searchUrls;
   3.538 +
   3.539 +    // additive filtering if urlsSubset is given
   3.540 +    if (urlsSubset !== undefined) {
   3.541 +        filterUrls = urlsSubset;
   3.542 +        searchUrls = [];
   3.543 +    } else {
   3.544 +        this._filteredBookmarks = new StringSet();
   3.545 +
   3.546 +        filterUrls = this._bookmarks.keys();
   3.547 +        searchUrls = undefined;
   3.548 +    }
   3.549 +
   3.550 +    // apply tag filter
   3.551 +    filterUrls.forEach(function (url) {
   3.552 +        var bookmark;
   3.553 +        var matchingTagCount = 0;
   3.554 +
   3.555 +        bookmark = this.get(url);
   3.556 +
   3.557 +        bookmark.tags.forEach(function (tag) {
   3.558 +            if (this._filterTags.has(tag)) {
   3.559 +                matchingTagCount++;
   3.560 +            }
   3.561 +        }, this);
   3.562 +
   3.563 +        if (matchingTagCount === this._filterTags.size) {
   3.564 +            this._filteredBookmarks.add(url);
   3.565 +            if (urlsSubset !== undefined) {
   3.566 +                searchUrls.push(url);
   3.567 +            }
   3.568 +        }
   3.569 +    }, this);
   3.570 +
   3.571 +    // search the filter results
   3.572 +    this.updateSearchedBookmarks(searchUrls);
   3.573 +};
   3.574 +
   3.575 +BookmarkModel.prototype.toggleFilterTag = function (tag) {
   3.576 +    if (this._filterTags.has(tag)) {
   3.577 +        this._filterTags.delete(tag);
   3.578 +    } else {
   3.579 +        this._filterTags.add(tag);
   3.580 +    }
   3.581 +    this.updateFilteredSearchedBookmarks();
   3.582 +    this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.583 +        this._filterTags, this._searchTerm);
   3.584 +};
   3.585 +
   3.586 +BookmarkModel.prototype.setFilterTags = function (filterTags) {
   3.587 +    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
   3.588 +        this._filterTags = new StringSet(filterTags);
   3.589 +        this.updateFilteredSearchedBookmarks();
   3.590 +        this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.591 +            this._filterTags, this._searchTerm);
   3.592 +    }
   3.593 +};
   3.594 +
   3.595 +BookmarkModel.prototype.setSearchTerm = function (searchTerm) {
   3.596 +    if (searchTerm !== this._searchTerm) {
   3.597 +        this._searchTerm = searchTerm;
   3.598 +        this.updateSearchedBookmarks();
   3.599 +        this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.600 +            this._filterTags, this._searchTerm);
   3.601 +    }
   3.602 +};
   3.603 +
   3.604 +BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags,
   3.605 +        searchTerm) {
   3.606 +    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
   3.607 +        this._filterTags = new StringSet(filterTags);
   3.608 +        this._searchTerm = searchTerm;
   3.609 +        this.updateFilteredSearchedBookmarks();
   3.610 +        this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.611 +            this._filterTags, this._searchTerm);
   3.612 +    } else if (searchTerm !== this._searchTerm) {
   3.613 +        this._searchTerm = searchTerm;
   3.614 +        this.updateSearchedBookmarks();
   3.615 +        this.notify('filter-tags-search-changed', this._searchedBookmarks,
   3.616 +            this._filterTags, this._searchTerm);
   3.617 +    }
   3.618 +};
   3.619 +
   3.620 +BookmarkModel.prototype.parseLoadedBookmarks = function (data) {
   3.621 +    var parsedData;
   3.622 +    var bookmarks = [];
   3.623 +
   3.624 +    try {
   3.625 +        parsedData = JSON.parse(data);
   3.626 +    } catch (e) {
   3.627 +        this.notify('load-file-error', e.message);
   3.628 +        return;
   3.629 +    }
   3.630 +
   3.631 +    if (!Array.isArray(parsedData.bookmarks)) {
   3.632 +        this.notify('parse-file-error',
   3.633 +            'This file does not contain bookmarks.');
   3.634 +        return;
   3.635 +    }
   3.636 +
   3.637 +    // create a temporary list of valid bookmarks
   3.638 +    parsedData.bookmarks.forEach(function (bookmark) {
   3.639 +        if (isString(bookmark.url) && bookmark.url !== '') {
   3.640 +            bookmarks.push(new Bookmark(bookmark.url, bookmark.title,
   3.641 +                bookmark.tags, bookmark.ctime, bookmark.mtime));
   3.642 +        }
   3.643 +    }, this);
   3.644 +
   3.645 +    // add each bookmark to the model ordered by the last modification time
   3.646 +    this.add(bookmarks.sort(function (bookmark1, bookmark2) {
   3.647 +        return bookmark1.ctime - bookmark2.ctime;
   3.648 +    }));
   3.649 +    this.unsavedChanges = false;
   3.650 +};
   3.651 +
   3.652 +BookmarkModel.prototype.loadFile = function (bookmarkFile) {
   3.653 +    var bookmarkFileReader;
   3.654 +
   3.655 +    // delete all existing bookmarks first
   3.656 +    this.delete(this._bookmarks.keys());
   3.657 +    this.unsavedChanges = false;
   3.658 +
   3.659 +    bookmarkFileReader = new FileReader();
   3.660 +    bookmarkFileReader.addEventListener('error', this);
   3.661 +    bookmarkFileReader.addEventListener('load', this);
   3.662 +    bookmarkFileReader.readAsText(bookmarkFile);
   3.663 +};
   3.664 +
   3.665 +BookmarkModel.prototype.saveFile = function () {
   3.666 +    var jsonBlob;
   3.667 +    var bookmarkData = {
   3.668 +        'bookmarks': []
   3.669 +    };
   3.670 +
   3.671 +    this._bookmarks.forEach(function (bookmark) {
   3.672 +        bookmarkData.bookmarks.push(bookmark);
   3.673 +    }, this);
   3.674 +
   3.675 +    jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type:
   3.676 +        'application/json'});
   3.677 +    this.notify('save-file', jsonBlob);
   3.678 +    this.unsavedChanges = false;
   3.679 +};
   3.680 +
   3.681 +BookmarkModel.prototype.handleEvent = function (e) {
   3.682 +    if (e.type === 'load') {
   3.683 +        this.parseLoadedBookmarks(e.target.result);
   3.684 +    } else if (e.type === 'error') {
   3.685 +        this.notify('load-file-error', e.target.error.message);
   3.686 +    }
   3.687 +};
   3.688 +
   3.689 +
   3.690 +/*
   3.691 + * view
   3.692 + */
   3.693 +
   3.694 +var TagView = function () {
   3.695 +    ObservableMixin.call(this);
   3.696 +
   3.697 +    this.tagListElement = document.querySelector('#tags ul.tag-list');
   3.698 +    this.tagListElement.addEventListener('click', this);
   3.699 +
   3.700 +    this.tagTemplate = document.querySelector('#tag-template');
   3.701 +};
   3.702 +
   3.703 +extend(TagView, ObservableMixin);
   3.704 +
   3.705 +TagView.prototype.onTagAdded = function (tag) {
   3.706 +    var newNode;
   3.707 +    var tagElement;
   3.708 +    var setTagButton;
   3.709 +    var toggleTagButton;
   3.710 +    var tagElements;
   3.711 +    var i;
   3.712 +    var referenceTag = '';
   3.713 +    var referenceNode;
   3.714 +
   3.715 +    // create new tag element from template
   3.716 +    newNode = document.importNode(this.tagTemplate.content, true);
   3.717 +
   3.718 +    tagElement = newNode.querySelector('li');
   3.719 +    tagElement.dataset.tag = tag;
   3.720 +
   3.721 +    setTagButton = tagElement.querySelector('button[name="set-tag"]');
   3.722 +    setTagButton.textContent = tag;
   3.723 +    setTagButton.title = 'Set filter to "' + tag + '"';
   3.724 +
   3.725 +    toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]');
   3.726 +    toggleTagButton.textContent = '+';
   3.727 +    toggleTagButton.title = 'Add "' + tag + '" to filter';
   3.728 +
   3.729 +    // maintain alphabetical order when inserting the tag element
   3.730 +    tagElements = this.tagListElement.querySelectorAll('li');
   3.731 +    for (i = 0; i < tagElements.length; i ++) {
   3.732 +        if (tagElements[i].dataset.tag > referenceTag &&
   3.733 +                tagElements[i].dataset.tag < tag) {
   3.734 +            referenceTag = tagElements[i].dataset.tag;
   3.735 +            referenceNode = tagElements[i];
   3.736 +        }
   3.737 +    }
   3.738 +    this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ?
   3.739 +        referenceNode.nextSibling : this.tagListElement.firstChild);
   3.740 +
   3.741 +    // initialize tag count
   3.742 +    this.onTagCountChanged(tag, 1);
   3.743 +};
   3.744 +
   3.745 +TagView.prototype.onTagCountChanged = function (tag, tagCount) {
   3.746 +    this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) +
   3.747 +        ' .tag-count').textContent = '(' + tagCount + ')';
   3.748 +};
   3.749 +
   3.750 +TagView.prototype.onTagDeleted = function (tag) {
   3.751 +    var tagElement;
   3.752 +
   3.753 +    tagElement = this.tagListElement.querySelector('li' +
   3.754 +        createDatasetSelector('tag', tag));
   3.755 +    if (tagElement !== null) {
   3.756 +        tagElement.parentNode.removeChild(tagElement);
   3.757 +    }
   3.758 +};
   3.759 +
   3.760 +TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
   3.761 +        newFilterTags, newSearchTerm) {
   3.762 +    var tagElements;
   3.763 +    var i;
   3.764 +    var tag;
   3.765 +    var toggleTagButton;
   3.766 +
   3.767 +    tagElements = this.tagListElement.querySelectorAll('li');
   3.768 +    for (i = 0; i < tagElements.length; i++) {
   3.769 +        tag = tagElements[i].dataset.tag;
   3.770 +        toggleTagButton =
   3.771 +            tagElements[i].querySelector('button[name="toggle-tag"]');
   3.772 +        if (newFilterTags.has(tag)) {
   3.773 +            tagElements[i].classList.add('active-filter-tag');
   3.774 +            toggleTagButton.textContent = '\u2212';
   3.775 +            toggleTagButton.title = 'Remove "' + tag + '" from filter';
   3.776 +        } else {
   3.777 +            tagElements[i].classList.remove('active-filter-tag');
   3.778 +            toggleTagButton.textContent = '+';
   3.779 +            toggleTagButton.title = 'Add "' + tag + '" to filter';
   3.780 +        }
   3.781 +    }
   3.782 +};
   3.783 +
   3.784 +TagView.prototype.handleEvent = function (e) {
   3.785 +    if (e.type === 'click' && (e.target.name === 'set-tag' ||
   3.786 +            e.target.name === 'toggle-tag')) {
   3.787 +        e.target.blur();
   3.788 +
   3.789 +        this.notify(e.target.name, getAncestorElementDatasetItem(e.target,
   3.790 +            'tag'));
   3.791 +    }
   3.792 +};
   3.793 +
   3.794 +
   3.795 +var ActionsView = function () {
   3.796 +    var saveFormElement;
   3.797 +    var loadFormElement;
   3.798 +    var newNode;
   3.799 +    var editorFormElement;
   3.800 +
   3.801 +    ObservableMixin.call(this);
   3.802 +
   3.803 +    this.tagInputTemplate = document.querySelector('#tag-input-template');
   3.804 +    saveFormElement = document.querySelector('form#save-form');
   3.805 +    saveFormElement.addEventListener('submit', this);
   3.806 +
   3.807 +    this.saveLinkElement = saveFormElement.querySelector('a#save-link');
   3.808 +
   3.809 +    loadFormElement = document.querySelector('form#load-form');
   3.810 +    loadFormElement.addEventListener('submit', this);
   3.811 +
   3.812 +    // create new editor form from template
   3.813 +    newNode = document.importNode(
   3.814 +        document.querySelector('#bookmark-editor-template').content, true);
   3.815 +
   3.816 +    editorFormElement = newNode.querySelector('form.bookmark-editor-form');
   3.817 +    editorFormElement.querySelector('legend').textContent = 'Add Bookmark';
   3.818 +    editorFormElement.querySelector('input:not([type="hidden"])').accessKey =
   3.819 +        'a';
   3.820 +    editorFormElement.addEventListener('click', this);
   3.821 +    editorFormElement.addEventListener('submit', this);
   3.822 +    editorFormElement.addEventListener('reset', this);
   3.823 +
   3.824 +    this.editTagListElement =
   3.825 +        editorFormElement.querySelector('ul.tag-input-list');
   3.826 +    this.editTagListElement.appendChild(this.createTagInputElement(''));
   3.827 +
   3.828 +    saveFormElement.parentNode.insertBefore(newNode,
   3.829 +        saveFormElement.nextSibling);
   3.830 +};
   3.831 +
   3.832 +extend(ActionsView, ObservableMixin);
   3.833 +
   3.834 +ActionsView.prototype.createTagInputElement = function (tag) {
   3.835 +    var newNode;
   3.836 +
   3.837 +    newNode = document.importNode(this.tagInputTemplate.content, true);
   3.838 +    newNode.querySelector('input[name="tag"]').value = tag;
   3.839 +
   3.840 +    return newNode;
   3.841 +};
   3.842 +
   3.843 +ActionsView.prototype.handleEvent = function (e) {
   3.844 +    var tags = [];
   3.845 +    var i;
   3.846 +
   3.847 +    switch (e.type) {
   3.848 +    case 'click':
   3.849 +        if (e.target.name === 'more-tags') {
   3.850 +            e.preventDefault();
   3.851 +            e.target.blur();
   3.852 +
   3.853 +            this.editTagListElement.appendChild(this.createTagInputElement(''));
   3.854 +        }
   3.855 +        break;
   3.856 +    case 'submit':
   3.857 +        if (e.target.id === 'save-form') {
   3.858 +            e.preventDefault();
   3.859 +            e.target.blur();
   3.860 +
   3.861 +            this.notify('save-file');
   3.862 +        } else if (e.target.id === 'load-form') {
   3.863 +            e.preventDefault();
   3.864 +            e.target.blur();
   3.865 +
   3.866 +            this.notify('load-file', e.target.file.files[0]);
   3.867 +            e.target.reset();
   3.868 +        } else if (e.target.classList.contains('bookmark-editor-form')) {
   3.869 +            e.preventDefault();
   3.870 +            e.target.blur();
   3.871 +
   3.872 +            if (e.target.tag.length) {
   3.873 +                for (i = 0; i < e.target.tag.length; i++) {
   3.874 +                    tags.push(e.target.tag[i].value.trim());
   3.875 +                }
   3.876 +            } else {
   3.877 +                tags.push(e.target.tag.value.trim());
   3.878 +            }
   3.879 +
   3.880 +            this.notify('save-bookmark', e.target.url.value,
   3.881 +                e.target.title.value, tags);
   3.882 +
   3.883 +            e.target.reset();
   3.884 +        }
   3.885 +        break;
   3.886 +    case 'reset':
   3.887 +        if (e.target.classList.contains('bookmark-editor-form')) {
   3.888 +            e.target.blur();
   3.889 +
   3.890 +            // remove all but one tag input element
   3.891 +            while (this.editTagListElement.firstChild !== null) {
   3.892 +                this.editTagListElement.removeChild(
   3.893 +                    this.editTagListElement.firstChild);
   3.894 +            }
   3.895 +            this.editTagListElement.appendChild(this.createTagInputElement(''));
   3.896 +        }
   3.897 +        break;
   3.898 +    }
   3.899 +};
   3.900 +
   3.901 +ActionsView.prototype.onSaveFile = function (jsonBlob) {
   3.902 +    this.saveLinkElement.href = URL.createObjectURL(jsonBlob);
   3.903 +    this.saveLinkElement.click();
   3.904 +};
   3.905 +
   3.906 +ActionsView.prototype.confirmLoadFile = function () {
   3.907 +    return window.confirm('There are unsaved changes to your bookmarks.\n' +
   3.908 +        'Proceed loading the bookmark file?');
   3.909 +};
   3.910 +
   3.911 +ActionsView.prototype.onLoadFileError = function (message) {
   3.912 +    window.alert('Failed to load bookmark file:\n' + message);
   3.913 +};
   3.914 +
   3.915 +ActionsView.prototype.onParseFileError = function (message) {
   3.916 +    window.alert('Failed to parse bookmark file:\n' + message);
   3.917 +};
   3.918 +
   3.919 +
   3.920 +var BookmarkView = function () {
   3.921 +    var searchFormElement;
   3.922 +
   3.923 +    ObservableMixin.call(this);
   3.924 +
   3.925 +    this.bookmarkTemplate = document.querySelector('#bookmark-template');
   3.926 +    this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template');
   3.927 +    this.bookmarkEditorTemplate =
   3.928 +        document.querySelector('#bookmark-editor-template');
   3.929 +    this.tagInputTemplate = document.querySelector('#tag-input-template');
   3.930 +
   3.931 +    this.bookmarkListElement = document.querySelector('ul#bookmark-list');
   3.932 +    this.bookmarkListElement.addEventListener('click', this);
   3.933 +    this.bookmarkListElement.addEventListener('submit', this);
   3.934 +    this.bookmarkListElement.addEventListener('reset', this);
   3.935 +
   3.936 +    searchFormElement = document.querySelector('#search-form');
   3.937 +    searchFormElement.addEventListener('submit', this);
   3.938 +    searchFormElement.addEventListener('reset', this);
   3.939 +
   3.940 +    this.searchTermInputElement = searchFormElement['search-term'];
   3.941 +
   3.942 +    this.bookmarkMessageElement = document.querySelector('#bookmark-message');
   3.943 +
   3.944 +    this.updateBookmarkMessage();
   3.945 +};
   3.946 +
   3.947 +extend(BookmarkView, ObservableMixin);
   3.948 +
   3.949 +BookmarkView.prototype.handleEvent = function (e) {
   3.950 +    var i;
   3.951 +    var tags = [];
   3.952 +    var node;
   3.953 +
   3.954 +    switch (e.type) {
   3.955 +    case 'click':
   3.956 +        switch (e.target.name) {
   3.957 +        case 'edit-bookmark':
   3.958 +            e.target.blur();
   3.959 +            // fallthrough
   3.960 +        case 'delete-bookmark':
   3.961 +            this.notify(e.target.name,
   3.962 +                getAncestorElementDatasetItem(e.target, 'bookmarkUrl'));
   3.963 +            break;
   3.964 +        case 'more-tags':
   3.965 +            e.target.blur();
   3.966 +
   3.967 +            e.target.form.querySelector('ul.tag-input-list').appendChild(
   3.968 +                this.createTagInputElement(''));
   3.969 +            break;
   3.970 +        case 'set-tag':
   3.971 +        case 'toggle-tag':
   3.972 +            e.target.blur();
   3.973 +
   3.974 +            this.notify(e.target.name,
   3.975 +                getAncestorElementDatasetItem(e.target, 'tag'));
   3.976 +            break;
   3.977 +        }
   3.978 +        break;
   3.979 +    case 'submit':
   3.980 +        if (e.target.classList.contains('bookmark-editor-form')) {
   3.981 +            // save bookmark-editor-form form contents
   3.982 +            e.preventDefault();
   3.983 +
   3.984 +            if (e.target.tag.length) {
   3.985 +                for (i = 0; i < e.target.tag.length; i++) {
   3.986 +                    tags.push(e.target.tag[i].value.trim());
   3.987 +                }
   3.988 +            } else {
   3.989 +                tags.push(e.target.tag.value.trim());
   3.990 +            }
   3.991 +
   3.992 +            this.notify('save-bookmark', e.target.url.value,
   3.993 +                e.target.title.value, tags, e.target['original-url'].value);
   3.994 +        } else if (e.target.id === 'search-form') {
   3.995 +            // search
   3.996 +            e.preventDefault();
   3.997 +            e.target.blur();
   3.998 +
   3.999 +            this.notify('search', e.target['search-term'].value);
  3.1000 +        }
  3.1001 +        break;
  3.1002 +    case 'reset':
  3.1003 +        if (e.target.classList.contains('bookmark-editor-form')) {
  3.1004 +            // cancel bookmark-editor-form form
  3.1005 +            e.preventDefault();
  3.1006 +
  3.1007 +            // re-enable edit button
  3.1008 +            this.bookmarkListElement.querySelector('li' +
  3.1009 +                createDatasetSelector('bookmark-url',
  3.1010 +                e.target['original-url'].value) +
  3.1011 +                ' button[name="edit-bookmark"]').disabled = false;
  3.1012 +
  3.1013 +            e.target.parentNode.removeChild(e.target);
  3.1014 +        } else if (e.target.id === 'search-form') {
  3.1015 +            // clear search
  3.1016 +            e.preventDefault();
  3.1017 +            e.target.blur();
  3.1018 +
  3.1019 +            this.notify('search', '');
  3.1020 +        }
  3.1021 +        break;
  3.1022 +    }
  3.1023 +};
  3.1024 +
  3.1025 +BookmarkView.prototype.updateBookmarkMessage = function () {
  3.1026 +    this.bookmarkMessageElement.textContent = 'Showing ' +
  3.1027 +        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
  3.1028 +        'li:not([hidden])').length + ' of ' +
  3.1029 +        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
  3.1030 +        'li').length + ' bookmarks.';
  3.1031 +};
  3.1032 +
  3.1033 +BookmarkView.prototype.onBookmarkAdded = function (bookmark) {
  3.1034 +    var newNode;
  3.1035 +    var bookmarkElement;
  3.1036 +    var linkElement;
  3.1037 +    var tagListElement;
  3.1038 +
  3.1039 +    newNode = document.importNode(this.bookmarkTemplate.content, true);
  3.1040 +
  3.1041 +    bookmarkElement = newNode.querySelector('li');
  3.1042 +    bookmarkElement.dataset.bookmarkUrl = bookmark.url;
  3.1043 +
  3.1044 +    linkElement = bookmarkElement.querySelector('a.bookmark-link');
  3.1045 +    linkElement.textContent = linkElement.title = bookmark.title;
  3.1046 +    linkElement.href = bookmark.url;
  3.1047 +
  3.1048 +    tagListElement = bookmarkElement.querySelector('ul.tag-list');
  3.1049 +    bookmark.tags.forEach(function (tag) {
  3.1050 +        var newNode;
  3.1051 +        var tagElement;
  3.1052 +        var setTagButton;
  3.1053 +        var toggleTagButton;
  3.1054 +
  3.1055 +        newNode = document.importNode(this.bookmarkTagTemplate.content, true);
  3.1056 +
  3.1057 +        tagElement = newNode.querySelector('li');
  3.1058 +        tagElement.dataset.tag = tag;
  3.1059 +
  3.1060 +        setTagButton = newNode.querySelector('button[name="set-tag"]');
  3.1061 +        setTagButton.textContent = tag;
  3.1062 +        setTagButton.title = 'Set filter to "' + tag + '"';
  3.1063 +
  3.1064 +        toggleTagButton = newNode.querySelector('button[name="toggle-tag"]');
  3.1065 +        toggleTagButton.textContent = '+';
  3.1066 +        toggleTagButton.title = 'Add "' + tag + '" to filter';
  3.1067 +
  3.1068 +        tagListElement.appendChild(newNode);
  3.1069 +    }, this);
  3.1070 +
  3.1071 +    // insert new or last modified bookmark on top of the list
  3.1072 +    this.bookmarkListElement.insertBefore(newNode,
  3.1073 +        this.bookmarkListElement.firstChild);
  3.1074 +
  3.1075 +    this.updateBookmarkMessage();
  3.1076 +};
  3.1077 +
  3.1078 +BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) {
  3.1079 +    var bookmarkElement;
  3.1080 +
  3.1081 +    bookmarkElement = this.bookmarkListElement.querySelector('li' +
  3.1082 +        createDatasetSelector('bookmark-url', bookmarkUrl));
  3.1083 +    if (bookmarkElement !== null) {
  3.1084 +        this.bookmarkListElement.removeChild(bookmarkElement);
  3.1085 +
  3.1086 +        this.updateBookmarkMessage();
  3.1087 +    }
  3.1088 +};
  3.1089 +
  3.1090 +BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
  3.1091 +        newFilterTags, newSearchTerm) {
  3.1092 +    var bookmarkElements;
  3.1093 +    var i;
  3.1094 +    var tagElements;
  3.1095 +    var toggleTagButton;
  3.1096 +    var j;
  3.1097 +    var tag;
  3.1098 +
  3.1099 +    this.searchTermInputElement.value = newSearchTerm;
  3.1100 +
  3.1101 +    bookmarkElements =
  3.1102 +        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li');
  3.1103 +    for (i = 0; i < bookmarkElements.length; i++) {
  3.1104 +        // update visibility of bookmarks
  3.1105 +        if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) {
  3.1106 +            // update tag elements of visible bookmarks
  3.1107 +            tagElements =
  3.1108 +                bookmarkElements[i].querySelectorAll('ul.tag-list > li');
  3.1109 +            for (j = 0; j < tagElements.length; j++) {
  3.1110 +                tag = tagElements[j].dataset.tag;
  3.1111 +                toggleTagButton =
  3.1112 +                    tagElements[j].querySelector('button[name="toggle-tag"]');
  3.1113 +                if (newFilterTags.has(tag)) {
  3.1114 +                    tagElements[j].classList.add('active-filter-tag');
  3.1115 +                    toggleTagButton.textContent = '\u2212';
  3.1116 +                    toggleTagButton.title = 'Remove "' + tag + '" from filter';
  3.1117 +                } else {
  3.1118 +                    tagElements[j].classList.remove('active-filter-tag');
  3.1119 +                    toggleTagButton.textContent = '+';
  3.1120 +                    toggleTagButton.title = 'Add "' + tag + '" to filter';
  3.1121 +                }
  3.1122 +            }
  3.1123 +            bookmarkElements[i].hidden = false;
  3.1124 +        } else {
  3.1125 +            bookmarkElements[i].hidden = true;
  3.1126 +        }
  3.1127 +    }
  3.1128 +
  3.1129 +    this.updateBookmarkMessage();
  3.1130 +};
  3.1131 +
  3.1132 +BookmarkView.prototype.createTagInputElement = function (tag) {
  3.1133 +    var newNode;
  3.1134 +
  3.1135 +    newNode = document.importNode(this.tagInputTemplate.content, true);
  3.1136 +    newNode.querySelector('input[name="tag"]').value = tag;
  3.1137 +
  3.1138 +    return newNode;
  3.1139 +};
  3.1140 +
  3.1141 +BookmarkView.prototype.displayBookmarkEditor = function (bookmark) {
  3.1142 +    var bookmarkElement;
  3.1143 +    var newNode;
  3.1144 +    var formElement;
  3.1145 +    var editTagListElement;
  3.1146 +
  3.1147 +    bookmarkElement =
  3.1148 +        this.bookmarkListElement.querySelector('ul#bookmark-list > li' +
  3.1149 +        createDatasetSelector('bookmark-url', bookmark.url));
  3.1150 +
  3.1151 +    // disable edit button while editing
  3.1152 +    bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled =
  3.1153 +        true;
  3.1154 +
  3.1155 +    // create new editor form from template
  3.1156 +    newNode = document.importNode(this.bookmarkEditorTemplate.content, true);
  3.1157 +
  3.1158 +    // fill with data of given bookmark
  3.1159 +    formElement = newNode.querySelector('form.bookmark-editor-form');
  3.1160 +    formElement.querySelector('legend').textContent = 'Edit Bookmark';
  3.1161 +    formElement['original-url'].value = bookmark.url;
  3.1162 +    formElement.url.value = bookmark.url;
  3.1163 +    formElement.title.value = bookmark.title;
  3.1164 +
  3.1165 +    editTagListElement = formElement.querySelector('ul.tag-input-list');
  3.1166 +    bookmark.tags.forEach(function (tag) {
  3.1167 +        editTagListElement.appendChild(this.createTagInputElement(tag));
  3.1168 +    }, this);
  3.1169 +    editTagListElement.appendChild(this.createTagInputElement(''));
  3.1170 +
  3.1171 +    // insert editor form into bookmark item
  3.1172 +    bookmarkElement.appendChild(newNode);
  3.1173 +
  3.1174 +    // focus first input element
  3.1175 +    formElement.querySelector('input').focus();
  3.1176 +};
  3.1177 +
  3.1178 +BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) {
  3.1179 +    return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' +
  3.1180 +        bookmark.url + ']?');
  3.1181 +};
  3.1182 +
  3.1183 +BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) {
  3.1184 +    return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' +
  3.1185 +        bookmark.url + ']?');
  3.1186 +};
  3.1187 +
  3.1188 +
  3.1189 +/*
  3.1190 + * controller
  3.1191 + */
  3.1192 +
  3.1193 +var BooketController = function(bookmarkModel, actionsView, tagView,
  3.1194 +        bookmarkView) {
  3.1195 +    this.bookmarkModel = bookmarkModel;
  3.1196 +    this.actionsView = actionsView;
  3.1197 +    this.tagView = tagView;
  3.1198 +    this.bookmarkView = bookmarkView;
  3.1199 +
  3.1200 +    /* connect the views to the model */
  3.1201 +    this.bookmarkModel.addObserver('bookmark-added',
  3.1202 +        this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView));
  3.1203 +    this.bookmarkModel.addObserver('bookmark-deleted',
  3.1204 +        this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView));
  3.1205 +    this.bookmarkModel.addObserver('filter-tags-search-changed',
  3.1206 +        this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView));
  3.1207 +    this.bookmarkModel.addObserver('load-file-error',
  3.1208 +        this.actionsView.onLoadFileError.bind(this.actionsView));
  3.1209 +    this.bookmarkModel.addObserver('parse-file-error',
  3.1210 +        this.actionsView.onParseFileError.bind(this.actionsView));
  3.1211 +    this.bookmarkModel.addObserver('save-file',
  3.1212 +        this.actionsView.onSaveFile.bind(this.actionsView));
  3.1213 +    this.bookmarkModel.addObserver('tag-added',
  3.1214 +        this.tagView.onTagAdded.bind(this.tagView));
  3.1215 +    this.bookmarkModel.addObserver('tag-count-changed',
  3.1216 +        this.tagView.onTagCountChanged.bind(this.tagView));
  3.1217 +    this.bookmarkModel.addObserver('tag-deleted',
  3.1218 +        this.tagView.onTagDeleted.bind(this.tagView));
  3.1219 +    this.bookmarkModel.addObserver('filter-tags-search-changed',
  3.1220 +        this.tagView.onFilterTagsSearchChanged.bind(this.tagView));
  3.1221 +    this.bookmarkModel.addObserver('filter-tags-search-changed',
  3.1222 +        this.onFilterTagsSearchChanged.bind(this));
  3.1223 +
  3.1224 +    /* handle input */
  3.1225 +    window.addEventListener('hashchange', this.onHashChange.bind(this));
  3.1226 +    window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
  3.1227 +    this.actionsView.addObserver('save-file',
  3.1228 +        this.bookmarkModel.saveFile.bind(this.bookmarkModel));
  3.1229 +    this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
  3.1230 +    this.actionsView.addObserver('save-bookmark',
  3.1231 +        this.onSaveBookmark.bind(this));
  3.1232 +    this.bookmarkView.addObserver('edit-bookmark',
  3.1233 +        this.onEditBookmark.bind(this));
  3.1234 +    this.bookmarkView.addObserver('save-bookmark',
  3.1235 +        this.onSaveBookmark.bind(this));
  3.1236 +    this.bookmarkView.addObserver('delete-bookmark',
  3.1237 +        this.onDeleteBookmark.bind(this));
  3.1238 +    this.bookmarkView.addObserver('toggle-tag',
  3.1239 +        this.onToggleFilterTag.bind(this));
  3.1240 +    this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this));
  3.1241 +    this.bookmarkView.addObserver('search', this.onSearch.bind(this));
  3.1242 +    this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this));
  3.1243 +    this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this));
  3.1244 +};
  3.1245 +
  3.1246 +BooketController.prototype.parseTagsParameter = function (tagsString) {
  3.1247 +    var tags;
  3.1248 +
  3.1249 +    tags = tagsString.split(',').filter(function (tag) {
  3.1250 +        return (tag !== '') && this.bookmarkModel.hasTag(tag);
  3.1251 +    }, this).sort();
  3.1252 +
  3.1253 +    return new StringSet(tags);
  3.1254 +};
  3.1255 +
  3.1256 +BooketController.prototype.onHashChange = function (e) {
  3.1257 +    var hashData;
  3.1258 +    var filterTags;
  3.1259 +    var searchTerm;
  3.1260 +
  3.1261 +    hashData = parseHash(window.location.href);
  3.1262 +
  3.1263 +    filterTags = hashData.has('tags') ?
  3.1264 +        this.parseTagsParameter(hashData.get('tags')) : new StringSet();
  3.1265 +
  3.1266 +    searchTerm = hashData.has('search') ? hashData.get('search') : '';
  3.1267 +
  3.1268 +    this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm);
  3.1269 +};
  3.1270 +
  3.1271 +BooketController.prototype.onBeforeUnload = function (e) {
  3.1272 +    var confirmationMessage = 'There are unsaved changes to your bookmarks.';
  3.1273 +
  3.1274 +    if (this.bookmarkModel.unsavedChanges) {
  3.1275 +        if (e) {
  3.1276 +            e.returnValue = confirmationMessage;
  3.1277 +        }
  3.1278 +        if (window.event) {
  3.1279 +            window.event.returnValue = confirmationMessage;
  3.1280 +        }
  3.1281 +        return confirmationMessage;
  3.1282 +    }
  3.1283 +};
  3.1284 +
  3.1285 +BooketController.prototype.onFilterTagsSearchChanged =
  3.1286 +        function (filteredBookmarks, newFilterTags, newSearchTerm) {
  3.1287 +    var url = window.location.href;
  3.1288 +    var hashData;
  3.1289 +
  3.1290 +    // serialize tag filter and search term and update window.location
  3.1291 +    hashData = parseHash(url);
  3.1292 +    hashData.set('tags', newFilterTags.values().join(','));
  3.1293 +    hashData.set('search', newSearchTerm);
  3.1294 +    history.pushState(null, null, serializeHash(url, hashData));
  3.1295 +};
  3.1296 +
  3.1297 +BooketController.prototype.onLoadFile = function (bookmarkFile) {
  3.1298 +    if (this.bookmarkModel.unsavedChanges) {
  3.1299 +        if (!this.actionsView.confirmLoadFile()) {
  3.1300 +            return;
  3.1301 +        }
  3.1302 +        this.bookmarkModel.unsavedChanges = false;
  3.1303 +    }
  3.1304 +
  3.1305 +    this.bookmarkModel.loadFile(bookmarkFile);
  3.1306 +};
  3.1307 +
  3.1308 +BooketController.prototype.onEditBookmark = function (bookmarkUrl) {
  3.1309 +    this.bookmarkView.displayBookmarkEditor(
  3.1310 +        this.bookmarkModel.get(bookmarkUrl));
  3.1311 +};
  3.1312 +
  3.1313 +BooketController.prototype.onSaveBookmark = function (url, title, tags,
  3.1314 +        originalUrl) {
  3.1315 +    var ctime;
  3.1316 +
  3.1317 +    if (originalUrl === undefined) {
  3.1318 +        // saving new bookmark, get confirmation before replacing existing one
  3.1319 +        if (this.bookmarkModel.has(url)) {
  3.1320 +            if (this.bookmarkView.confirmReplaceBookmark(
  3.1321 +                    this.bookmarkModel.get(url))) {
  3.1322 +                this.bookmarkModel.delete(url);
  3.1323 +            } else {
  3.1324 +                return;
  3.1325 +            }
  3.1326 +        }
  3.1327 +
  3.1328 +        ctime = new Date();
  3.1329 +    } else {
  3.1330 +        // saving edited bookmark, preserve creation time of any replaced
  3.1331 +        // bookmark
  3.1332 +        ctime = (this.bookmarkModel.has(url)) ?
  3.1333 +            this.bookmarkModel.get(url).ctime : new Date();
  3.1334 +
  3.1335 +        this.bookmarkModel.delete(originalUrl);
  3.1336 +    }
  3.1337 +    this.bookmarkModel.add(new Bookmark(url, title, tags, ctime));
  3.1338 +};
  3.1339 +
  3.1340 +BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) {
  3.1341 +    if (this.bookmarkView.confirmDeleteBookmark(
  3.1342 +            this.bookmarkModel.get(bookmarkUrl))) {
  3.1343 +        this.bookmarkModel.delete(bookmarkUrl);
  3.1344 +    }
  3.1345 +};
  3.1346 +
  3.1347 +BooketController.prototype.onToggleFilterTag = function (tag) {
  3.1348 +    this.bookmarkModel.toggleFilterTag(tag);
  3.1349 +};
  3.1350 +
  3.1351 +BooketController.prototype.onSetTagFilter = function (tag) {
  3.1352 +    this.bookmarkModel.setFilterTags(new StringSet([tag]));
  3.1353 +};
  3.1354 +
  3.1355 +BooketController.prototype.onSearch = function (searchTerm) {
  3.1356 +    this.bookmarkModel.setSearchTerm(searchTerm);
  3.1357 +};
  3.1358 +
  3.1359 +
  3.1360 +document.addEventListener('DOMContentLoaded', function (e) {
  3.1361 +    var controller;
  3.1362 +    var bookmarkModel;
  3.1363 +    var actionsView;
  3.1364 +    var tagView;
  3.1365 +    var bookmarkView;
  3.1366 +    var hashChangeEvent;
  3.1367 +
  3.1368 +    bookmarkModel = new BookmarkModel();
  3.1369 +    tagView = new TagView();
  3.1370 +    actionsView = new ActionsView();
  3.1371 +    bookmarkView = new BookmarkView();
  3.1372 +    controller = new BooketController(bookmarkModel, actionsView,
  3.1373 +        tagView, bookmarkView);
  3.1374 +
  3.1375 +    // initialize state from the current URL
  3.1376 +    hashChangeEvent = new Event('hashchange');
  3.1377 +    hashChangeEvent.oldURL = window.location.href;
  3.1378 +    hashChangeEvent.newURL = window.location.href;
  3.1379 +    window.dispatchEvent(hashChangeEvent);
  3.1380 +});
  3.1381 +}());
  3.1382 +