view booket.js @ 1:6559033d9996

Added tag version-1 for changeset c2248f662a2c
author Guido Berhoerster <guido+booket@berhoerster.name>
date Sat, 06 Sep 2014 18:20:15 +0200
parents c2248f662a2c
children 82c50265c8dc
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';

/*
 * utility stuff
 */

function isNumber(number) {
    return (Object.prototype.toString.call(number) === '[object Number]');
}

function isString(number) {
    return (Object.prototype.toString.call(number) === '[object String]');
}

function arrayEqual(array1, array2) {
    if (!Array.isArray(array1)) {
        throw new TypeError(typeof array1 + ' is not an array');
    } else if (!Array.isArray(array2)) {
        throw new TypeError(typeof array2 + ' is not an array');
    }

    if (array1.length !== array2.length) {
        return false;
    } else if (array1.length === 0 && array2.length === 0) {
        return true;
    }

    return array1.slice().sort().every(function (value, i) {
        return value === array2[i];
    });
}

function parseHash(url) {
    var hashData;
    var pos;
    var hash;
    var hashParts;
    var key;
    var value;
    var i;

    hashData = new StringMap();
    pos = url.indexOf('#');
    hash = (pos > -1) ? url.substr(pos + 1) : '';
    // hash parts are seperated by a ';'
    hashParts = hash.split(';');
    for (i = 0; i < hashParts.length; i++) {
        // key and value pairs are seperated by a '=', an empty value will
        // cause the key to be ignored
        pos = hashParts[i].indexOf('=');
        if (pos > -1) {
            key = decodeURIComponent(hashParts[i].substr(0, pos));
            value = decodeURIComponent(hashParts[i].substr(pos + 1));
            hashData.set(key, value);
        }
    }

    return hashData;
}

function serializeHash(url, hashData) {
    var hashParts = [];
    var pos;

    pos = url.indexOf('#');
    if (pos > -1) {
        url = url.substr(0, pos);
    }

    hashData.forEach(function (value, key) {
        if (value !== '') {
            hashParts.push(encodeURIComponent(key) + '=' +
                encodeURIComponent(value));
        }
    });

    // only append a '#' if there are any hash parts
    return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : '');
}

function getAncestorElementDatasetItem(node, item) {
    while ((node = node.parentNode) !== null) {
        if (node.dataset && node.dataset[item] !== undefined) {
            return node.dataset[item];
        }
    }

    return undefined;
}

// for use with Node.querySelector() and Node.querySelectorAll()
function createDatasetSelector(name, value) {
    return  '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]';
}

function extend(targetObject, sourceObject) {
    var propertyName;

    for (propertyName in sourceObject.prototype) {
        if (!Object.prototype.hasOwnProperty.call(targetObject.prototype,
                propertyName)) {
            targetObject.prototype[propertyName] =
                sourceObject.prototype[propertyName];
        }
    }
}


var ObservableMixin = function () {
    this._eventsObservers = {};
};

ObservableMixin.prototype.addObserver = function (eventName, observer) {
    var i;

    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
            eventName)) {
        this._eventsObservers[eventName] = [];
    }

    // prevent observers for an event from being called more than once
    for (i = 0; i < this._eventsObservers[eventName].length; i++) {
        if (this._eventsObservers[eventName][i] === observer) {
            return;
        }
    }
    this._eventsObservers[eventName].push(observer);
};

ObservableMixin.prototype.deleteObserver = function (eventName, observer) {
    var i = 0;

    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
            eventName)) {
        return;
    }

    while (i < this._eventsObservers[eventName].length) {
        if (this._eventsObservers[eventName][i] === observer) {
            this._eventsObservers[eventName].splice(i, 1);
        }
    }
};

ObservableMixin.prototype.notify = function (eventName) {
    var origArguments;

    if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
            eventName)) {
        return;
    }

    origArguments = Array.prototype.slice.call(arguments, 1);
    this._eventsObservers[eventName].forEach(function (observer, i) {
        // call the observer function and pass on any additional arguments
        observer.apply(undefined, origArguments);
    });
};


var StringMap = function (iter) {
    this._stringMap = Object.create(null);

    if (iter !== undefined) {
        if (Array.isArray(iter)) {
            iter.forEach(function (pair) {
                if (Array.isArray(pair)) {
                    this.set(pair[0], pair[1]);
                } else {
                    throw new TypeError(typeof pair + ' is not an array');
                }
            }, this);
        } else {
            throw new TypeError(typeof iter + ' is not iterable');
        }
    }
};

Object.defineProperty(StringMap.prototype, 'size', {
    get: function () {
        var size = 0;
        var key;

        for (key in this._stringMap) {
            if (key.charAt(0) === '@') {
                size++;
            }
        }

        return size;
    }
});

StringMap.prototype.set = function (key, value) {
    this._stringMap['@' + key] = value;

    return this;
};

StringMap.prototype.get = function (key) {
    return this._stringMap['@' + key];
};

StringMap.prototype.has = function (key) {
    return (('@' + key) in this._stringMap);
};

StringMap.prototype.delete = function (key) {
    if (this.has(key)) {
        delete this._stringMap['@' + key];

        return true;
    }

    return false;
};

StringMap.prototype.forEach = function (callbackFn, thisArg) {
    Object.keys(this._stringMap).forEach(function (key) {
        if (key.charAt(0) === '@') {
            key = key.substr(1);
            callbackFn.call(thisArg, this.get(key), key, this);
        }
    }, this);
};

StringMap.prototype.keys = function () {
    return Object.keys(this._stringMap).map(function (key) {
        return key.substr(1);
    });
};

StringMap.prototype.toJSON = function () {
    return this._stringMap;
};

StringMap.prototype.toString = function () {
    return Object.prototype.toString.call(this._stringMap);
};


var StringSet = function (iter) {
    this._stringArray = [];
    this._stringMap = new StringMap();
    if (iter !== undefined) {
        if (Array.isArray(iter) || iter instanceof StringSet) {
            iter.forEach(function (string) {
                this.add(string);
            }, this);
        } else {
            throw new TypeError(typeof iter + ' is not iterable');
        }
    }
};

Object.defineProperty(StringSet.prototype, 'size', {
    get: function () {
        return this._stringArray.length;
    }
});

StringSet.prototype.has = function (string) {
    return this._stringMap.has(string);
};

StringSet.prototype.add = function (string) {
    if (!this.has(string)) {
        this._stringMap.set(string, true);
        this._stringArray.push(string);
    }
    return this;
};

StringSet.prototype.delete = function (string) {
    if (this.has(string)) {
        this._stringMap.delete(string);
        this._stringArray.splice(this._stringArray.indexOf(string), 1);
        return true;
    }
    return false;
};

StringSet.prototype.forEach = function (callbackFn, thisArg) {
    this._stringArray.forEach(function (key) {
        callbackFn.call(thisArg, key, key, this);
    });
};

StringSet.prototype.keys = function () {
    return this._stringArray.slice();
};

StringSet.prototype.values = function () {
    return this._stringArray.slice();
};

StringSet.prototype.clear = function () {
    this._stringMap = new StringMap();
    this._stringArray = [];
};

StringSet.prototype.toJSON = function () {
    return this._stringArray;
};

StringSet.prototype.toString = function () {
    return this._stringArray.toString();
};


/*
 * model
 */

var Bookmark = function (url, title, tags, ctime, mtime) {
    var parsedTime;

    if (!isString(url)) {
        throw new TypeError(typeof url + ' is not a string');
    }
    this.url = url;

    this.title = (isString(title) && title !== '') ? title : url;

    if (Array.isArray(tags)) {
        // remove duplicates, non-string or empty tags and tags containing
        // commas
        this.tags = new StringSet(tags.filter(function (tag) {
            return (isString(tag) && tag !== '' && tag.indexOf(',') === -1);
        }).sort());
    } else {
        this.tags = new StringSet();
    }

    if (isNumber(ctime) || isString(ctime)) {
        parsedTime = new Date(ctime);
        this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date();
    } else {
        this.ctime = new Date();
    }

    if (isNumber(mtime) || isString(mtime)) {
        parsedTime = new Date(mtime);
        // modification time must be greater than creation time
        this.mtime = (!isNaN(parsedTime.getTime()) ||
            parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime);
    } else {
        this.mtime = new Date(this.ctime);
    }
};


var BookmarkModel = function () {
    ObservableMixin.call(this);

    this.unsavedChanges = false;
    this._bookmarks = new StringMap();
    this._tagCount = new StringMap();
    this._filterTags = new StringSet();
    this._searchTerm = '';
    this._filteredBookmarks = new StringSet();
    this._searchedBookmarks = new StringSet();
};

extend(BookmarkModel, ObservableMixin);

BookmarkModel.prototype.add = function (bookmarks) {
    var addedBookmarkUrls = new StringSet();

    // argument can be a single bookmark or a list of bookmarks
    if (!Array.isArray(bookmarks)) {
        bookmarks = [bookmarks];
    }

    bookmarks.forEach(function (bookmark) {
        // delete any existing bookmark for the given URL before adding the new
        // one in order to update views
        this.delete(bookmark.url);
        this._bookmarks.set(bookmark.url, bookmark);
        addedBookmarkUrls.add(bookmark.url);
        this.unsavedChanges = true;
        this.notify('bookmark-added', bookmark);

        // update tag count
        bookmark.tags.forEach(function (tag) {
            var tagCount;

            if (this._tagCount.has(tag)) {
                tagCount = this._tagCount.get(tag) + 1;
                this._tagCount.set(tag, tagCount);
                this.notify('tag-count-changed', tag, tagCount);
            } else {
                this._tagCount.set(tag, 1);
                this.notify('tag-added', tag);
            }
        }, this);
    }, this);

    // apply tag filter and search added bookmarks
    this.updateFilteredSearchedBookmarks(addedBookmarkUrls);
    this.notify('filter-tags-search-changed', this._searchedBookmarks,
        this._filterTags, this._searchTerm);
};

BookmarkModel.prototype.has = function (url) {
    return this._bookmarks.has(url);
};

BookmarkModel.prototype.get = function (url) {
    return this._bookmarks.get(url);
};

BookmarkModel.prototype.delete = function (urls) {
    var needUpdateFilterTags = false;

    // argument can be a single bookmark or a list of bookmarks
    if (!Array.isArray(urls)) {
        urls = [urls];
    }

    urls.forEach(function (url) {
        var bookmark;
        var tagCount;

        if (this._bookmarks.has(url)) {
            bookmark = this._bookmarks.get(url);
            this._bookmarks.delete(url);
            this.unsavedChanges = true;
            this.notify('bookmark-deleted', bookmark.url);

            // update tag count
            bookmark.tags.forEach(function (tag) {
                if (this._tagCount.has(tag)) {
                    tagCount = this._tagCount.get(tag);
                    if (tagCount > 1) {
                        tagCount--;
                        this._tagCount.set(tag, tagCount);
                        this.notify('tag-count-changed', tag, tagCount);
                    } else {
                        this._tagCount.delete(tag);
                        this.notify('tag-deleted', tag);

                        if (this._filterTags.has(tag)) {
                            this._filterTags.delete(tag);
                            needUpdateFilterTags = true;
                        }
                    }
                }
            }, this);

            // update filtered and searched bookmarks
            if (this._filteredBookmarks.has(url)) {
                this._filteredBookmarks.delete(url);
                if (this._searchedBookmarks.has(url)) {
                    this._searchedBookmarks.delete(url);
                }
            }
        }
    }, this);

    if (needUpdateFilterTags) {
        this.updateFilteredSearchedBookmarks();
        this.notify('filter-tags-search-changed', this._searchedBookmarks,
            this._filterTags, this._searchTerm);
    }
};

BookmarkModel.prototype.forEach =  function (callbackFn, thisArg) {
    this._bookmarks.keys().forEach(function (key) {
        callbackFn.call(thisArg, this._bookmarks.get(key), key, this);
    }, this);
};

BookmarkModel.prototype.hasTag = function (tag) {
    return this._tagCount.has(tag);
};

BookmarkModel.prototype.getTagCount = function (tag) {
    return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined;
};

BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) {
    var searchUrls;

    // additive search if urlsSubset is given
    if (urlsSubset !== undefined) {
        searchUrls = urlsSubset;
    } else {
        this._searchedBookmarks = new StringSet();

        searchUrls = this._filteredBookmarks.values();
    }

    // search for the search term in title and URL
    searchUrls.forEach(function (url) {
        var bookmark;

        bookmark = this.get(url);
        if (this._searchTerm === '' ||
                bookmark.title.indexOf(this._searchTerm) !== -1 ||
                bookmark.url.indexOf(this._searchTerm) !== -1) {
            this._searchedBookmarks.add(url);
        }
    }, this);
};

BookmarkModel.prototype.updateFilteredSearchedBookmarks =
        function (urlsSubset) {
    var filterUrls;
    var searchUrls;

    // additive filtering if urlsSubset is given
    if (urlsSubset !== undefined) {
        filterUrls = urlsSubset;
        searchUrls = [];
    } else {
        this._filteredBookmarks = new StringSet();

        filterUrls = this._bookmarks.keys();
        searchUrls = undefined;
    }

    // apply tag filter
    filterUrls.forEach(function (url) {
        var bookmark;
        var matchingTagCount = 0;

        bookmark = this.get(url);

        bookmark.tags.forEach(function (tag) {
            if (this._filterTags.has(tag)) {
                matchingTagCount++;
            }
        }, this);

        if (matchingTagCount === this._filterTags.size) {
            this._filteredBookmarks.add(url);
            if (urlsSubset !== undefined) {
                searchUrls.push(url);
            }
        }
    }, this);

    // search the filter results
    this.updateSearchedBookmarks(searchUrls);
};

BookmarkModel.prototype.toggleFilterTag = function (tag) {
    if (this._filterTags.has(tag)) {
        this._filterTags.delete(tag);
    } else {
        this._filterTags.add(tag);
    }
    this.updateFilteredSearchedBookmarks();
    this.notify('filter-tags-search-changed', this._searchedBookmarks,
        this._filterTags, this._searchTerm);
};

BookmarkModel.prototype.setFilterTags = function (filterTags) {
    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
        this._filterTags = new StringSet(filterTags);
        this.updateFilteredSearchedBookmarks();
        this.notify('filter-tags-search-changed', this._searchedBookmarks,
            this._filterTags, this._searchTerm);
    }
};

BookmarkModel.prototype.setSearchTerm = function (searchTerm) {
    if (searchTerm !== this._searchTerm) {
        this._searchTerm = searchTerm;
        this.updateSearchedBookmarks();
        this.notify('filter-tags-search-changed', this._searchedBookmarks,
            this._filterTags, this._searchTerm);
    }
};

BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags,
        searchTerm) {
    if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
        this._filterTags = new StringSet(filterTags);
        this._searchTerm = searchTerm;
        this.updateFilteredSearchedBookmarks();
        this.notify('filter-tags-search-changed', this._searchedBookmarks,
            this._filterTags, this._searchTerm);
    } else if (searchTerm !== this._searchTerm) {
        this._searchTerm = searchTerm;
        this.updateSearchedBookmarks();
        this.notify('filter-tags-search-changed', this._searchedBookmarks,
            this._filterTags, this._searchTerm);
    }
};

BookmarkModel.prototype.parseLoadedBookmarks = function (data) {
    var parsedData;
    var bookmarks = [];

    try {
        parsedData = JSON.parse(data);
    } catch (e) {
        this.notify('load-file-error', e.message);
        return;
    }

    if (!Array.isArray(parsedData.bookmarks)) {
        this.notify('parse-file-error',
            'This file does not contain bookmarks.');
        return;
    }

    // create a temporary list of valid bookmarks
    parsedData.bookmarks.forEach(function (bookmark) {
        if (isString(bookmark.url) && bookmark.url !== '') {
            bookmarks.push(new Bookmark(bookmark.url, bookmark.title,
                bookmark.tags, bookmark.ctime, bookmark.mtime));
        }
    }, this);

    // add each bookmark to the model ordered by the last modification time
    this.add(bookmarks.sort(function (bookmark1, bookmark2) {
        return bookmark1.ctime - bookmark2.ctime;
    }));
    this.unsavedChanges = false;
};

BookmarkModel.prototype.loadFile = function (bookmarkFile) {
    var bookmarkFileReader;

    // delete all existing bookmarks first
    this.delete(this._bookmarks.keys());
    this.unsavedChanges = false;

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

BookmarkModel.prototype.saveFile = function () {
    var jsonBlob;
    var bookmarkData = {
        'bookmarks': []
    };

    this._bookmarks.forEach(function (bookmark) {
        bookmarkData.bookmarks.push(bookmark);
    }, this);

    jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type:
        'application/json'});
    this.notify('save-file', jsonBlob);
    this.unsavedChanges = false;
};

BookmarkModel.prototype.handleEvent = function (e) {
    if (e.type === 'load') {
        this.parseLoadedBookmarks(e.target.result);
    } else if (e.type === 'error') {
        this.notify('load-file-error', e.target.error.message);
    }
};


/*
 * view
 */

var TagView = function () {
    ObservableMixin.call(this);

    this.tagListElement = document.querySelector('#tags ul.tag-list');
    this.tagListElement.addEventListener('click', this);

    this.tagTemplate = document.querySelector('#tag-template');
};

extend(TagView, ObservableMixin);

TagView.prototype.onTagAdded = function (tag) {
    var newNode;
    var tagElement;
    var setTagButton;
    var toggleTagButton;
    var tagElements;
    var i;
    var referenceTag = '';
    var referenceNode;

    // create new tag element from template
    newNode = document.importNode(this.tagTemplate.content, true);

    tagElement = newNode.querySelector('li');
    tagElement.dataset.tag = tag;

    setTagButton = tagElement.querySelector('button[name="set-tag"]');
    setTagButton.textContent = tag;
    setTagButton.title = 'Set filter to "' + tag + '"';

    toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]');
    toggleTagButton.textContent = '+';
    toggleTagButton.title = 'Add "' + tag + '" to filter';

    // maintain alphabetical order when inserting the tag element
    tagElements = this.tagListElement.querySelectorAll('li');
    for (i = 0; i < tagElements.length; i ++) {
        if (tagElements[i].dataset.tag > referenceTag &&
                tagElements[i].dataset.tag < tag) {
            referenceTag = tagElements[i].dataset.tag;
            referenceNode = tagElements[i];
        }
    }
    this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ?
        referenceNode.nextSibling : this.tagListElement.firstChild);

    // initialize tag count
    this.onTagCountChanged(tag, 1);
};

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

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

    tagElement = this.tagListElement.querySelector('li' +
        createDatasetSelector('tag', tag));
    if (tagElement !== null) {
        tagElement.parentNode.removeChild(tagElement);
    }
};

TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
        newFilterTags, newSearchTerm) {
    var tagElements;
    var i;
    var tag;
    var toggleTagButton;

    tagElements = this.tagListElement.querySelectorAll('li');
    for (i = 0; i < tagElements.length; i++) {
        tag = tagElements[i].dataset.tag;
        toggleTagButton =
            tagElements[i].querySelector('button[name="toggle-tag"]');
        if (newFilterTags.has(tag)) {
            tagElements[i].classList.add('active-filter-tag');
            toggleTagButton.textContent = '\u2212';
            toggleTagButton.title = 'Remove "' + tag + '" from filter';
        } else {
            tagElements[i].classList.remove('active-filter-tag');
            toggleTagButton.textContent = '+';
            toggleTagButton.title = 'Add "' + tag + '" to filter';
        }
    }
};

TagView.prototype.handleEvent = function (e) {
    if (e.type === 'click' && (e.target.name === 'set-tag' ||
            e.target.name === 'toggle-tag')) {
        e.target.blur();

        this.notify(e.target.name, getAncestorElementDatasetItem(e.target,
            'tag'));
    }
};


var ActionsView = function () {
    var saveFormElement;
    var loadFormElement;
    var newNode;
    var editorFormElement;

    ObservableMixin.call(this);

    this.tagInputTemplate = document.querySelector('#tag-input-template');
    saveFormElement = document.querySelector('form#save-form');
    saveFormElement.addEventListener('submit', this);

    this.saveLinkElement = saveFormElement.querySelector('a#save-link');

    loadFormElement = document.querySelector('form#load-form');
    loadFormElement.addEventListener('submit', this);

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

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

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

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

extend(ActionsView, ObservableMixin);

ActionsView.prototype.createTagInputElement = function (tag) {
    var newNode;

    newNode = document.importNode(this.tagInputTemplate.content, true);
    newNode.querySelector('input[name="tag"]').value = tag;

    return newNode;
};

ActionsView.prototype.handleEvent = function (e) {
    var tags = [];
    var i;

    switch (e.type) {
    case 'click':
        if (e.target.name === 'more-tags') {
            e.preventDefault();
            e.target.blur();

            this.editTagListElement.appendChild(this.createTagInputElement(''));
        }
        break;
    case 'submit':
        if (e.target.id === 'save-form') {
            e.preventDefault();
            e.target.blur();

            this.notify('save-file');
        } else if (e.target.id === 'load-form') {
            e.preventDefault();
            e.target.blur();

            this.notify('load-file', e.target.file.files[0]);
            e.target.reset();
        } else if (e.target.classList.contains('bookmark-editor-form')) {
            e.preventDefault();
            e.target.blur();

            if (e.target.tag.length) {
                for (i = 0; i < e.target.tag.length; i++) {
                    tags.push(e.target.tag[i].value.trim());
                }
            } else {
                tags.push(e.target.tag.value.trim());
            }

            this.notify('save-bookmark', e.target.url.value,
                e.target.title.value, tags);

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

            // remove all but one tag input element
            while (this.editTagListElement.firstChild !== null) {
                this.editTagListElement.removeChild(
                    this.editTagListElement.firstChild);
            }
            this.editTagListElement.appendChild(this.createTagInputElement(''));
        }
        break;
    }
};

ActionsView.prototype.onSaveFile = function (jsonBlob) {
    this.saveLinkElement.href = URL.createObjectURL(jsonBlob);
    this.saveLinkElement.click();
};

ActionsView.prototype.confirmLoadFile = function () {
    return window.confirm('There are unsaved changes to your bookmarks.\n' +
        'Proceed loading the bookmark file?');
};

ActionsView.prototype.onLoadFileError = function (message) {
    window.alert('Failed to load bookmark file:\n' + message);
};

ActionsView.prototype.onParseFileError = function (message) {
    window.alert('Failed to parse bookmark file:\n' + message);
};


var BookmarkView = function () {
    var searchFormElement;

    ObservableMixin.call(this);

    this.bookmarkTemplate = document.querySelector('#bookmark-template');
    this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template');
    this.bookmarkEditorTemplate =
        document.querySelector('#bookmark-editor-template');
    this.tagInputTemplate = document.querySelector('#tag-input-template');

    this.bookmarkListElement = document.querySelector('ul#bookmark-list');
    this.bookmarkListElement.addEventListener('click', this);
    this.bookmarkListElement.addEventListener('submit', this);
    this.bookmarkListElement.addEventListener('reset', this);

    searchFormElement = document.querySelector('#search-form');
    searchFormElement.addEventListener('submit', this);
    searchFormElement.addEventListener('reset', this);

    this.searchTermInputElement = searchFormElement['search-term'];

    this.bookmarkMessageElement = document.querySelector('#bookmark-message');

    this.updateBookmarkMessage();
};

extend(BookmarkView, ObservableMixin);

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

    switch (e.type) {
    case 'click':
        switch (e.target.name) {
        case 'edit-bookmark':
            e.target.blur();
            // fallthrough
        case 'delete-bookmark':
            this.notify(e.target.name,
                getAncestorElementDatasetItem(e.target, 'bookmarkUrl'));
            break;
        case 'more-tags':
            e.target.blur();

            e.target.form.querySelector('ul.tag-input-list').appendChild(
                this.createTagInputElement(''));
            break;
        case 'set-tag':
        case 'toggle-tag':
            e.target.blur();

            this.notify(e.target.name,
                getAncestorElementDatasetItem(e.target, 'tag'));
            break;
        }
        break;
    case 'submit':
        if (e.target.classList.contains('bookmark-editor-form')) {
            // save bookmark-editor-form form contents
            e.preventDefault();

            if (e.target.tag.length) {
                for (i = 0; i < e.target.tag.length; i++) {
                    tags.push(e.target.tag[i].value.trim());
                }
            } else {
                tags.push(e.target.tag.value.trim());
            }

            this.notify('save-bookmark', e.target.url.value,
                e.target.title.value, tags, e.target['original-url'].value);
        } else if (e.target.id === 'search-form') {
            // search
            e.preventDefault();
            e.target.blur();

            this.notify('search', e.target['search-term'].value);
        }
        break;
    case 'reset':
        if (e.target.classList.contains('bookmark-editor-form')) {
            // cancel bookmark-editor-form form
            e.preventDefault();

            // re-enable edit button
            this.bookmarkListElement.querySelector('li' +
                createDatasetSelector('bookmark-url',
                e.target['original-url'].value) +
                ' button[name="edit-bookmark"]').disabled = false;

            e.target.parentNode.removeChild(e.target);
        } else if (e.target.id === 'search-form') {
            // clear search
            e.preventDefault();
            e.target.blur();

            this.notify('search', '');
        }
        break;
    }
};

BookmarkView.prototype.updateBookmarkMessage = function () {
    this.bookmarkMessageElement.textContent = 'Showing ' +
        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
        'li:not([hidden])').length + ' of ' +
        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
        'li').length + ' bookmarks.';
};

BookmarkView.prototype.onBookmarkAdded = function (bookmark) {
    var newNode;
    var bookmarkElement;
    var linkElement;
    var tagListElement;

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

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

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

    tagListElement = bookmarkElement.querySelector('ul.tag-list');
    bookmark.tags.forEach(function (tag) {
        var newNode;
        var tagElement;
        var setTagButton;
        var toggleTagButton;

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

        tagElement = newNode.querySelector('li');
        tagElement.dataset.tag = tag;

        setTagButton = newNode.querySelector('button[name="set-tag"]');
        setTagButton.textContent = tag;
        setTagButton.title = 'Set filter to "' + tag + '"';

        toggleTagButton = newNode.querySelector('button[name="toggle-tag"]');
        toggleTagButton.textContent = '+';
        toggleTagButton.title = 'Add "' + tag + '" to filter';

        tagListElement.appendChild(newNode);
    }, this);

    // insert new or last modified bookmark on top of the list
    this.bookmarkListElement.insertBefore(newNode,
        this.bookmarkListElement.firstChild);

    this.updateBookmarkMessage();
};

BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) {
    var bookmarkElement;

    bookmarkElement = this.bookmarkListElement.querySelector('li' +
        createDatasetSelector('bookmark-url', bookmarkUrl));
    if (bookmarkElement !== null) {
        this.bookmarkListElement.removeChild(bookmarkElement);

        this.updateBookmarkMessage();
    }
};

BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
        newFilterTags, newSearchTerm) {
    var bookmarkElements;
    var i;
    var tagElements;
    var toggleTagButton;
    var j;
    var tag;

    this.searchTermInputElement.value = newSearchTerm;

    bookmarkElements =
        this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li');
    for (i = 0; i < bookmarkElements.length; i++) {
        // update visibility of bookmarks
        if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) {
            // update tag elements of visible bookmarks
            tagElements =
                bookmarkElements[i].querySelectorAll('ul.tag-list > li');
            for (j = 0; j < tagElements.length; j++) {
                tag = tagElements[j].dataset.tag;
                toggleTagButton =
                    tagElements[j].querySelector('button[name="toggle-tag"]');
                if (newFilterTags.has(tag)) {
                    tagElements[j].classList.add('active-filter-tag');
                    toggleTagButton.textContent = '\u2212';
                    toggleTagButton.title = 'Remove "' + tag + '" from filter';
                } else {
                    tagElements[j].classList.remove('active-filter-tag');
                    toggleTagButton.textContent = '+';
                    toggleTagButton.title = 'Add "' + tag + '" to filter';
                }
            }
            bookmarkElements[i].hidden = false;
        } else {
            bookmarkElements[i].hidden = true;
        }
    }

    this.updateBookmarkMessage();
};

BookmarkView.prototype.createTagInputElement = function (tag) {
    var newNode;

    newNode = document.importNode(this.tagInputTemplate.content, true);
    newNode.querySelector('input[name="tag"]').value = tag;

    return newNode;
};

BookmarkView.prototype.displayBookmarkEditor = function (bookmark) {
    var bookmarkElement;
    var newNode;
    var formElement;
    var editTagListElement;

    bookmarkElement =
        this.bookmarkListElement.querySelector('ul#bookmark-list > li' +
        createDatasetSelector('bookmark-url', bookmark.url));

    // disable edit button while editing
    bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled =
        true;

    // create new editor form from template
    newNode = document.importNode(this.bookmarkEditorTemplate.content, true);

    // fill with data of given bookmark
    formElement = newNode.querySelector('form.bookmark-editor-form');
    formElement.querySelector('legend').textContent = 'Edit Bookmark';
    formElement['original-url'].value = bookmark.url;
    formElement.url.value = bookmark.url;
    formElement.title.value = bookmark.title;

    editTagListElement = formElement.querySelector('ul.tag-input-list');
    bookmark.tags.forEach(function (tag) {
        editTagListElement.appendChild(this.createTagInputElement(tag));
    }, this);
    editTagListElement.appendChild(this.createTagInputElement(''));

    // insert editor form into bookmark item
    bookmarkElement.appendChild(newNode);

    // focus first input element
    formElement.querySelector('input').focus();
};

BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) {
    return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' +
        bookmark.url + ']?');
};

BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) {
    return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' +
        bookmark.url + ']?');
};


/*
 * controller
 */

var BooketController = function(bookmarkModel, actionsView, tagView,
        bookmarkView) {
    this.bookmarkModel = bookmarkModel;
    this.actionsView = actionsView;
    this.tagView = tagView;
    this.bookmarkView = bookmarkView;

    /* connect the views to the model */
    this.bookmarkModel.addObserver('bookmark-added',
        this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView));
    this.bookmarkModel.addObserver('bookmark-deleted',
        this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView));
    this.bookmarkModel.addObserver('filter-tags-search-changed',
        this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView));
    this.bookmarkModel.addObserver('load-file-error',
        this.actionsView.onLoadFileError.bind(this.actionsView));
    this.bookmarkModel.addObserver('parse-file-error',
        this.actionsView.onParseFileError.bind(this.actionsView));
    this.bookmarkModel.addObserver('save-file',
        this.actionsView.onSaveFile.bind(this.actionsView));
    this.bookmarkModel.addObserver('tag-added',
        this.tagView.onTagAdded.bind(this.tagView));
    this.bookmarkModel.addObserver('tag-count-changed',
        this.tagView.onTagCountChanged.bind(this.tagView));
    this.bookmarkModel.addObserver('tag-deleted',
        this.tagView.onTagDeleted.bind(this.tagView));
    this.bookmarkModel.addObserver('filter-tags-search-changed',
        this.tagView.onFilterTagsSearchChanged.bind(this.tagView));
    this.bookmarkModel.addObserver('filter-tags-search-changed',
        this.onFilterTagsSearchChanged.bind(this));

    /* handle input */
    window.addEventListener('hashchange', this.onHashChange.bind(this));
    window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
    this.actionsView.addObserver('save-file',
        this.bookmarkModel.saveFile.bind(this.bookmarkModel));
    this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
    this.actionsView.addObserver('save-bookmark',
        this.onSaveBookmark.bind(this));
    this.bookmarkView.addObserver('edit-bookmark',
        this.onEditBookmark.bind(this));
    this.bookmarkView.addObserver('save-bookmark',
        this.onSaveBookmark.bind(this));
    this.bookmarkView.addObserver('delete-bookmark',
        this.onDeleteBookmark.bind(this));
    this.bookmarkView.addObserver('toggle-tag',
        this.onToggleFilterTag.bind(this));
    this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this));
    this.bookmarkView.addObserver('search', this.onSearch.bind(this));
    this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this));
    this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this));
};

BooketController.prototype.parseTagsParameter = function (tagsString) {
    var tags;

    tags = tagsString.split(',').filter(function (tag) {
        return (tag !== '') && this.bookmarkModel.hasTag(tag);
    }, this).sort();

    return new StringSet(tags);
};

BooketController.prototype.onHashChange = function (e) {
    var hashData;
    var filterTags;
    var searchTerm;

    hashData = parseHash(window.location.href);

    filterTags = hashData.has('tags') ?
        this.parseTagsParameter(hashData.get('tags')) : new StringSet();

    searchTerm = hashData.has('search') ? hashData.get('search') : '';

    this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm);
};

BooketController.prototype.onBeforeUnload = function (e) {
    var confirmationMessage = 'There are unsaved changes to your bookmarks.';

    if (this.bookmarkModel.unsavedChanges) {
        if (e) {
            e.returnValue = confirmationMessage;
        }
        if (window.event) {
            window.event.returnValue = confirmationMessage;
        }
        return confirmationMessage;
    }
};

BooketController.prototype.onFilterTagsSearchChanged =
        function (filteredBookmarks, newFilterTags, newSearchTerm) {
    var url = window.location.href;
    var hashData;

    // serialize tag filter and search term and update window.location
    hashData = parseHash(url);
    hashData.set('tags', newFilterTags.values().join(','));
    hashData.set('search', newSearchTerm);
    history.pushState(null, null, serializeHash(url, hashData));
};

BooketController.prototype.onLoadFile = function (bookmarkFile) {
    if (this.bookmarkModel.unsavedChanges) {
        if (!this.actionsView.confirmLoadFile()) {
            return;
        }
        this.bookmarkModel.unsavedChanges = false;
    }

    this.bookmarkModel.loadFile(bookmarkFile);
};

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

BooketController.prototype.onSaveBookmark = function (url, title, tags,
        originalUrl) {
    var ctime;

    if (originalUrl === undefined) {
        // saving new bookmark, get confirmation before replacing existing one
        if (this.bookmarkModel.has(url)) {
            if (this.bookmarkView.confirmReplaceBookmark(
                    this.bookmarkModel.get(url))) {
                this.bookmarkModel.delete(url);
            } else {
                return;
            }
        }

        ctime = new Date();
    } else {
        // saving edited bookmark, preserve creation time of any replaced
        // bookmark
        ctime = (this.bookmarkModel.has(url)) ?
            this.bookmarkModel.get(url).ctime : new Date();

        this.bookmarkModel.delete(originalUrl);
    }
    this.bookmarkModel.add(new Bookmark(url, title, tags, ctime));
};

BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) {
    if (this.bookmarkView.confirmDeleteBookmark(
            this.bookmarkModel.get(bookmarkUrl))) {
        this.bookmarkModel.delete(bookmarkUrl);
    }
};

BooketController.prototype.onToggleFilterTag = function (tag) {
    this.bookmarkModel.toggleFilterTag(tag);
};

BooketController.prototype.onSetTagFilter = function (tag) {
    this.bookmarkModel.setFilterTags(new StringSet([tag]));
};

BooketController.prototype.onSearch = function (searchTerm) {
    this.bookmarkModel.setSearchTerm(searchTerm);
};


document.addEventListener('DOMContentLoaded', function (e) {
    var controller;
    var bookmarkModel;
    var actionsView;
    var tagView;
    var bookmarkView;
    var hashChangeEvent;

    bookmarkModel = new BookmarkModel();
    tagView = new TagView();
    actionsView = new ActionsView();
    bookmarkView = new BookmarkView();
    controller = new BooketController(bookmarkModel, actionsView,
        tagView, bookmarkView);

    // initialize state from the current URL
    hashChangeEvent = new Event('hashchange');
    hashChangeEvent.oldURL = window.location.href;
    hashChangeEvent.newURL = window.location.href;
    window.dispatchEvent(hashChangeEvent);
});
}());