Mercurial > projects > booket
diff booket.js @ 0:c2248f662a2c version-1
Initial revision
author | Guido Berhoerster <guido+booket@berhoerster.name> |
---|---|
date | Sat, 06 Sep 2014 18:18:29 +0200 |
parents | |
children | 82c50265c8dc |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/booket.js Sat Sep 06 18:18:29 2014 +0200 @@ -0,0 +1,1379 @@ +/* + * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name> + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be included + * in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +(function () { +'use strict'; + +/* + * utility stuff + */ + +function isNumber(number) { + return (Object.prototype.toString.call(number) === '[object Number]'); +} + +function isString(number) { + return (Object.prototype.toString.call(number) === '[object String]'); +} + +function arrayEqual(array1, array2) { + if (!Array.isArray(array1)) { + throw new TypeError(typeof array1 + ' is not an array'); + } else if (!Array.isArray(array2)) { + throw new TypeError(typeof array2 + ' is not an array'); + } + + if (array1.length !== array2.length) { + return false; + } else if (array1.length === 0 && array2.length === 0) { + return true; + } + + return array1.slice().sort().every(function (value, i) { + return value === array2[i]; + }); +} + +function parseHash(url) { + var hashData; + var pos; + var hash; + var hashParts; + var key; + var value; + var i; + + hashData = new StringMap(); + pos = url.indexOf('#'); + hash = (pos > -1) ? url.substr(pos + 1) : ''; + // hash parts are seperated by a ';' + hashParts = hash.split(';'); + for (i = 0; i < hashParts.length; i++) { + // key and value pairs are seperated by a '=', an empty value will + // cause the key to be ignored + pos = hashParts[i].indexOf('='); + if (pos > -1) { + key = decodeURIComponent(hashParts[i].substr(0, pos)); + value = decodeURIComponent(hashParts[i].substr(pos + 1)); + hashData.set(key, value); + } + } + + return hashData; +} + +function serializeHash(url, hashData) { + var hashParts = []; + var pos; + + pos = url.indexOf('#'); + if (pos > -1) { + url = url.substr(0, pos); + } + + hashData.forEach(function (value, key) { + if (value !== '') { + hashParts.push(encodeURIComponent(key) + '=' + + encodeURIComponent(value)); + } + }); + + // only append a '#' if there are any hash parts + return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : ''); +} + +function getAncestorElementDatasetItem(node, item) { + while ((node = node.parentNode) !== null) { + if (node.dataset && node.dataset[item] !== undefined) { + return node.dataset[item]; + } + } + + return undefined; +} + +// for use with Node.querySelector() and Node.querySelectorAll() +function createDatasetSelector(name, value) { + return '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]'; +} + +function extend(targetObject, sourceObject) { + var propertyName; + + for (propertyName in sourceObject.prototype) { + if (!Object.prototype.hasOwnProperty.call(targetObject.prototype, + propertyName)) { + targetObject.prototype[propertyName] = + sourceObject.prototype[propertyName]; + } + } +} + + +var ObservableMixin = function () { + this._eventsObservers = {}; +}; + +ObservableMixin.prototype.addObserver = function (eventName, observer) { + var i; + + if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, + eventName)) { + this._eventsObservers[eventName] = []; + } + + // prevent observers for an event from being called more than once + for (i = 0; i < this._eventsObservers[eventName].length; i++) { + if (this._eventsObservers[eventName][i] === observer) { + return; + } + } + this._eventsObservers[eventName].push(observer); +}; + +ObservableMixin.prototype.deleteObserver = function (eventName, observer) { + var i = 0; + + if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, + eventName)) { + return; + } + + while (i < this._eventsObservers[eventName].length) { + if (this._eventsObservers[eventName][i] === observer) { + this._eventsObservers[eventName].splice(i, 1); + } + } +}; + +ObservableMixin.prototype.notify = function (eventName) { + var origArguments; + + if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, + eventName)) { + return; + } + + origArguments = Array.prototype.slice.call(arguments, 1); + this._eventsObservers[eventName].forEach(function (observer, i) { + // call the observer function and pass on any additional arguments + observer.apply(undefined, origArguments); + }); +}; + + +var StringMap = function (iter) { + this._stringMap = Object.create(null); + + if (iter !== undefined) { + if (Array.isArray(iter)) { + iter.forEach(function (pair) { + if (Array.isArray(pair)) { + this.set(pair[0], pair[1]); + } else { + throw new TypeError(typeof pair + ' is not an array'); + } + }, this); + } else { + throw new TypeError(typeof iter + ' is not iterable'); + } + } +}; + +Object.defineProperty(StringMap.prototype, 'size', { + get: function () { + var size = 0; + var key; + + for (key in this._stringMap) { + if (key.charAt(0) === '@') { + size++; + } + } + + return size; + } +}); + +StringMap.prototype.set = function (key, value) { + this._stringMap['@' + key] = value; + + return this; +}; + +StringMap.prototype.get = function (key) { + return this._stringMap['@' + key]; +}; + +StringMap.prototype.has = function (key) { + return (('@' + key) in this._stringMap); +}; + +StringMap.prototype.delete = function (key) { + if (this.has(key)) { + delete this._stringMap['@' + key]; + + return true; + } + + return false; +}; + +StringMap.prototype.forEach = function (callbackFn, thisArg) { + Object.keys(this._stringMap).forEach(function (key) { + if (key.charAt(0) === '@') { + key = key.substr(1); + callbackFn.call(thisArg, this.get(key), key, this); + } + }, this); +}; + +StringMap.prototype.keys = function () { + return Object.keys(this._stringMap).map(function (key) { + return key.substr(1); + }); +}; + +StringMap.prototype.toJSON = function () { + return this._stringMap; +}; + +StringMap.prototype.toString = function () { + return Object.prototype.toString.call(this._stringMap); +}; + + +var StringSet = function (iter) { + this._stringArray = []; + this._stringMap = new StringMap(); + if (iter !== undefined) { + if (Array.isArray(iter) || iter instanceof StringSet) { + iter.forEach(function (string) { + this.add(string); + }, this); + } else { + throw new TypeError(typeof iter + ' is not iterable'); + } + } +}; + +Object.defineProperty(StringSet.prototype, 'size', { + get: function () { + return this._stringArray.length; + } +}); + +StringSet.prototype.has = function (string) { + return this._stringMap.has(string); +}; + +StringSet.prototype.add = function (string) { + if (!this.has(string)) { + this._stringMap.set(string, true); + this._stringArray.push(string); + } + return this; +}; + +StringSet.prototype.delete = function (string) { + if (this.has(string)) { + this._stringMap.delete(string); + this._stringArray.splice(this._stringArray.indexOf(string), 1); + return true; + } + return false; +}; + +StringSet.prototype.forEach = function (callbackFn, thisArg) { + this._stringArray.forEach(function (key) { + callbackFn.call(thisArg, key, key, this); + }); +}; + +StringSet.prototype.keys = function () { + return this._stringArray.slice(); +}; + +StringSet.prototype.values = function () { + return this._stringArray.slice(); +}; + +StringSet.prototype.clear = function () { + this._stringMap = new StringMap(); + this._stringArray = []; +}; + +StringSet.prototype.toJSON = function () { + return this._stringArray; +}; + +StringSet.prototype.toString = function () { + return this._stringArray.toString(); +}; + + +/* + * model + */ + +var Bookmark = function (url, title, tags, ctime, mtime) { + var parsedTime; + + if (!isString(url)) { + throw new TypeError(typeof url + ' is not a string'); + } + this.url = url; + + this.title = (isString(title) && title !== '') ? title : url; + + if (Array.isArray(tags)) { + // remove duplicates, non-string or empty tags and tags containing + // commas + this.tags = new StringSet(tags.filter(function (tag) { + return (isString(tag) && tag !== '' && tag.indexOf(',') === -1); + }).sort()); + } else { + this.tags = new StringSet(); + } + + if (isNumber(ctime) || isString(ctime)) { + parsedTime = new Date(ctime); + this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date(); + } else { + this.ctime = new Date(); + } + + if (isNumber(mtime) || isString(mtime)) { + parsedTime = new Date(mtime); + // modification time must be greater than creation time + this.mtime = (!isNaN(parsedTime.getTime()) || + parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime); + } else { + this.mtime = new Date(this.ctime); + } +}; + + +var BookmarkModel = function () { + ObservableMixin.call(this); + + this.unsavedChanges = false; + this._bookmarks = new StringMap(); + this._tagCount = new StringMap(); + this._filterTags = new StringSet(); + this._searchTerm = ''; + this._filteredBookmarks = new StringSet(); + this._searchedBookmarks = new StringSet(); +}; + +extend(BookmarkModel, ObservableMixin); + +BookmarkModel.prototype.add = function (bookmarks) { + var addedBookmarkUrls = new StringSet(); + + // argument can be a single bookmark or a list of bookmarks + if (!Array.isArray(bookmarks)) { + bookmarks = [bookmarks]; + } + + bookmarks.forEach(function (bookmark) { + // delete any existing bookmark for the given URL before adding the new + // one in order to update views + this.delete(bookmark.url); + this._bookmarks.set(bookmark.url, bookmark); + addedBookmarkUrls.add(bookmark.url); + this.unsavedChanges = true; + this.notify('bookmark-added', bookmark); + + // update tag count + bookmark.tags.forEach(function (tag) { + var tagCount; + + if (this._tagCount.has(tag)) { + tagCount = this._tagCount.get(tag) + 1; + this._tagCount.set(tag, tagCount); + this.notify('tag-count-changed', tag, tagCount); + } else { + this._tagCount.set(tag, 1); + this.notify('tag-added', tag); + } + }, this); + }, this); + + // apply tag filter and search added bookmarks + this.updateFilteredSearchedBookmarks(addedBookmarkUrls); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); +}; + +BookmarkModel.prototype.has = function (url) { + return this._bookmarks.has(url); +}; + +BookmarkModel.prototype.get = function (url) { + return this._bookmarks.get(url); +}; + +BookmarkModel.prototype.delete = function (urls) { + var needUpdateFilterTags = false; + + // argument can be a single bookmark or a list of bookmarks + if (!Array.isArray(urls)) { + urls = [urls]; + } + + urls.forEach(function (url) { + var bookmark; + var tagCount; + + if (this._bookmarks.has(url)) { + bookmark = this._bookmarks.get(url); + this._bookmarks.delete(url); + this.unsavedChanges = true; + this.notify('bookmark-deleted', bookmark.url); + + // update tag count + bookmark.tags.forEach(function (tag) { + if (this._tagCount.has(tag)) { + tagCount = this._tagCount.get(tag); + if (tagCount > 1) { + tagCount--; + this._tagCount.set(tag, tagCount); + this.notify('tag-count-changed', tag, tagCount); + } else { + this._tagCount.delete(tag); + this.notify('tag-deleted', tag); + + if (this._filterTags.has(tag)) { + this._filterTags.delete(tag); + needUpdateFilterTags = true; + } + } + } + }, this); + + // update filtered and searched bookmarks + if (this._filteredBookmarks.has(url)) { + this._filteredBookmarks.delete(url); + if (this._searchedBookmarks.has(url)) { + this._searchedBookmarks.delete(url); + } + } + } + }, this); + + if (needUpdateFilterTags) { + this.updateFilteredSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); + } +}; + +BookmarkModel.prototype.forEach = function (callbackFn, thisArg) { + this._bookmarks.keys().forEach(function (key) { + callbackFn.call(thisArg, this._bookmarks.get(key), key, this); + }, this); +}; + +BookmarkModel.prototype.hasTag = function (tag) { + return this._tagCount.has(tag); +}; + +BookmarkModel.prototype.getTagCount = function (tag) { + return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined; +}; + +BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) { + var searchUrls; + + // additive search if urlsSubset is given + if (urlsSubset !== undefined) { + searchUrls = urlsSubset; + } else { + this._searchedBookmarks = new StringSet(); + + searchUrls = this._filteredBookmarks.values(); + } + + // search for the search term in title and URL + searchUrls.forEach(function (url) { + var bookmark; + + bookmark = this.get(url); + if (this._searchTerm === '' || + bookmark.title.indexOf(this._searchTerm) !== -1 || + bookmark.url.indexOf(this._searchTerm) !== -1) { + this._searchedBookmarks.add(url); + } + }, this); +}; + +BookmarkModel.prototype.updateFilteredSearchedBookmarks = + function (urlsSubset) { + var filterUrls; + var searchUrls; + + // additive filtering if urlsSubset is given + if (urlsSubset !== undefined) { + filterUrls = urlsSubset; + searchUrls = []; + } else { + this._filteredBookmarks = new StringSet(); + + filterUrls = this._bookmarks.keys(); + searchUrls = undefined; + } + + // apply tag filter + filterUrls.forEach(function (url) { + var bookmark; + var matchingTagCount = 0; + + bookmark = this.get(url); + + bookmark.tags.forEach(function (tag) { + if (this._filterTags.has(tag)) { + matchingTagCount++; + } + }, this); + + if (matchingTagCount === this._filterTags.size) { + this._filteredBookmarks.add(url); + if (urlsSubset !== undefined) { + searchUrls.push(url); + } + } + }, this); + + // search the filter results + this.updateSearchedBookmarks(searchUrls); +}; + +BookmarkModel.prototype.toggleFilterTag = function (tag) { + if (this._filterTags.has(tag)) { + this._filterTags.delete(tag); + } else { + this._filterTags.add(tag); + } + this.updateFilteredSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); +}; + +BookmarkModel.prototype.setFilterTags = function (filterTags) { + if (!arrayEqual(filterTags.values(), this._filterTags.values())) { + this._filterTags = new StringSet(filterTags); + this.updateFilteredSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); + } +}; + +BookmarkModel.prototype.setSearchTerm = function (searchTerm) { + if (searchTerm !== this._searchTerm) { + this._searchTerm = searchTerm; + this.updateSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); + } +}; + +BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags, + searchTerm) { + if (!arrayEqual(filterTags.values(), this._filterTags.values())) { + this._filterTags = new StringSet(filterTags); + this._searchTerm = searchTerm; + this.updateFilteredSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); + } else if (searchTerm !== this._searchTerm) { + this._searchTerm = searchTerm; + this.updateSearchedBookmarks(); + this.notify('filter-tags-search-changed', this._searchedBookmarks, + this._filterTags, this._searchTerm); + } +}; + +BookmarkModel.prototype.parseLoadedBookmarks = function (data) { + var parsedData; + var bookmarks = []; + + try { + parsedData = JSON.parse(data); + } catch (e) { + this.notify('load-file-error', e.message); + return; + } + + if (!Array.isArray(parsedData.bookmarks)) { + this.notify('parse-file-error', + 'This file does not contain bookmarks.'); + return; + } + + // create a temporary list of valid bookmarks + parsedData.bookmarks.forEach(function (bookmark) { + if (isString(bookmark.url) && bookmark.url !== '') { + bookmarks.push(new Bookmark(bookmark.url, bookmark.title, + bookmark.tags, bookmark.ctime, bookmark.mtime)); + } + }, this); + + // add each bookmark to the model ordered by the last modification time + this.add(bookmarks.sort(function (bookmark1, bookmark2) { + return bookmark1.ctime - bookmark2.ctime; + })); + this.unsavedChanges = false; +}; + +BookmarkModel.prototype.loadFile = function (bookmarkFile) { + var bookmarkFileReader; + + // delete all existing bookmarks first + this.delete(this._bookmarks.keys()); + this.unsavedChanges = false; + + bookmarkFileReader = new FileReader(); + bookmarkFileReader.addEventListener('error', this); + bookmarkFileReader.addEventListener('load', this); + bookmarkFileReader.readAsText(bookmarkFile); +}; + +BookmarkModel.prototype.saveFile = function () { + var jsonBlob; + var bookmarkData = { + 'bookmarks': [] + }; + + this._bookmarks.forEach(function (bookmark) { + bookmarkData.bookmarks.push(bookmark); + }, this); + + jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type: + 'application/json'}); + this.notify('save-file', jsonBlob); + this.unsavedChanges = false; +}; + +BookmarkModel.prototype.handleEvent = function (e) { + if (e.type === 'load') { + this.parseLoadedBookmarks(e.target.result); + } else if (e.type === 'error') { + this.notify('load-file-error', e.target.error.message); + } +}; + + +/* + * view + */ + +var TagView = function () { + ObservableMixin.call(this); + + this.tagListElement = document.querySelector('#tags ul.tag-list'); + this.tagListElement.addEventListener('click', this); + + this.tagTemplate = document.querySelector('#tag-template'); +}; + +extend(TagView, ObservableMixin); + +TagView.prototype.onTagAdded = function (tag) { + var newNode; + var tagElement; + var setTagButton; + var toggleTagButton; + var tagElements; + var i; + var referenceTag = ''; + var referenceNode; + + // create new tag element from template + newNode = document.importNode(this.tagTemplate.content, true); + + tagElement = newNode.querySelector('li'); + tagElement.dataset.tag = tag; + + setTagButton = tagElement.querySelector('button[name="set-tag"]'); + setTagButton.textContent = tag; + setTagButton.title = 'Set filter to "' + tag + '"'; + + toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]'); + toggleTagButton.textContent = '+'; + toggleTagButton.title = 'Add "' + tag + '" to filter'; + + // maintain alphabetical order when inserting the tag element + tagElements = this.tagListElement.querySelectorAll('li'); + for (i = 0; i < tagElements.length; i ++) { + if (tagElements[i].dataset.tag > referenceTag && + tagElements[i].dataset.tag < tag) { + referenceTag = tagElements[i].dataset.tag; + referenceNode = tagElements[i]; + } + } + this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ? + referenceNode.nextSibling : this.tagListElement.firstChild); + + // initialize tag count + this.onTagCountChanged(tag, 1); +}; + +TagView.prototype.onTagCountChanged = function (tag, tagCount) { + this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) + + ' .tag-count').textContent = '(' + tagCount + ')'; +}; + +TagView.prototype.onTagDeleted = function (tag) { + var tagElement; + + tagElement = this.tagListElement.querySelector('li' + + createDatasetSelector('tag', tag)); + if (tagElement !== null) { + tagElement.parentNode.removeChild(tagElement); + } +}; + +TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks, + newFilterTags, newSearchTerm) { + var tagElements; + var i; + var tag; + var toggleTagButton; + + tagElements = this.tagListElement.querySelectorAll('li'); + for (i = 0; i < tagElements.length; i++) { + tag = tagElements[i].dataset.tag; + toggleTagButton = + tagElements[i].querySelector('button[name="toggle-tag"]'); + if (newFilterTags.has(tag)) { + tagElements[i].classList.add('active-filter-tag'); + toggleTagButton.textContent = '\u2212'; + toggleTagButton.title = 'Remove "' + tag + '" from filter'; + } else { + tagElements[i].classList.remove('active-filter-tag'); + toggleTagButton.textContent = '+'; + toggleTagButton.title = 'Add "' + tag + '" to filter'; + } + } +}; + +TagView.prototype.handleEvent = function (e) { + if (e.type === 'click' && (e.target.name === 'set-tag' || + e.target.name === 'toggle-tag')) { + e.target.blur(); + + this.notify(e.target.name, getAncestorElementDatasetItem(e.target, + 'tag')); + } +}; + + +var ActionsView = function () { + var saveFormElement; + var loadFormElement; + var newNode; + var editorFormElement; + + ObservableMixin.call(this); + + this.tagInputTemplate = document.querySelector('#tag-input-template'); + saveFormElement = document.querySelector('form#save-form'); + saveFormElement.addEventListener('submit', this); + + this.saveLinkElement = saveFormElement.querySelector('a#save-link'); + + loadFormElement = document.querySelector('form#load-form'); + loadFormElement.addEventListener('submit', this); + + // create new editor form from template + newNode = document.importNode( + document.querySelector('#bookmark-editor-template').content, true); + + editorFormElement = newNode.querySelector('form.bookmark-editor-form'); + editorFormElement.querySelector('legend').textContent = 'Add Bookmark'; + editorFormElement.querySelector('input:not([type="hidden"])').accessKey = + 'a'; + editorFormElement.addEventListener('click', this); + editorFormElement.addEventListener('submit', this); + editorFormElement.addEventListener('reset', this); + + this.editTagListElement = + editorFormElement.querySelector('ul.tag-input-list'); + this.editTagListElement.appendChild(this.createTagInputElement('')); + + saveFormElement.parentNode.insertBefore(newNode, + saveFormElement.nextSibling); +}; + +extend(ActionsView, ObservableMixin); + +ActionsView.prototype.createTagInputElement = function (tag) { + var newNode; + + newNode = document.importNode(this.tagInputTemplate.content, true); + newNode.querySelector('input[name="tag"]').value = tag; + + return newNode; +}; + +ActionsView.prototype.handleEvent = function (e) { + var tags = []; + var i; + + switch (e.type) { + case 'click': + if (e.target.name === 'more-tags') { + e.preventDefault(); + e.target.blur(); + + this.editTagListElement.appendChild(this.createTagInputElement('')); + } + break; + case 'submit': + if (e.target.id === 'save-form') { + e.preventDefault(); + e.target.blur(); + + this.notify('save-file'); + } else if (e.target.id === 'load-form') { + e.preventDefault(); + e.target.blur(); + + this.notify('load-file', e.target.file.files[0]); + e.target.reset(); + } else if (e.target.classList.contains('bookmark-editor-form')) { + e.preventDefault(); + e.target.blur(); + + if (e.target.tag.length) { + for (i = 0; i < e.target.tag.length; i++) { + tags.push(e.target.tag[i].value.trim()); + } + } else { + tags.push(e.target.tag.value.trim()); + } + + this.notify('save-bookmark', e.target.url.value, + e.target.title.value, tags); + + e.target.reset(); + } + break; + case 'reset': + if (e.target.classList.contains('bookmark-editor-form')) { + e.target.blur(); + + // remove all but one tag input element + while (this.editTagListElement.firstChild !== null) { + this.editTagListElement.removeChild( + this.editTagListElement.firstChild); + } + this.editTagListElement.appendChild(this.createTagInputElement('')); + } + break; + } +}; + +ActionsView.prototype.onSaveFile = function (jsonBlob) { + this.saveLinkElement.href = URL.createObjectURL(jsonBlob); + this.saveLinkElement.click(); +}; + +ActionsView.prototype.confirmLoadFile = function () { + return window.confirm('There are unsaved changes to your bookmarks.\n' + + 'Proceed loading the bookmark file?'); +}; + +ActionsView.prototype.onLoadFileError = function (message) { + window.alert('Failed to load bookmark file:\n' + message); +}; + +ActionsView.prototype.onParseFileError = function (message) { + window.alert('Failed to parse bookmark file:\n' + message); +}; + + +var BookmarkView = function () { + var searchFormElement; + + ObservableMixin.call(this); + + this.bookmarkTemplate = document.querySelector('#bookmark-template'); + this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template'); + this.bookmarkEditorTemplate = + document.querySelector('#bookmark-editor-template'); + this.tagInputTemplate = document.querySelector('#tag-input-template'); + + this.bookmarkListElement = document.querySelector('ul#bookmark-list'); + this.bookmarkListElement.addEventListener('click', this); + this.bookmarkListElement.addEventListener('submit', this); + this.bookmarkListElement.addEventListener('reset', this); + + searchFormElement = document.querySelector('#search-form'); + searchFormElement.addEventListener('submit', this); + searchFormElement.addEventListener('reset', this); + + this.searchTermInputElement = searchFormElement['search-term']; + + this.bookmarkMessageElement = document.querySelector('#bookmark-message'); + + this.updateBookmarkMessage(); +}; + +extend(BookmarkView, ObservableMixin); + +BookmarkView.prototype.handleEvent = function (e) { + var i; + var tags = []; + var node; + + switch (e.type) { + case 'click': + switch (e.target.name) { + case 'edit-bookmark': + e.target.blur(); + // fallthrough + case 'delete-bookmark': + this.notify(e.target.name, + getAncestorElementDatasetItem(e.target, 'bookmarkUrl')); + break; + case 'more-tags': + e.target.blur(); + + e.target.form.querySelector('ul.tag-input-list').appendChild( + this.createTagInputElement('')); + break; + case 'set-tag': + case 'toggle-tag': + e.target.blur(); + + this.notify(e.target.name, + getAncestorElementDatasetItem(e.target, 'tag')); + break; + } + break; + case 'submit': + if (e.target.classList.contains('bookmark-editor-form')) { + // save bookmark-editor-form form contents + e.preventDefault(); + + if (e.target.tag.length) { + for (i = 0; i < e.target.tag.length; i++) { + tags.push(e.target.tag[i].value.trim()); + } + } else { + tags.push(e.target.tag.value.trim()); + } + + this.notify('save-bookmark', e.target.url.value, + e.target.title.value, tags, e.target['original-url'].value); + } else if (e.target.id === 'search-form') { + // search + e.preventDefault(); + e.target.blur(); + + this.notify('search', e.target['search-term'].value); + } + break; + case 'reset': + if (e.target.classList.contains('bookmark-editor-form')) { + // cancel bookmark-editor-form form + e.preventDefault(); + + // re-enable edit button + this.bookmarkListElement.querySelector('li' + + createDatasetSelector('bookmark-url', + e.target['original-url'].value) + + ' button[name="edit-bookmark"]').disabled = false; + + e.target.parentNode.removeChild(e.target); + } else if (e.target.id === 'search-form') { + // clear search + e.preventDefault(); + e.target.blur(); + + this.notify('search', ''); + } + break; + } +}; + +BookmarkView.prototype.updateBookmarkMessage = function () { + this.bookmarkMessageElement.textContent = 'Showing ' + + this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' + + 'li:not([hidden])').length + ' of ' + + this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' + + 'li').length + ' bookmarks.'; +}; + +BookmarkView.prototype.onBookmarkAdded = function (bookmark) { + var newNode; + var bookmarkElement; + var linkElement; + var tagListElement; + + newNode = document.importNode(this.bookmarkTemplate.content, true); + + bookmarkElement = newNode.querySelector('li'); + bookmarkElement.dataset.bookmarkUrl = bookmark.url; + + linkElement = bookmarkElement.querySelector('a.bookmark-link'); + linkElement.textContent = linkElement.title = bookmark.title; + linkElement.href = bookmark.url; + + tagListElement = bookmarkElement.querySelector('ul.tag-list'); + bookmark.tags.forEach(function (tag) { + var newNode; + var tagElement; + var setTagButton; + var toggleTagButton; + + newNode = document.importNode(this.bookmarkTagTemplate.content, true); + + tagElement = newNode.querySelector('li'); + tagElement.dataset.tag = tag; + + setTagButton = newNode.querySelector('button[name="set-tag"]'); + setTagButton.textContent = tag; + setTagButton.title = 'Set filter to "' + tag + '"'; + + toggleTagButton = newNode.querySelector('button[name="toggle-tag"]'); + toggleTagButton.textContent = '+'; + toggleTagButton.title = 'Add "' + tag + '" to filter'; + + tagListElement.appendChild(newNode); + }, this); + + // insert new or last modified bookmark on top of the list + this.bookmarkListElement.insertBefore(newNode, + this.bookmarkListElement.firstChild); + + this.updateBookmarkMessage(); +}; + +BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) { + var bookmarkElement; + + bookmarkElement = this.bookmarkListElement.querySelector('li' + + createDatasetSelector('bookmark-url', bookmarkUrl)); + if (bookmarkElement !== null) { + this.bookmarkListElement.removeChild(bookmarkElement); + + this.updateBookmarkMessage(); + } +}; + +BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks, + newFilterTags, newSearchTerm) { + var bookmarkElements; + var i; + var tagElements; + var toggleTagButton; + var j; + var tag; + + this.searchTermInputElement.value = newSearchTerm; + + bookmarkElements = + this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li'); + for (i = 0; i < bookmarkElements.length; i++) { + // update visibility of bookmarks + if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) { + // update tag elements of visible bookmarks + tagElements = + bookmarkElements[i].querySelectorAll('ul.tag-list > li'); + for (j = 0; j < tagElements.length; j++) { + tag = tagElements[j].dataset.tag; + toggleTagButton = + tagElements[j].querySelector('button[name="toggle-tag"]'); + if (newFilterTags.has(tag)) { + tagElements[j].classList.add('active-filter-tag'); + toggleTagButton.textContent = '\u2212'; + toggleTagButton.title = 'Remove "' + tag + '" from filter'; + } else { + tagElements[j].classList.remove('active-filter-tag'); + toggleTagButton.textContent = '+'; + toggleTagButton.title = 'Add "' + tag + '" to filter'; + } + } + bookmarkElements[i].hidden = false; + } else { + bookmarkElements[i].hidden = true; + } + } + + this.updateBookmarkMessage(); +}; + +BookmarkView.prototype.createTagInputElement = function (tag) { + var newNode; + + newNode = document.importNode(this.tagInputTemplate.content, true); + newNode.querySelector('input[name="tag"]').value = tag; + + return newNode; +}; + +BookmarkView.prototype.displayBookmarkEditor = function (bookmark) { + var bookmarkElement; + var newNode; + var formElement; + var editTagListElement; + + bookmarkElement = + this.bookmarkListElement.querySelector('ul#bookmark-list > li' + + createDatasetSelector('bookmark-url', bookmark.url)); + + // disable edit button while editing + bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled = + true; + + // create new editor form from template + newNode = document.importNode(this.bookmarkEditorTemplate.content, true); + + // fill with data of given bookmark + formElement = newNode.querySelector('form.bookmark-editor-form'); + formElement.querySelector('legend').textContent = 'Edit Bookmark'; + formElement['original-url'].value = bookmark.url; + formElement.url.value = bookmark.url; + formElement.title.value = bookmark.title; + + editTagListElement = formElement.querySelector('ul.tag-input-list'); + bookmark.tags.forEach(function (tag) { + editTagListElement.appendChild(this.createTagInputElement(tag)); + }, this); + editTagListElement.appendChild(this.createTagInputElement('')); + + // insert editor form into bookmark item + bookmarkElement.appendChild(newNode); + + // focus first input element + formElement.querySelector('input').focus(); +}; + +BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) { + return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' + + bookmark.url + ']?'); +}; + +BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) { + return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' + + bookmark.url + ']?'); +}; + + +/* + * controller + */ + +var BooketController = function(bookmarkModel, actionsView, tagView, + bookmarkView) { + this.bookmarkModel = bookmarkModel; + this.actionsView = actionsView; + this.tagView = tagView; + this.bookmarkView = bookmarkView; + + /* connect the views to the model */ + this.bookmarkModel.addObserver('bookmark-added', + this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView)); + this.bookmarkModel.addObserver('bookmark-deleted', + this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView)); + this.bookmarkModel.addObserver('filter-tags-search-changed', + this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView)); + this.bookmarkModel.addObserver('load-file-error', + this.actionsView.onLoadFileError.bind(this.actionsView)); + this.bookmarkModel.addObserver('parse-file-error', + this.actionsView.onParseFileError.bind(this.actionsView)); + this.bookmarkModel.addObserver('save-file', + this.actionsView.onSaveFile.bind(this.actionsView)); + this.bookmarkModel.addObserver('tag-added', + this.tagView.onTagAdded.bind(this.tagView)); + this.bookmarkModel.addObserver('tag-count-changed', + this.tagView.onTagCountChanged.bind(this.tagView)); + this.bookmarkModel.addObserver('tag-deleted', + this.tagView.onTagDeleted.bind(this.tagView)); + this.bookmarkModel.addObserver('filter-tags-search-changed', + this.tagView.onFilterTagsSearchChanged.bind(this.tagView)); + this.bookmarkModel.addObserver('filter-tags-search-changed', + this.onFilterTagsSearchChanged.bind(this)); + + /* handle input */ + window.addEventListener('hashchange', this.onHashChange.bind(this)); + window.addEventListener('beforeunload', this.onBeforeUnload.bind(this)); + this.actionsView.addObserver('save-file', + this.bookmarkModel.saveFile.bind(this.bookmarkModel)); + this.actionsView.addObserver('load-file', this.onLoadFile.bind(this)); + this.actionsView.addObserver('save-bookmark', + this.onSaveBookmark.bind(this)); + this.bookmarkView.addObserver('edit-bookmark', + this.onEditBookmark.bind(this)); + this.bookmarkView.addObserver('save-bookmark', + this.onSaveBookmark.bind(this)); + this.bookmarkView.addObserver('delete-bookmark', + this.onDeleteBookmark.bind(this)); + this.bookmarkView.addObserver('toggle-tag', + this.onToggleFilterTag.bind(this)); + this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this)); + this.bookmarkView.addObserver('search', this.onSearch.bind(this)); + this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this)); + this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this)); +}; + +BooketController.prototype.parseTagsParameter = function (tagsString) { + var tags; + + tags = tagsString.split(',').filter(function (tag) { + return (tag !== '') && this.bookmarkModel.hasTag(tag); + }, this).sort(); + + return new StringSet(tags); +}; + +BooketController.prototype.onHashChange = function (e) { + var hashData; + var filterTags; + var searchTerm; + + hashData = parseHash(window.location.href); + + filterTags = hashData.has('tags') ? + this.parseTagsParameter(hashData.get('tags')) : new StringSet(); + + searchTerm = hashData.has('search') ? hashData.get('search') : ''; + + this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm); +}; + +BooketController.prototype.onBeforeUnload = function (e) { + var confirmationMessage = 'There are unsaved changes to your bookmarks.'; + + if (this.bookmarkModel.unsavedChanges) { + if (e) { + e.returnValue = confirmationMessage; + } + if (window.event) { + window.event.returnValue = confirmationMessage; + } + return confirmationMessage; + } +}; + +BooketController.prototype.onFilterTagsSearchChanged = + function (filteredBookmarks, newFilterTags, newSearchTerm) { + var url = window.location.href; + var hashData; + + // serialize tag filter and search term and update window.location + hashData = parseHash(url); + hashData.set('tags', newFilterTags.values().join(',')); + hashData.set('search', newSearchTerm); + history.pushState(null, null, serializeHash(url, hashData)); +}; + +BooketController.prototype.onLoadFile = function (bookmarkFile) { + if (this.bookmarkModel.unsavedChanges) { + if (!this.actionsView.confirmLoadFile()) { + return; + } + this.bookmarkModel.unsavedChanges = false; + } + + this.bookmarkModel.loadFile(bookmarkFile); +}; + +BooketController.prototype.onEditBookmark = function (bookmarkUrl) { + this.bookmarkView.displayBookmarkEditor( + this.bookmarkModel.get(bookmarkUrl)); +}; + +BooketController.prototype.onSaveBookmark = function (url, title, tags, + originalUrl) { + var ctime; + + if (originalUrl === undefined) { + // saving new bookmark, get confirmation before replacing existing one + if (this.bookmarkModel.has(url)) { + if (this.bookmarkView.confirmReplaceBookmark( + this.bookmarkModel.get(url))) { + this.bookmarkModel.delete(url); + } else { + return; + } + } + + ctime = new Date(); + } else { + // saving edited bookmark, preserve creation time of any replaced + // bookmark + ctime = (this.bookmarkModel.has(url)) ? + this.bookmarkModel.get(url).ctime : new Date(); + + this.bookmarkModel.delete(originalUrl); + } + this.bookmarkModel.add(new Bookmark(url, title, tags, ctime)); +}; + +BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) { + if (this.bookmarkView.confirmDeleteBookmark( + this.bookmarkModel.get(bookmarkUrl))) { + this.bookmarkModel.delete(bookmarkUrl); + } +}; + +BooketController.prototype.onToggleFilterTag = function (tag) { + this.bookmarkModel.toggleFilterTag(tag); +}; + +BooketController.prototype.onSetTagFilter = function (tag) { + this.bookmarkModel.setFilterTags(new StringSet([tag])); +}; + +BooketController.prototype.onSearch = function (searchTerm) { + this.bookmarkModel.setSearchTerm(searchTerm); +}; + + +document.addEventListener('DOMContentLoaded', function (e) { + var controller; + var bookmarkModel; + var actionsView; + var tagView; + var bookmarkView; + var hashChangeEvent; + + bookmarkModel = new BookmarkModel(); + tagView = new TagView(); + actionsView = new ActionsView(); + bookmarkView = new BookmarkView(); + controller = new BooketController(bookmarkModel, actionsView, + tagView, bookmarkView); + + // initialize state from the current URL + hashChangeEvent = new Event('hashchange'); + hashChangeEvent.oldURL = window.location.href; + hashChangeEvent.newURL = window.location.href; + window.dispatchEvent(hashChangeEvent); +}); +}()); +