Mercurial > projects > booket
view 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 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); }); }());