view booket.js @ 11:ef5d75bcac5e

Add Netscape bookmark file export function
author Guido Berhoerster <guido+booket@berhoerster.name>
date Wed, 17 Sep 2014 21:12:38 +0200
parents 20902b548d9f
children 948048e40fab
line wrap: on
line source

/*
 * 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';

var BOOKMARKLET_URI =
    'javascript:(function() {' +
         '\'use strict\';' +
     '' +
        'function displayBookmarkData(bookmarkData) {' +
            'window.alert(\'Copy the following data and paste it into \' +' +
                '\'Booket:\\n\\n\' + JSON.stringify(bookmarkData));' +
        '}' +
    '' +
        'var bookmarkData = {' +
            '\'url\': document.URL,' +
            '\'title\': document.title,' +
            '\'favicon\': undefined' +
        '};' +
        'var faviconLinkElement;' +
        'var faviconUrls = [];' +
        'var aElement;' +
        'var canvasElement;' +
        'var canvasCtx;' +
        'var imgElement;' +
    '' +
        'aElement = document.createElement(\'a\');' +
        'aElement.href = document.URL;' +
    '' +
        'faviconUrls.push(aElement.protocol + \'//\' + aElement.host + ' +
            '\'/favicon.ico\');' +
    '' +
        'faviconLinkElement = document.querySelector(' +
            '\'link[rel~=\\\'icon\\\']\');' +
        'if (faviconLinkElement !== null) {' +
            'faviconUrls.push(faviconLinkElement.href);' +
        '}' +
    '' +
        'canvasElement = document.createElement(\'canvas\');' +
        'canvasCtx = canvasElement.getContext(\'2d\');' +
    '' +
        'imgElement = new Image();' +
        'imgElement.addEventListener(\'load\', function(e) {' +
            'var faviconUrl;' +
    '' +
            'canvasElement.width = 16;' +
            'canvasElement.height = 16;' +
            'canvasCtx.clearRect(0, 0, 16, 16);' +
            'try {' +
                'canvasCtx.drawImage(this, 0, 0, 16, 16);' +
                'bookmarkData.favicon = canvasElement.toDataURL();' +
            '} catch (exception) {' +
                'faviconUrl = faviconUrls.pop();' +
            '}' +
            'if (bookmarkData.favicon !== undefined || ' +
                    'faviconUrl === undefined) {' +
                'displayBookmarkData(bookmarkData);' +
            '} else {' +
                'imgElement.src = faviconUrl;' +
            '}' +
        '});' +
        'imgElement.addEventListener(\'error\', function(e) {' +
            'var faviconUrl;' +
    '' +
            'faviconUrl = faviconUrls.pop();' +
            'if (faviconUrl !== undefined) {' +
                'imgElement.src = faviconUrl;' +
            '} else {' +
                'displayBookmarkData(bookmarkData);' +
            '}' +
        '});' +
        'imgElement.src = faviconUrls.pop();' +
    '})();';


/*
 * 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, favicon, 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 (isString(favicon) && favicon.match(/^data:image\/png;base64,/)) {
        this.favicon = favicon;
    } else {
        this.favicon = undefined;
    }

    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.loadFileReader = null;
    this.importFileReader= null;
    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.favicon, 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.parseImportedBookmarks = function (data) {
    var bookmarkDoc;
    var bookmarkElements;
    var i;
    var url;
    var title;
    var favicon;
    var tags;
    var ctime;
    var mtime;
    var bookmarks = [];

    bookmarkDoc = document.implementation.createHTMLDocument();
    bookmarkDoc.open();
    bookmarkDoc.write(data);
    bookmarkDoc.close();

    // create a temporary list of valid bookmarks
    bookmarkElements = bookmarkDoc.querySelectorAll('dt > a[href]');
    for (i = 0; i < bookmarkElements.length; i++) {
        url = bookmarkElements[i].href;
        if (url !== '') {
            title = bookmarkElements[i].textContent;
            favicon = bookmarkElements[i].getAttribute('icon');
            tags = ((tags = bookmarkElements[i].getAttribute('tags')) !==
                null) ? tags.split(',') : [];
            ctime = !isNaN(ctime =
                parseInt(bookmarkElements[i].getAttribute('add_date'), 10)) ?
                ctime * 1000 : undefined;
            mtime = !isNaN(mtime =
                parseInt(bookmarkElements[i].getAttribute('last_modified'),
                10)) ?  mtime * 1000 : undefined;
            bookmarks.push(new Bookmark(url, title, favicon, tags, ctime, mtime));
        }
    }

    // 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) {
    // delete all existing bookmarks first
    this.delete(this._bookmarks.keys());
    this.unsavedChanges = false;

    this.loadFileReader = new FileReader();
    this.loadFileReader.addEventListener('error', this);
    this.loadFileReader.addEventListener('load', this);
    this.loadFileReader.readAsText(bookmarkFile);
};

BookmarkModel.prototype.importFile = function (bookmarkFile) {
    // delete all existing bookmarks first
    this.delete(this._bookmarks.keys());
    this.unsavedChanges = false;

    this.importFileReader = new FileReader();
    this.importFileReader.addEventListener('error', this);
    this.importFileReader.addEventListener('load', this);
    this.importFileReader.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.exportFile = function () {
    var htmlBlob;
    var bookmarkDoc;
    var commentNode;
    var metaElement;
    var titleElement;
    var headingElement;
    var bookmarkListElement;
    var bookmarkLinkElement;
    var bookmarkElement;

    bookmarkDoc = document.implementation.createHTMLDocument();

    // construct Netscape bookmarks format within body
    commentNode = bookmarkDoc.createComment('This is an automatically ' +
        'generated file.\nIt will be read and overwritten.\nDO NOT EDIT!');
    bookmarkDoc.body.appendChild(commentNode);

    metaElement = bookmarkDoc.createElement('meta');
    metaElement.setAttribute('http-equiv', 'Content-Type');
    metaElement.setAttribute('content', 'text/html; charset=UTF-8');
    bookmarkDoc.body.appendChild(metaElement);

    titleElement = bookmarkDoc.createElement('title');
    titleElement.textContent = 'Bookmarks';
    bookmarkDoc.body.appendChild(titleElement);

    headingElement = bookmarkDoc.createElement('h1');
    headingElement.textContent = 'Bookmarks';
    bookmarkDoc.body.appendChild(headingElement);

    bookmarkListElement = bookmarkDoc.createElement('dl');
    bookmarkDoc.body.appendChild(bookmarkListElement);

    this._bookmarks.forEach(function (bookmark) {
        bookmarkElement = bookmarkDoc.createElement('dt');

        bookmarkLinkElement = bookmarkDoc.createElement('a');
        bookmarkLinkElement.href = bookmark.url;
        bookmarkLinkElement.textContent = bookmark.title;
        bookmarkLinkElement.setAttribute('icon', bookmark.favicon);
        bookmarkLinkElement.setAttribute('tags',
            bookmark.tags.values().join(','));
        bookmarkLinkElement.setAttribute('add_date',
            Math.round(bookmark.ctime.getTime() / 1000));
        bookmarkLinkElement.setAttribute('last_modified',
            Math.round(bookmark.mtime.getTime() / 1000));

        bookmarkElement.appendChild(bookmarkLinkElement);

        bookmarkListElement.appendChild(bookmarkElement);
        bookmarkListElement.appendChild(bookmarkDoc.createElement('dd'));
    }, this);

    htmlBlob = new Blob(['<!DOCTYPE NETSCAPE-Bookmark-file-1>\n' +
        bookmarkDoc.body.innerHTML], {type: 'text/html'});
    this.notify('export-file', htmlBlob);
};

BookmarkModel.prototype.handleEvent = function (e) {
    if (e.type === 'load') {
        if (e.target === this.loadFileReader) {
            this.parseLoadedBookmarks(e.target.result);
            this.loadFileReader = null;
        } else if (e.target === this.importFileReader) {
            this.parseImportedBookmarks(e.target.result);
            this.importFileReader = null;
        }
    } 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.tagDatalistElement = document.querySelector('#tag-datalist');

    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;
    var tagOptionElement;
    var i;
    var isInDatalist = false;

    // 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);

    // add to datalist
    for (i = 0; i < this.tagDatalistElement.options.length; i++) {
        if (this.tagDatalistElement.options[i].value == tag) {
            isInDatalist = true;
            break;
        }
    }
    if (!isInDatalist) {
        tagOptionElement = document.createElement('option');
        tagOptionElement.value = tag;
        this.tagDatalistElement.appendChild(tagOptionElement);
    }
};

TagView.prototype.onTagCountChanged = function (tag, tagCount) {
    this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) +
        ' .tag-count').textContent = '(' + tagCount + ')';
};

TagView.prototype.onTagDeleted = function (tag) {
    var tagElement;

    // remove from tag list
    tagElement = this.tagListElement.querySelector('li' +
        createDatasetSelector('tag', tag));
    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 importFormElement;
    var exportFormElement;
    var newNode;

    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);

    importFormElement = document.querySelector('form#import-form');
    importFormElement.addEventListener('submit', this);

    exportFormElement = document.querySelector('form#export-form');
    exportFormElement.addEventListener('submit', this);

    // create new editor form from template
    newNode = document.importNode(
        document.querySelector('#bookmark-editor-template').content, true);

    this.editorFormElement = newNode.querySelector('form.bookmark-editor-form');
    this.editorFormElement.querySelector('legend').textContent = 'Add Bookmark';
    this.editorFormElement.querySelector(
        'input:not([type="hidden"])').accessKey = 'a';
    this.editorFormElement.addEventListener('input', this);
    this.editorFormElement.addEventListener('click', this);
    this.editorFormElement.addEventListener('submit', this);
    this.editorFormElement.addEventListener('reset', this);

    this.faviconImageElement =
        this.editorFormElement.querySelector('img.bookmark-favicon');
    this.faviconImageElement.addEventListener('load', this);
    this.faviconImageElement.addEventListener('error', this);

    this.missingFaviconUri = this.faviconImageElement.src;

    this.editTagListElement =
        this.editorFormElement.querySelector('ul.tag-input-list');
    this.editTagListElement.appendChild(this.createTagInputElement(''));

    saveFormElement.parentNode.insertBefore(newNode,
        saveFormElement.nextSibling);

    document.querySelector('a#bookmarklet-link').href = BOOKMARKLET_URI;
};

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 bookmarkletData;
    var parsedData;
    var tags = [];
    var i;

    switch (e.type) {
    case 'error':
        if (e.target.classList.contains('bookmark-favicon')) {
            if (e.target.src !== this.missingFaviconUri) {
                e.target.src = this.missingFaviconUri;
            }
        }
        break;
    case 'load':
        if (e.target.classList.contains('bookmark-favicon')) {
            this.editorFormElement.favicon.value =
                (e.target.src !== this.missingFaviconUri) ?  e.target.src : '';
        }
        break;
    case 'input':
        if (e.target.name === 'bookmarklet-import') {
            // get rid of any preceding text
            bookmarkletData = e.target.value.replace(/^[^{]*/, '');

            try {
                parsedData = JSON.parse(bookmarkletData);
            } catch (exception) {
                return;
            }

            if (isString(parsedData.url) && parsedData.url !== '') {
                e.target.form.elements.url.value = parsedData.url;
            }
            if (isString(parsedData.title) && parsedData.title !== '') {
                e.target.form.elements.title.value = parsedData.title;
            }
            if (isString(parsedData.favicon) &&
                    parsedData.favicon.match(/^data:image\/png;base64,/)) {
                this.faviconImageElement.src = parsedData.favicon;
            }
        }
        break;
    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.id === 'import-form') {
            e.preventDefault();
            e.target.blur();

            this.notify('import-file', e.target.file.files[0]);
            e.target.reset();
        } else if (e.target.id === 'export-form') {
            e.preventDefault();
            e.target.blur();

            this.notify('export-file');
        } 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, e.target.favicon.value, tags);

            e.target.reset();
        }
        break;
    case 'reset':
        if (e.target.classList.contains('bookmark-editor-form')) {
            e.target.blur();

            e.target.querySelector('img.bookmark-favicon').src =
                this.missingFaviconUri;

            // 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.onExportFile = function (htmlBlob) {
    var exportLinkElement;

    exportLinkElement = document.querySelector('a#export-link');
    exportLinkElement.href = URL.createObjectURL(htmlBlob);
    exportLinkElement.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('input', this);
    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.missingFaviconUri = '';

    this.updateBookmarkMessage();
};

extend(BookmarkView, ObservableMixin);

BookmarkView.prototype.handleEvent = function (e) {
    var bookmarkletData;
    var parsedData;
    var i;
    var tags = [];
    var node;

    switch (e.type) {
    case 'error':
        if (e.target.classList.contains('bookmark-favicon')) {
            if (e.target.src !== this.missingFaviconUri) {
                e.target.src = this.missingFaviconUri;
            }
        }
        break;
    case 'load':
        if (e.target.classList.contains('bookmark-favicon')) {
            node = e.target;
            while ((node = node.parentNode) !== null) {
                if (node.classList.contains('bookmark-editor-form')) {
                    node.favicon.value =
                        (e.target.src !== this.missingFaviconUri) ?
                        e.target.src : '';
                    break;
                }
            }
        }
        break;
    case 'input':
        if (e.target.name === 'bookmarklet-import') {
            // get rid of any preceding text
            bookmarkletData = e.target.value.replace(/^[^{]*/, '');

            try {
                parsedData = JSON.parse(bookmarkletData);
            } catch (exception) {
                return;
            }

            if (isString(parsedData.url) && parsedData.url !== '') {
                e.target.form.elements.url.value = parsedData.url;
            }
            if (isString(parsedData.title) && parsedData.title !== '') {
                e.target.form.elements.title.value = parsedData.title;
            }
            if (isString(parsedData.favicon) &&
                    parsedData.favicon.match(/^data:image\/png;base64,/)) {
                e.target.form.querySelector('img.bookmark-favicon').src =
                    parsedData.favicon;
            }
        }
        break;
    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, e.target.favicon.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 faviconElement;
    var linkElement;
    var hostnameElement;
    var urlElement;
    var ctimeElement;
    var mtimeElement;
    var tagListElement;

    newNode = document.importNode(this.bookmarkTemplate.content, true);

    bookmarkElement = newNode.querySelector('li');
    bookmarkElement.dataset.bookmarkUrl = bookmark.url;

    faviconElement = bookmarkElement.querySelector('img.bookmark-favicon');
    faviconElement.src = (bookmark.favicon) ? bookmark.favicon :
        this.missingFaviconUri;
    faviconElement.alt = '';

    linkElement = bookmarkElement.querySelector('a.bookmark-link');
    linkElement.textContent = linkElement.title = bookmark.title;
    linkElement.href = bookmark.url;

    hostnameElement = bookmarkElement.querySelector('.bookmark-hostname');
    hostnameElement.textContent = (linkElement.hostname !== '') ?
        '[' + linkElement.hostname + ']' : '';

    urlElement = bookmarkElement.querySelector('.bookmark-url');
    urlElement.textContent = bookmark.url;

    ctimeElement = bookmarkElement.querySelector('.ctime');
    ctimeElement.dateTime = bookmark.ctime.toISOString();
    ctimeElement.textContent = bookmark.ctime.toString();

    mtimeElement = bookmarkElement.querySelector('.mtime');
    mtimeElement.dateTime = bookmark.mtime.toISOString();
    mtimeElement.textContent = bookmark.mtime.toString();

    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 faviconImageElement;
    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;

    faviconImageElement = formElement.querySelector('img.bookmark-favicon');
    faviconImageElement.addEventListener('load', this);
    faviconImageElement.addEventListener('error', this);
    this.missingFaviconUri = faviconImageElement.src;
    if (bookmark.favicon) {
        faviconImageElement.src = bookmark.favicon;
    }

    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('export-file',
        this.actionsView.onExportFile.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('export-file',
        this.bookmarkModel.exportFile.bind(this.bookmarkModel));
    this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
    this.actionsView.addObserver('import-file', this.onImportFile.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.onImportFile = function (bookmarkFile) {
    if (this.bookmarkModel.unsavedChanges) {
        if (!this.actionsView.confirmLoadFile()) {
            return;
        }
        this.bookmarkModel.unsavedChanges = false;
    }

    this.bookmarkModel.importFile(bookmarkFile);
};

BooketController.prototype.onEditBookmark = function (bookmarkUrl) {
    this.bookmarkView.displayBookmarkEditor(
        this.bookmarkModel.get(bookmarkUrl));
};

BooketController.prototype.onSaveBookmark = function (url, title,
        favicon, 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, favicon, 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);
});
}());