Mercurial > projects > booket
view booket.js @ 11:ef5d75bcac5e
Add Netscape bookmark file export function
author | Guido Berhoerster <guido+booket@berhoerster.name> |
---|---|
date | Wed, 17 Sep 2014 21:12:38 +0200 |
parents | 20902b548d9f |
children | 948048e40fab |
line wrap: on
line source
/* * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name> * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be included * in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ (function () { 'use strict'; var BOOKMARKLET_URI = 'javascript:(function() {' + '\'use strict\';' + '' + 'function displayBookmarkData(bookmarkData) {' + 'window.alert(\'Copy the following data and paste it into \' +' + '\'Booket:\\n\\n\' + JSON.stringify(bookmarkData));' + '}' + '' + 'var bookmarkData = {' + '\'url\': document.URL,' + '\'title\': document.title,' + '\'favicon\': undefined' + '};' + 'var faviconLinkElement;' + 'var faviconUrls = [];' + 'var aElement;' + 'var canvasElement;' + 'var canvasCtx;' + 'var imgElement;' + '' + 'aElement = document.createElement(\'a\');' + 'aElement.href = document.URL;' + '' + 'faviconUrls.push(aElement.protocol + \'//\' + aElement.host + ' + '\'/favicon.ico\');' + '' + 'faviconLinkElement = document.querySelector(' + '\'link[rel~=\\\'icon\\\']\');' + 'if (faviconLinkElement !== null) {' + 'faviconUrls.push(faviconLinkElement.href);' + '}' + '' + 'canvasElement = document.createElement(\'canvas\');' + 'canvasCtx = canvasElement.getContext(\'2d\');' + '' + 'imgElement = new Image();' + 'imgElement.addEventListener(\'load\', function(e) {' + 'var faviconUrl;' + '' + 'canvasElement.width = 16;' + 'canvasElement.height = 16;' + 'canvasCtx.clearRect(0, 0, 16, 16);' + 'try {' + 'canvasCtx.drawImage(this, 0, 0, 16, 16);' + 'bookmarkData.favicon = canvasElement.toDataURL();' + '} catch (exception) {' + 'faviconUrl = faviconUrls.pop();' + '}' + 'if (bookmarkData.favicon !== undefined || ' + 'faviconUrl === undefined) {' + 'displayBookmarkData(bookmarkData);' + '} else {' + 'imgElement.src = faviconUrl;' + '}' + '});' + 'imgElement.addEventListener(\'error\', function(e) {' + 'var faviconUrl;' + '' + 'faviconUrl = faviconUrls.pop();' + 'if (faviconUrl !== undefined) {' + 'imgElement.src = faviconUrl;' + '} else {' + 'displayBookmarkData(bookmarkData);' + '}' + '});' + 'imgElement.src = faviconUrls.pop();' + '})();'; /* * utility stuff */ function isNumber(number) { return (Object.prototype.toString.call(number) === '[object Number]'); } function isString(number) { return (Object.prototype.toString.call(number) === '[object String]'); } function arrayEqual(array1, array2) { if (!Array.isArray(array1)) { throw new TypeError(typeof array1 + ' is not an array'); } else if (!Array.isArray(array2)) { throw new TypeError(typeof array2 + ' is not an array'); } if (array1.length !== array2.length) { return false; } else if (array1.length === 0 && array2.length === 0) { return true; } return array1.slice().sort().every(function (value, i) { return value === array2[i]; }); } function parseHash(url) { var hashData; var pos; var hash; var hashParts; var key; var value; var i; hashData = new StringMap(); pos = url.indexOf('#'); hash = (pos > -1) ? url.substr(pos + 1) : ''; // hash parts are seperated by a ';' hashParts = hash.split(';'); for (i = 0; i < hashParts.length; i++) { // key and value pairs are seperated by a '=', an empty value will // cause the key to be ignored pos = hashParts[i].indexOf('='); if (pos > -1) { key = decodeURIComponent(hashParts[i].substr(0, pos)); value = decodeURIComponent(hashParts[i].substr(pos + 1)); hashData.set(key, value); } } return hashData; } function serializeHash(url, hashData) { var hashParts = []; var pos; pos = url.indexOf('#'); if (pos > -1) { url = url.substr(0, pos); } hashData.forEach(function (value, key) { if (value !== '') { hashParts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value)); } }); // only append a '#' if there are any hash parts return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : ''); } function getAncestorElementDatasetItem(node, item) { while ((node = node.parentNode) !== null) { if (node.dataset && node.dataset[item] !== undefined) { return node.dataset[item]; } } return undefined; } // for use with Node.querySelector() and Node.querySelectorAll() function createDatasetSelector(name, value) { return '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]'; } function extend(targetObject, sourceObject) { var propertyName; for (propertyName in sourceObject.prototype) { if (!Object.prototype.hasOwnProperty.call(targetObject.prototype, propertyName)) { targetObject.prototype[propertyName] = sourceObject.prototype[propertyName]; } } } var ObservableMixin = function () { this._eventsObservers = {}; }; ObservableMixin.prototype.addObserver = function (eventName, observer) { var i; if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, eventName)) { this._eventsObservers[eventName] = []; } // prevent observers for an event from being called more than once for (i = 0; i < this._eventsObservers[eventName].length; i++) { if (this._eventsObservers[eventName][i] === observer) { return; } } this._eventsObservers[eventName].push(observer); }; ObservableMixin.prototype.deleteObserver = function (eventName, observer) { var i = 0; if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, eventName)) { return; } while (i < this._eventsObservers[eventName].length) { if (this._eventsObservers[eventName][i] === observer) { this._eventsObservers[eventName].splice(i, 1); } } }; ObservableMixin.prototype.notify = function (eventName) { var origArguments; if (!Object.prototype.hasOwnProperty.call(this._eventsObservers, eventName)) { return; } origArguments = Array.prototype.slice.call(arguments, 1); this._eventsObservers[eventName].forEach(function (observer, i) { // call the observer function and pass on any additional arguments observer.apply(undefined, origArguments); }); }; var StringMap = function (iter) { this._stringMap = Object.create(null); if (iter !== undefined) { if (Array.isArray(iter)) { iter.forEach(function (pair) { if (Array.isArray(pair)) { this.set(pair[0], pair[1]); } else { throw new TypeError(typeof pair + ' is not an array'); } }, this); } else { throw new TypeError(typeof iter + ' is not iterable'); } } }; Object.defineProperty(StringMap.prototype, 'size', { get: function () { var size = 0; var key; for (key in this._stringMap) { if (key.charAt(0) === '@') { size++; } } return size; } }); StringMap.prototype.set = function (key, value) { this._stringMap['@' + key] = value; return this; }; StringMap.prototype.get = function (key) { return this._stringMap['@' + key]; }; StringMap.prototype.has = function (key) { return (('@' + key) in this._stringMap); }; StringMap.prototype.delete = function (key) { if (this.has(key)) { delete this._stringMap['@' + key]; return true; } return false; }; StringMap.prototype.forEach = function (callbackFn, thisArg) { Object.keys(this._stringMap).forEach(function (key) { if (key.charAt(0) === '@') { key = key.substr(1); callbackFn.call(thisArg, this.get(key), key, this); } }, this); }; StringMap.prototype.keys = function () { return Object.keys(this._stringMap).map(function (key) { return key.substr(1); }); }; StringMap.prototype.toJSON = function () { return this._stringMap; }; StringMap.prototype.toString = function () { return Object.prototype.toString.call(this._stringMap); }; var StringSet = function (iter) { this._stringArray = []; this._stringMap = new StringMap(); if (iter !== undefined) { if (Array.isArray(iter) || iter instanceof StringSet) { iter.forEach(function (string) { this.add(string); }, this); } else { throw new TypeError(typeof iter + ' is not iterable'); } } }; Object.defineProperty(StringSet.prototype, 'size', { get: function () { return this._stringArray.length; } }); StringSet.prototype.has = function (string) { return this._stringMap.has(string); }; StringSet.prototype.add = function (string) { if (!this.has(string)) { this._stringMap.set(string, true); this._stringArray.push(string); } return this; }; StringSet.prototype.delete = function (string) { if (this.has(string)) { this._stringMap.delete(string); this._stringArray.splice(this._stringArray.indexOf(string), 1); return true; } return false; }; StringSet.prototype.forEach = function (callbackFn, thisArg) { this._stringArray.forEach(function (key) { callbackFn.call(thisArg, key, key, this); }); }; StringSet.prototype.keys = function () { return this._stringArray.slice(); }; StringSet.prototype.values = function () { return this._stringArray.slice(); }; StringSet.prototype.clear = function () { this._stringMap = new StringMap(); this._stringArray = []; }; StringSet.prototype.toJSON = function () { return this._stringArray; }; StringSet.prototype.toString = function () { return this._stringArray.toString(); }; /* * model */ var Bookmark = function (url, title, favicon, tags, ctime, mtime) { var parsedTime; if (!isString(url)) { throw new TypeError(typeof url + ' is not a string'); } this.url = url; this.title = (isString(title) && title !== '') ? title : url; if (isString(favicon) && favicon.match(/^data:image\/png;base64,/)) { this.favicon = favicon; } else { this.favicon = undefined; } if (Array.isArray(tags)) { // remove duplicates, non-string or empty tags and tags containing // commas this.tags = new StringSet(tags.filter(function (tag) { return (isString(tag) && tag !== '' && tag.indexOf(',') === -1); }).sort()); } else { this.tags = new StringSet(); } if (isNumber(ctime) || isString(ctime)) { parsedTime = new Date(ctime); this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date(); } else { this.ctime = new Date(); } if (isNumber(mtime) || isString(mtime)) { parsedTime = new Date(mtime); // modification time must be greater than creation time this.mtime = (!isNaN(parsedTime.getTime()) || parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime); } else { this.mtime = new Date(this.ctime); } }; var BookmarkModel = function () { ObservableMixin.call(this); this.unsavedChanges = false; this.loadFileReader = null; this.importFileReader= null; this._bookmarks = new StringMap(); this._tagCount = new StringMap(); this._filterTags = new StringSet(); this._searchTerm = ''; this._filteredBookmarks = new StringSet(); this._searchedBookmarks = new StringSet(); }; extend(BookmarkModel, ObservableMixin); BookmarkModel.prototype.add = function (bookmarks) { var addedBookmarkUrls = new StringSet(); // argument can be a single bookmark or a list of bookmarks if (!Array.isArray(bookmarks)) { bookmarks = [bookmarks]; } bookmarks.forEach(function (bookmark) { // delete any existing bookmark for the given URL before adding the new // one in order to update views this.delete(bookmark.url); this._bookmarks.set(bookmark.url, bookmark); addedBookmarkUrls.add(bookmark.url); this.unsavedChanges = true; this.notify('bookmark-added', bookmark); // update tag count bookmark.tags.forEach(function (tag) { var tagCount; if (this._tagCount.has(tag)) { tagCount = this._tagCount.get(tag) + 1; this._tagCount.set(tag, tagCount); this.notify('tag-count-changed', tag, tagCount); } else { this._tagCount.set(tag, 1); this.notify('tag-added', tag); } }, this); }, this); // apply tag filter and search added bookmarks this.updateFilteredSearchedBookmarks(addedBookmarkUrls); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); }; BookmarkModel.prototype.has = function (url) { return this._bookmarks.has(url); }; BookmarkModel.prototype.get = function (url) { return this._bookmarks.get(url); }; BookmarkModel.prototype.delete = function (urls) { var needUpdateFilterTags = false; // argument can be a single bookmark or a list of bookmarks if (!Array.isArray(urls)) { urls = [urls]; } urls.forEach(function (url) { var bookmark; var tagCount; if (this._bookmarks.has(url)) { bookmark = this._bookmarks.get(url); this._bookmarks.delete(url); this.unsavedChanges = true; this.notify('bookmark-deleted', bookmark.url); // update tag count bookmark.tags.forEach(function (tag) { if (this._tagCount.has(tag)) { tagCount = this._tagCount.get(tag); if (tagCount > 1) { tagCount--; this._tagCount.set(tag, tagCount); this.notify('tag-count-changed', tag, tagCount); } else { this._tagCount.delete(tag); this.notify('tag-deleted', tag); if (this._filterTags.has(tag)) { this._filterTags.delete(tag); needUpdateFilterTags = true; } } } }, this); // update filtered and searched bookmarks if (this._filteredBookmarks.has(url)) { this._filteredBookmarks.delete(url); if (this._searchedBookmarks.has(url)) { this._searchedBookmarks.delete(url); } } } }, this); if (needUpdateFilterTags) { this.updateFilteredSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); } }; BookmarkModel.prototype.forEach = function (callbackFn, thisArg) { this._bookmarks.keys().forEach(function (key) { callbackFn.call(thisArg, this._bookmarks.get(key), key, this); }, this); }; BookmarkModel.prototype.hasTag = function (tag) { return this._tagCount.has(tag); }; BookmarkModel.prototype.getTagCount = function (tag) { return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined; }; BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) { var searchUrls; // additive search if urlsSubset is given if (urlsSubset !== undefined) { searchUrls = urlsSubset; } else { this._searchedBookmarks = new StringSet(); searchUrls = this._filteredBookmarks.values(); } // search for the search term in title and URL searchUrls.forEach(function (url) { var bookmark; bookmark = this.get(url); if (this._searchTerm === '' || bookmark.title.indexOf(this._searchTerm) !== -1 || bookmark.url.indexOf(this._searchTerm) !== -1) { this._searchedBookmarks.add(url); } }, this); }; BookmarkModel.prototype.updateFilteredSearchedBookmarks = function (urlsSubset) { var filterUrls; var searchUrls; // additive filtering if urlsSubset is given if (urlsSubset !== undefined) { filterUrls = urlsSubset; searchUrls = []; } else { this._filteredBookmarks = new StringSet(); filterUrls = this._bookmarks.keys(); searchUrls = undefined; } // apply tag filter filterUrls.forEach(function (url) { var bookmark; var matchingTagCount = 0; bookmark = this.get(url); bookmark.tags.forEach(function (tag) { if (this._filterTags.has(tag)) { matchingTagCount++; } }, this); if (matchingTagCount === this._filterTags.size) { this._filteredBookmarks.add(url); if (urlsSubset !== undefined) { searchUrls.push(url); } } }, this); // search the filter results this.updateSearchedBookmarks(searchUrls); }; BookmarkModel.prototype.toggleFilterTag = function (tag) { if (this._filterTags.has(tag)) { this._filterTags.delete(tag); } else { this._filterTags.add(tag); } this.updateFilteredSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); }; BookmarkModel.prototype.setFilterTags = function (filterTags) { if (!arrayEqual(filterTags.values(), this._filterTags.values())) { this._filterTags = new StringSet(filterTags); this.updateFilteredSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); } }; BookmarkModel.prototype.setSearchTerm = function (searchTerm) { if (searchTerm !== this._searchTerm) { this._searchTerm = searchTerm; this.updateSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); } }; BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags, searchTerm) { if (!arrayEqual(filterTags.values(), this._filterTags.values())) { this._filterTags = new StringSet(filterTags); this._searchTerm = searchTerm; this.updateFilteredSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); } else if (searchTerm !== this._searchTerm) { this._searchTerm = searchTerm; this.updateSearchedBookmarks(); this.notify('filter-tags-search-changed', this._searchedBookmarks, this._filterTags, this._searchTerm); } }; BookmarkModel.prototype.parseLoadedBookmarks = function (data) { var parsedData; var bookmarks = []; try { parsedData = JSON.parse(data); } catch (e) { this.notify('load-file-error', e.message); return; } if (!Array.isArray(parsedData.bookmarks)) { this.notify('parse-file-error', 'This file does not contain bookmarks.'); return; } // create a temporary list of valid bookmarks parsedData.bookmarks.forEach(function (bookmark) { if (isString(bookmark.url) && bookmark.url !== '') { bookmarks.push(new Bookmark(bookmark.url, bookmark.title, bookmark.favicon, bookmark.tags, bookmark.ctime, bookmark.mtime)); } }, this); // add each bookmark to the model ordered by the last modification time this.add(bookmarks.sort(function (bookmark1, bookmark2) { return bookmark1.ctime - bookmark2.ctime; })); this.unsavedChanges = false; }; BookmarkModel.prototype.parseImportedBookmarks = function (data) { var bookmarkDoc; var bookmarkElements; var i; var url; var title; var favicon; var tags; var ctime; var mtime; var bookmarks = []; bookmarkDoc = document.implementation.createHTMLDocument(); bookmarkDoc.open(); bookmarkDoc.write(data); bookmarkDoc.close(); // create a temporary list of valid bookmarks bookmarkElements = bookmarkDoc.querySelectorAll('dt > a[href]'); for (i = 0; i < bookmarkElements.length; i++) { url = bookmarkElements[i].href; if (url !== '') { title = bookmarkElements[i].textContent; favicon = bookmarkElements[i].getAttribute('icon'); tags = ((tags = bookmarkElements[i].getAttribute('tags')) !== null) ? tags.split(',') : []; ctime = !isNaN(ctime = parseInt(bookmarkElements[i].getAttribute('add_date'), 10)) ? ctime * 1000 : undefined; mtime = !isNaN(mtime = parseInt(bookmarkElements[i].getAttribute('last_modified'), 10)) ? mtime * 1000 : undefined; bookmarks.push(new Bookmark(url, title, favicon, tags, ctime, mtime)); } } // add each bookmark to the model ordered by the last modification time this.add(bookmarks.sort(function (bookmark1, bookmark2) { return bookmark1.ctime - bookmark2.ctime; })); this.unsavedChanges = false; }; BookmarkModel.prototype.loadFile = function (bookmarkFile) { // delete all existing bookmarks first this.delete(this._bookmarks.keys()); this.unsavedChanges = false; this.loadFileReader = new FileReader(); this.loadFileReader.addEventListener('error', this); this.loadFileReader.addEventListener('load', this); this.loadFileReader.readAsText(bookmarkFile); }; BookmarkModel.prototype.importFile = function (bookmarkFile) { // delete all existing bookmarks first this.delete(this._bookmarks.keys()); this.unsavedChanges = false; this.importFileReader = new FileReader(); this.importFileReader.addEventListener('error', this); this.importFileReader.addEventListener('load', this); this.importFileReader.readAsText(bookmarkFile); }; BookmarkModel.prototype.saveFile = function () { var jsonBlob; var bookmarkData = { 'bookmarks': [] }; this._bookmarks.forEach(function (bookmark) { bookmarkData.bookmarks.push(bookmark); }, this); jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type: 'application/json'}); this.notify('save-file', jsonBlob); this.unsavedChanges = false; }; BookmarkModel.prototype.exportFile = function () { var htmlBlob; var bookmarkDoc; var commentNode; var metaElement; var titleElement; var headingElement; var bookmarkListElement; var bookmarkLinkElement; var bookmarkElement; bookmarkDoc = document.implementation.createHTMLDocument(); // construct Netscape bookmarks format within body commentNode = bookmarkDoc.createComment('This is an automatically ' + 'generated file.\nIt will be read and overwritten.\nDO NOT EDIT!'); bookmarkDoc.body.appendChild(commentNode); metaElement = bookmarkDoc.createElement('meta'); metaElement.setAttribute('http-equiv', 'Content-Type'); metaElement.setAttribute('content', 'text/html; charset=UTF-8'); bookmarkDoc.body.appendChild(metaElement); titleElement = bookmarkDoc.createElement('title'); titleElement.textContent = 'Bookmarks'; bookmarkDoc.body.appendChild(titleElement); headingElement = bookmarkDoc.createElement('h1'); headingElement.textContent = 'Bookmarks'; bookmarkDoc.body.appendChild(headingElement); bookmarkListElement = bookmarkDoc.createElement('dl'); bookmarkDoc.body.appendChild(bookmarkListElement); this._bookmarks.forEach(function (bookmark) { bookmarkElement = bookmarkDoc.createElement('dt'); bookmarkLinkElement = bookmarkDoc.createElement('a'); bookmarkLinkElement.href = bookmark.url; bookmarkLinkElement.textContent = bookmark.title; bookmarkLinkElement.setAttribute('icon', bookmark.favicon); bookmarkLinkElement.setAttribute('tags', bookmark.tags.values().join(',')); bookmarkLinkElement.setAttribute('add_date', Math.round(bookmark.ctime.getTime() / 1000)); bookmarkLinkElement.setAttribute('last_modified', Math.round(bookmark.mtime.getTime() / 1000)); bookmarkElement.appendChild(bookmarkLinkElement); bookmarkListElement.appendChild(bookmarkElement); bookmarkListElement.appendChild(bookmarkDoc.createElement('dd')); }, this); htmlBlob = new Blob(['<!DOCTYPE NETSCAPE-Bookmark-file-1>\n' + bookmarkDoc.body.innerHTML], {type: 'text/html'}); this.notify('export-file', htmlBlob); }; BookmarkModel.prototype.handleEvent = function (e) { if (e.type === 'load') { if (e.target === this.loadFileReader) { this.parseLoadedBookmarks(e.target.result); this.loadFileReader = null; } else if (e.target === this.importFileReader) { this.parseImportedBookmarks(e.target.result); this.importFileReader = null; } } else if (e.type === 'error') { this.notify('load-file-error', e.target.error.message); } }; /* * view */ var TagView = function () { ObservableMixin.call(this); this.tagListElement = document.querySelector('#tags ul.tag-list'); this.tagListElement.addEventListener('click', this); this.tagDatalistElement = document.querySelector('#tag-datalist'); this.tagTemplate = document.querySelector('#tag-template'); }; extend(TagView, ObservableMixin); TagView.prototype.onTagAdded = function (tag) { var newNode; var tagElement; var setTagButton; var toggleTagButton; var tagElements; var i; var referenceTag = ''; var referenceNode; var tagOptionElement; var i; var isInDatalist = false; // create new tag element from template newNode = document.importNode(this.tagTemplate.content, true); tagElement = newNode.querySelector('li'); tagElement.dataset.tag = tag; setTagButton = tagElement.querySelector('button[name="set-tag"]'); setTagButton.textContent = tag; setTagButton.title = 'Set filter to "' + tag + '"'; toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]'); toggleTagButton.textContent = '+'; toggleTagButton.title = 'Add "' + tag + '" to filter'; // maintain alphabetical order when inserting the tag element tagElements = this.tagListElement.querySelectorAll('li'); for (i = 0; i < tagElements.length; i ++) { if (tagElements[i].dataset.tag > referenceTag && tagElements[i].dataset.tag < tag) { referenceTag = tagElements[i].dataset.tag; referenceNode = tagElements[i]; } } this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ? referenceNode.nextSibling : this.tagListElement.firstChild); // initialize tag count this.onTagCountChanged(tag, 1); // add to datalist for (i = 0; i < this.tagDatalistElement.options.length; i++) { if (this.tagDatalistElement.options[i].value == tag) { isInDatalist = true; break; } } if (!isInDatalist) { tagOptionElement = document.createElement('option'); tagOptionElement.value = tag; this.tagDatalistElement.appendChild(tagOptionElement); } }; TagView.prototype.onTagCountChanged = function (tag, tagCount) { this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) + ' .tag-count').textContent = '(' + tagCount + ')'; }; TagView.prototype.onTagDeleted = function (tag) { var tagElement; // remove from tag list tagElement = this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag)); tagElement.parentNode.removeChild(tagElement); }; TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks, newFilterTags, newSearchTerm) { var tagElements; var i; var tag; var toggleTagButton; tagElements = this.tagListElement.querySelectorAll('li'); for (i = 0; i < tagElements.length; i++) { tag = tagElements[i].dataset.tag; toggleTagButton = tagElements[i].querySelector('button[name="toggle-tag"]'); if (newFilterTags.has(tag)) { tagElements[i].classList.add('active-filter-tag'); toggleTagButton.textContent = '\u2212'; toggleTagButton.title = 'Remove "' + tag + '" from filter'; } else { tagElements[i].classList.remove('active-filter-tag'); toggleTagButton.textContent = '+'; toggleTagButton.title = 'Add "' + tag + '" to filter'; } } }; TagView.prototype.handleEvent = function (e) { if (e.type === 'click' && (e.target.name === 'set-tag' || e.target.name === 'toggle-tag')) { e.target.blur(); this.notify(e.target.name, getAncestorElementDatasetItem(e.target, 'tag')); } }; var ActionsView = function () { var saveFormElement; var loadFormElement; var importFormElement; var exportFormElement; var newNode; ObservableMixin.call(this); this.tagInputTemplate = document.querySelector('#tag-input-template'); saveFormElement = document.querySelector('form#save-form'); saveFormElement.addEventListener('submit', this); this.saveLinkElement = saveFormElement.querySelector('a#save-link'); loadFormElement = document.querySelector('form#load-form'); loadFormElement.addEventListener('submit', this); importFormElement = document.querySelector('form#import-form'); importFormElement.addEventListener('submit', this); exportFormElement = document.querySelector('form#export-form'); exportFormElement.addEventListener('submit', this); // create new editor form from template newNode = document.importNode( document.querySelector('#bookmark-editor-template').content, true); this.editorFormElement = newNode.querySelector('form.bookmark-editor-form'); this.editorFormElement.querySelector('legend').textContent = 'Add Bookmark'; this.editorFormElement.querySelector( 'input:not([type="hidden"])').accessKey = 'a'; this.editorFormElement.addEventListener('input', this); this.editorFormElement.addEventListener('click', this); this.editorFormElement.addEventListener('submit', this); this.editorFormElement.addEventListener('reset', this); this.faviconImageElement = this.editorFormElement.querySelector('img.bookmark-favicon'); this.faviconImageElement.addEventListener('load', this); this.faviconImageElement.addEventListener('error', this); this.missingFaviconUri = this.faviconImageElement.src; this.editTagListElement = this.editorFormElement.querySelector('ul.tag-input-list'); this.editTagListElement.appendChild(this.createTagInputElement('')); saveFormElement.parentNode.insertBefore(newNode, saveFormElement.nextSibling); document.querySelector('a#bookmarklet-link').href = BOOKMARKLET_URI; }; extend(ActionsView, ObservableMixin); ActionsView.prototype.createTagInputElement = function (tag) { var newNode; newNode = document.importNode(this.tagInputTemplate.content, true); newNode.querySelector('input[name="tag"]').value = tag; return newNode; }; ActionsView.prototype.handleEvent = function (e) { var bookmarkletData; var parsedData; var tags = []; var i; switch (e.type) { case 'error': if (e.target.classList.contains('bookmark-favicon')) { if (e.target.src !== this.missingFaviconUri) { e.target.src = this.missingFaviconUri; } } break; case 'load': if (e.target.classList.contains('bookmark-favicon')) { this.editorFormElement.favicon.value = (e.target.src !== this.missingFaviconUri) ? e.target.src : ''; } break; case 'input': if (e.target.name === 'bookmarklet-import') { // get rid of any preceding text bookmarkletData = e.target.value.replace(/^[^{]*/, ''); try { parsedData = JSON.parse(bookmarkletData); } catch (exception) { return; } if (isString(parsedData.url) && parsedData.url !== '') { e.target.form.elements.url.value = parsedData.url; } if (isString(parsedData.title) && parsedData.title !== '') { e.target.form.elements.title.value = parsedData.title; } if (isString(parsedData.favicon) && parsedData.favicon.match(/^data:image\/png;base64,/)) { this.faviconImageElement.src = parsedData.favicon; } } break; case 'click': if (e.target.name === 'more-tags') { e.preventDefault(); e.target.blur(); this.editTagListElement.appendChild(this.createTagInputElement('')); } break; case 'submit': if (e.target.id === 'save-form') { e.preventDefault(); e.target.blur(); this.notify('save-file'); } else if (e.target.id === 'load-form') { e.preventDefault(); e.target.blur(); this.notify('load-file', e.target.file.files[0]); e.target.reset(); } else if (e.target.id === 'import-form') { e.preventDefault(); e.target.blur(); this.notify('import-file', e.target.file.files[0]); e.target.reset(); } else if (e.target.id === 'export-form') { e.preventDefault(); e.target.blur(); this.notify('export-file'); } else if (e.target.classList.contains('bookmark-editor-form')) { e.preventDefault(); e.target.blur(); if (e.target.tag.length) { for (i = 0; i < e.target.tag.length; i++) { tags.push(e.target.tag[i].value.trim()); } } else { tags.push(e.target.tag.value.trim()); } this.notify('save-bookmark', e.target.url.value, e.target.title.value, e.target.favicon.value, tags); e.target.reset(); } break; case 'reset': if (e.target.classList.contains('bookmark-editor-form')) { e.target.blur(); e.target.querySelector('img.bookmark-favicon').src = this.missingFaviconUri; // remove all but one tag input element while (this.editTagListElement.firstChild !== null) { this.editTagListElement.removeChild( this.editTagListElement.firstChild); } this.editTagListElement.appendChild(this.createTagInputElement('')); } break; } }; ActionsView.prototype.onSaveFile = function (jsonBlob) { this.saveLinkElement.href = URL.createObjectURL(jsonBlob); this.saveLinkElement.click(); }; ActionsView.prototype.onExportFile = function (htmlBlob) { var exportLinkElement; exportLinkElement = document.querySelector('a#export-link'); exportLinkElement.href = URL.createObjectURL(htmlBlob); exportLinkElement.click(); }; ActionsView.prototype.confirmLoadFile = function () { return window.confirm('There are unsaved changes to your bookmarks.\n' + 'Proceed loading the bookmark file?'); }; ActionsView.prototype.onLoadFileError = function (message) { window.alert('Failed to load bookmark file:\n' + message); }; ActionsView.prototype.onParseFileError = function (message) { window.alert('Failed to parse bookmark file:\n' + message); }; var BookmarkView = function () { var searchFormElement; ObservableMixin.call(this); this.bookmarkTemplate = document.querySelector('#bookmark-template'); this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template'); this.bookmarkEditorTemplate = document.querySelector('#bookmark-editor-template'); this.tagInputTemplate = document.querySelector('#tag-input-template'); this.bookmarkListElement = document.querySelector('ul#bookmark-list'); this.bookmarkListElement.addEventListener('input', this); this.bookmarkListElement.addEventListener('click', this); this.bookmarkListElement.addEventListener('submit', this); this.bookmarkListElement.addEventListener('reset', this); searchFormElement = document.querySelector('#search-form'); searchFormElement.addEventListener('submit', this); searchFormElement.addEventListener('reset', this); this.searchTermInputElement = searchFormElement['search-term']; this.bookmarkMessageElement = document.querySelector('#bookmark-message'); this.missingFaviconUri = ''; this.updateBookmarkMessage(); }; extend(BookmarkView, ObservableMixin); BookmarkView.prototype.handleEvent = function (e) { var bookmarkletData; var parsedData; var i; var tags = []; var node; switch (e.type) { case 'error': if (e.target.classList.contains('bookmark-favicon')) { if (e.target.src !== this.missingFaviconUri) { e.target.src = this.missingFaviconUri; } } break; case 'load': if (e.target.classList.contains('bookmark-favicon')) { node = e.target; while ((node = node.parentNode) !== null) { if (node.classList.contains('bookmark-editor-form')) { node.favicon.value = (e.target.src !== this.missingFaviconUri) ? e.target.src : ''; break; } } } break; case 'input': if (e.target.name === 'bookmarklet-import') { // get rid of any preceding text bookmarkletData = e.target.value.replace(/^[^{]*/, ''); try { parsedData = JSON.parse(bookmarkletData); } catch (exception) { return; } if (isString(parsedData.url) && parsedData.url !== '') { e.target.form.elements.url.value = parsedData.url; } if (isString(parsedData.title) && parsedData.title !== '') { e.target.form.elements.title.value = parsedData.title; } if (isString(parsedData.favicon) && parsedData.favicon.match(/^data:image\/png;base64,/)) { e.target.form.querySelector('img.bookmark-favicon').src = parsedData.favicon; } } break; case 'click': switch (e.target.name) { case 'edit-bookmark': e.target.blur(); // fallthrough case 'delete-bookmark': this.notify(e.target.name, getAncestorElementDatasetItem(e.target, 'bookmarkUrl')); break; case 'more-tags': e.target.blur(); e.target.form.querySelector('ul.tag-input-list').appendChild( this.createTagInputElement('')); break; case 'set-tag': case 'toggle-tag': e.target.blur(); this.notify(e.target.name, getAncestorElementDatasetItem(e.target, 'tag')); break; } break; case 'submit': if (e.target.classList.contains('bookmark-editor-form')) { // save bookmark-editor-form form contents e.preventDefault(); if (e.target.tag.length) { for (i = 0; i < e.target.tag.length; i++) { tags.push(e.target.tag[i].value.trim()); } } else { tags.push(e.target.tag.value.trim()); } this.notify('save-bookmark', e.target.url.value, e.target.title.value, e.target.favicon.value, tags, e.target['original-url'].value); } else if (e.target.id === 'search-form') { // search e.preventDefault(); e.target.blur(); this.notify('search', e.target['search-term'].value); } break; case 'reset': if (e.target.classList.contains('bookmark-editor-form')) { // cancel bookmark-editor-form form e.preventDefault(); // re-enable edit button this.bookmarkListElement.querySelector('li' + createDatasetSelector('bookmark-url', e.target['original-url'].value) + ' button[name="edit-bookmark"]').disabled = false; e.target.parentNode.removeChild(e.target); } else if (e.target.id === 'search-form') { // clear search e.preventDefault(); e.target.blur(); this.notify('search', ''); } break; } }; BookmarkView.prototype.updateBookmarkMessage = function () { this.bookmarkMessageElement.textContent = 'Showing ' + this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' + 'li:not([hidden])').length + ' of ' + this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' + 'li').length + ' bookmarks.'; }; BookmarkView.prototype.onBookmarkAdded = function (bookmark) { var newNode; var bookmarkElement; var faviconElement; var linkElement; var hostnameElement; var urlElement; var ctimeElement; var mtimeElement; var tagListElement; newNode = document.importNode(this.bookmarkTemplate.content, true); bookmarkElement = newNode.querySelector('li'); bookmarkElement.dataset.bookmarkUrl = bookmark.url; faviconElement = bookmarkElement.querySelector('img.bookmark-favicon'); faviconElement.src = (bookmark.favicon) ? bookmark.favicon : this.missingFaviconUri; faviconElement.alt = ''; linkElement = bookmarkElement.querySelector('a.bookmark-link'); linkElement.textContent = linkElement.title = bookmark.title; linkElement.href = bookmark.url; hostnameElement = bookmarkElement.querySelector('.bookmark-hostname'); hostnameElement.textContent = (linkElement.hostname !== '') ? '[' + linkElement.hostname + ']' : ''; urlElement = bookmarkElement.querySelector('.bookmark-url'); urlElement.textContent = bookmark.url; ctimeElement = bookmarkElement.querySelector('.ctime'); ctimeElement.dateTime = bookmark.ctime.toISOString(); ctimeElement.textContent = bookmark.ctime.toString(); mtimeElement = bookmarkElement.querySelector('.mtime'); mtimeElement.dateTime = bookmark.mtime.toISOString(); mtimeElement.textContent = bookmark.mtime.toString(); tagListElement = bookmarkElement.querySelector('ul.tag-list'); bookmark.tags.forEach(function (tag) { var newNode; var tagElement; var setTagButton; var toggleTagButton; newNode = document.importNode(this.bookmarkTagTemplate.content, true); tagElement = newNode.querySelector('li'); tagElement.dataset.tag = tag; setTagButton = newNode.querySelector('button[name="set-tag"]'); setTagButton.textContent = tag; setTagButton.title = 'Set filter to "' + tag + '"'; toggleTagButton = newNode.querySelector('button[name="toggle-tag"]'); toggleTagButton.textContent = '+'; toggleTagButton.title = 'Add "' + tag + '" to filter'; tagListElement.appendChild(newNode); }, this); // insert new or last modified bookmark on top of the list this.bookmarkListElement.insertBefore(newNode, this.bookmarkListElement.firstChild); this.updateBookmarkMessage(); }; BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) { var bookmarkElement; bookmarkElement = this.bookmarkListElement.querySelector('li' + createDatasetSelector('bookmark-url', bookmarkUrl)); if (bookmarkElement !== null) { this.bookmarkListElement.removeChild(bookmarkElement); this.updateBookmarkMessage(); } }; BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks, newFilterTags, newSearchTerm) { var bookmarkElements; var i; var tagElements; var toggleTagButton; var j; var tag; this.searchTermInputElement.value = newSearchTerm; bookmarkElements = this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li'); for (i = 0; i < bookmarkElements.length; i++) { // update visibility of bookmarks if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) { // update tag elements of visible bookmarks tagElements = bookmarkElements[i].querySelectorAll('ul.tag-list > li'); for (j = 0; j < tagElements.length; j++) { tag = tagElements[j].dataset.tag; toggleTagButton = tagElements[j].querySelector('button[name="toggle-tag"]'); if (newFilterTags.has(tag)) { tagElements[j].classList.add('active-filter-tag'); toggleTagButton.textContent = '\u2212'; toggleTagButton.title = 'Remove "' + tag + '" from filter'; } else { tagElements[j].classList.remove('active-filter-tag'); toggleTagButton.textContent = '+'; toggleTagButton.title = 'Add "' + tag + '" to filter'; } } bookmarkElements[i].hidden = false; } else { bookmarkElements[i].hidden = true; } } this.updateBookmarkMessage(); }; BookmarkView.prototype.createTagInputElement = function (tag) { var newNode; newNode = document.importNode(this.tagInputTemplate.content, true); newNode.querySelector('input[name="tag"]').value = tag; return newNode; }; BookmarkView.prototype.displayBookmarkEditor = function (bookmark) { var bookmarkElement; var newNode; var formElement; var faviconImageElement; var editTagListElement; bookmarkElement = this.bookmarkListElement.querySelector('ul#bookmark-list > li' + createDatasetSelector('bookmark-url', bookmark.url)); // disable edit button while editing bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled = true; // create new editor form from template newNode = document.importNode(this.bookmarkEditorTemplate.content, true); // fill with data of given bookmark formElement = newNode.querySelector('form.bookmark-editor-form'); formElement.querySelector('legend').textContent = 'Edit Bookmark'; formElement['original-url'].value = bookmark.url; formElement.url.value = bookmark.url; formElement.title.value = bookmark.title; faviconImageElement = formElement.querySelector('img.bookmark-favicon'); faviconImageElement.addEventListener('load', this); faviconImageElement.addEventListener('error', this); this.missingFaviconUri = faviconImageElement.src; if (bookmark.favicon) { faviconImageElement.src = bookmark.favicon; } editTagListElement = formElement.querySelector('ul.tag-input-list'); bookmark.tags.forEach(function (tag) { editTagListElement.appendChild(this.createTagInputElement(tag)); }, this); editTagListElement.appendChild(this.createTagInputElement('')); // insert editor form into bookmark item bookmarkElement.appendChild(newNode); // focus first input element formElement.querySelector('input').focus(); }; BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) { return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' + bookmark.url + ']?'); }; BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) { return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' + bookmark.url + ']?'); }; /* * controller */ var BooketController = function(bookmarkModel, actionsView, tagView, bookmarkView) { this.bookmarkModel = bookmarkModel; this.actionsView = actionsView; this.tagView = tagView; this.bookmarkView = bookmarkView; /* connect the views to the model */ this.bookmarkModel.addObserver('bookmark-added', this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView)); this.bookmarkModel.addObserver('bookmark-deleted', this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView)); this.bookmarkModel.addObserver('filter-tags-search-changed', this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView)); this.bookmarkModel.addObserver('load-file-error', this.actionsView.onLoadFileError.bind(this.actionsView)); this.bookmarkModel.addObserver('parse-file-error', this.actionsView.onParseFileError.bind(this.actionsView)); this.bookmarkModel.addObserver('save-file', this.actionsView.onSaveFile.bind(this.actionsView)); this.bookmarkModel.addObserver('export-file', this.actionsView.onExportFile.bind(this.actionsView)); this.bookmarkModel.addObserver('tag-added', this.tagView.onTagAdded.bind(this.tagView)); this.bookmarkModel.addObserver('tag-count-changed', this.tagView.onTagCountChanged.bind(this.tagView)); this.bookmarkModel.addObserver('tag-deleted', this.tagView.onTagDeleted.bind(this.tagView)); this.bookmarkModel.addObserver('filter-tags-search-changed', this.tagView.onFilterTagsSearchChanged.bind(this.tagView)); this.bookmarkModel.addObserver('filter-tags-search-changed', this.onFilterTagsSearchChanged.bind(this)); /* handle input */ window.addEventListener('hashchange', this.onHashChange.bind(this)); window.addEventListener('beforeunload', this.onBeforeUnload.bind(this)); this.actionsView.addObserver('save-file', this.bookmarkModel.saveFile.bind(this.bookmarkModel)); this.actionsView.addObserver('export-file', this.bookmarkModel.exportFile.bind(this.bookmarkModel)); this.actionsView.addObserver('load-file', this.onLoadFile.bind(this)); this.actionsView.addObserver('import-file', this.onImportFile.bind(this)); this.actionsView.addObserver('save-bookmark', this.onSaveBookmark.bind(this)); this.bookmarkView.addObserver('edit-bookmark', this.onEditBookmark.bind(this)); this.bookmarkView.addObserver('save-bookmark', this.onSaveBookmark.bind(this)); this.bookmarkView.addObserver('delete-bookmark', this.onDeleteBookmark.bind(this)); this.bookmarkView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this)); this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this)); this.bookmarkView.addObserver('search', this.onSearch.bind(this)); this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this)); this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this)); }; BooketController.prototype.parseTagsParameter = function (tagsString) { var tags; tags = tagsString.split(',').filter(function (tag) { return (tag !== '') && this.bookmarkModel.hasTag(tag); }, this).sort(); return new StringSet(tags); }; BooketController.prototype.onHashChange = function (e) { var hashData; var filterTags; var searchTerm; hashData = parseHash(window.location.href); filterTags = hashData.has('tags') ? this.parseTagsParameter(hashData.get('tags')) : new StringSet(); searchTerm = hashData.has('search') ? hashData.get('search') : ''; this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm); }; BooketController.prototype.onBeforeUnload = function (e) { var confirmationMessage = 'There are unsaved changes to your bookmarks.'; if (this.bookmarkModel.unsavedChanges) { if (e) { e.returnValue = confirmationMessage; } if (window.event) { window.event.returnValue = confirmationMessage; } return confirmationMessage; } }; BooketController.prototype.onFilterTagsSearchChanged = function (filteredBookmarks, newFilterTags, newSearchTerm) { var url = window.location.href; var hashData; // serialize tag filter and search term and update window.location hashData = parseHash(url); hashData.set('tags', newFilterTags.values().join(',')); hashData.set('search', newSearchTerm); history.pushState(null, null, serializeHash(url, hashData)); }; BooketController.prototype.onLoadFile = function (bookmarkFile) { if (this.bookmarkModel.unsavedChanges) { if (!this.actionsView.confirmLoadFile()) { return; } this.bookmarkModel.unsavedChanges = false; } this.bookmarkModel.loadFile(bookmarkFile); }; BooketController.prototype.onImportFile = function (bookmarkFile) { if (this.bookmarkModel.unsavedChanges) { if (!this.actionsView.confirmLoadFile()) { return; } this.bookmarkModel.unsavedChanges = false; } this.bookmarkModel.importFile(bookmarkFile); }; BooketController.prototype.onEditBookmark = function (bookmarkUrl) { this.bookmarkView.displayBookmarkEditor( this.bookmarkModel.get(bookmarkUrl)); }; BooketController.prototype.onSaveBookmark = function (url, title, favicon, tags, originalUrl) { var ctime; if (originalUrl === undefined) { // saving new bookmark, get confirmation before replacing existing one if (this.bookmarkModel.has(url)) { if (this.bookmarkView.confirmReplaceBookmark( this.bookmarkModel.get(url))) { this.bookmarkModel.delete(url); } else { return; } } ctime = new Date(); } else { // saving edited bookmark, preserve creation time of any replaced // bookmark ctime = (this.bookmarkModel.has(url)) ? this.bookmarkModel.get(url).ctime : new Date(); this.bookmarkModel.delete(originalUrl); } this.bookmarkModel.add(new Bookmark(url, title, favicon, tags, ctime)); }; BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) { if (this.bookmarkView.confirmDeleteBookmark( this.bookmarkModel.get(bookmarkUrl))) { this.bookmarkModel.delete(bookmarkUrl); } }; BooketController.prototype.onToggleFilterTag = function (tag) { this.bookmarkModel.toggleFilterTag(tag); }; BooketController.prototype.onSetTagFilter = function (tag) { this.bookmarkModel.setFilterTags(new StringSet([tag])); }; BooketController.prototype.onSearch = function (searchTerm) { this.bookmarkModel.setSearchTerm(searchTerm); }; document.addEventListener('DOMContentLoaded', function (e) { var controller; var bookmarkModel; var actionsView; var tagView; var bookmarkView; var hashChangeEvent; bookmarkModel = new BookmarkModel(); tagView = new TagView(); actionsView = new ActionsView(); bookmarkView = new BookmarkView(); controller = new BooketController(bookmarkModel, actionsView, tagView, bookmarkView); // initialize state from the current URL hashChangeEvent = new Event('hashchange'); hashChangeEvent.oldURL = window.location.href; hashChangeEvent.newURL = window.location.href; window.dispatchEvent(hashChangeEvent); }); }());