projects/booket

view booket.js @ 31:ffe69492fc67

Improve README file

Adjust URL of the online version.
Extend build instructions.
Add information on bug reporting.
Add contact information.
author Guido Berhoerster <guido+booket@berhoerster.name>
date Thu Jan 29 10:28:28 2015 +0100 (2015-01-29)
parents b2c9c4fb8d4c
children
line source
1 /*
2 * Copyright (C) 2014 Guido Berhoerster <guido+booket@berhoerster.name>
3 *
4 * Permission is hereby granted, free of charge, to any person obtaining
5 * a copy of this software and associated documentation files (the
6 * "Software"), to deal in the Software without restriction, including
7 * without limitation the rights to use, copy, modify, merge, publish,
8 * distribute, sublicense, and/or sell copies of the Software, and to
9 * permit persons to whom the Software is furnished to do so, subject to
10 * the following conditions:
11 *
12 * The above copyright notice and this permission notice shall be included
13 * in all copies or substantial portions of the Software.
14 *
15 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18 * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
19 * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
20 * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
21 * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22 */
24 (function () {
25 'use strict';
27 var BOOKMARKLET_URI =
28 'javascript:(function() {' +
29 '\'use strict\';' +
30 '' +
31 'function displayBookmarkData(bookmarkData) {' +
32 'window.alert(\'Copy the following data and paste it into \' +' +
33 '\'Booket:\\n\\n\' + JSON.stringify(bookmarkData));' +
34 '}' +
35 '' +
36 'var bookmarkData = {' +
37 '\'url\': document.URL,' +
38 '\'title\': document.title,' +
39 '\'favicon\': undefined' +
40 '};' +
41 'var faviconLinkElement;' +
42 'var faviconUrls = [];' +
43 'var aElement;' +
44 'var canvasElement;' +
45 'var canvasCtx;' +
46 'var imgElement;' +
47 '' +
48 'aElement = document.createElement(\'a\');' +
49 'aElement.href = document.URL;' +
50 '' +
51 'faviconUrls.push(aElement.protocol + \'//\' + aElement.host + ' +
52 '\'/favicon.ico\');' +
53 '' +
54 'faviconLinkElement = document.querySelector(' +
55 '\'link[rel~=\\\'icon\\\']\');' +
56 'if (faviconLinkElement !== null) {' +
57 'faviconUrls.push(faviconLinkElement.href);' +
58 '}' +
59 '' +
60 'canvasElement = document.createElement(\'canvas\');' +
61 'canvasCtx = canvasElement.getContext(\'2d\');' +
62 '' +
63 'imgElement = new Image();' +
64 'imgElement.addEventListener(\'load\', function(e) {' +
65 'var faviconUrl;' +
66 '' +
67 'canvasElement.width = 16;' +
68 'canvasElement.height = 16;' +
69 'canvasCtx.clearRect(0, 0, 16, 16);' +
70 'try {' +
71 'canvasCtx.drawImage(this, 0, 0, 16, 16);' +
72 'bookmarkData.favicon = canvasElement.toDataURL();' +
73 '} catch (exception) {' +
74 'faviconUrl = faviconUrls.pop();' +
75 '}' +
76 'if (bookmarkData.favicon !== undefined || ' +
77 'faviconUrl === undefined) {' +
78 'displayBookmarkData(bookmarkData);' +
79 '} else {' +
80 'imgElement.src = faviconUrl;' +
81 '}' +
82 '});' +
83 'imgElement.addEventListener(\'error\', function(e) {' +
84 'var faviconUrl;' +
85 '' +
86 'faviconUrl = faviconUrls.pop();' +
87 'if (faviconUrl !== undefined) {' +
88 'imgElement.src = faviconUrl;' +
89 '} else {' +
90 'displayBookmarkData(bookmarkData);' +
91 '}' +
92 '});' +
93 'imgElement.src = faviconUrls.pop();' +
94 '})();';
97 /*
98 * utility stuff
99 */
101 function isNumber(number) {
102 return (Object.prototype.toString.call(number) === '[object Number]');
103 }
105 function isString(number) {
106 return (Object.prototype.toString.call(number) === '[object String]');
107 }
109 function arrayEqual(array1, array2) {
110 if (!Array.isArray(array1)) {
111 throw new TypeError(typeof array1 + ' is not an array');
112 } else if (!Array.isArray(array2)) {
113 throw new TypeError(typeof array2 + ' is not an array');
114 }
116 if (array1.length !== array2.length) {
117 return false;
118 } else if (array1.length === 0 && array2.length === 0) {
119 return true;
120 }
122 return array1.slice().sort().every(function (value, i) {
123 return value === array2[i];
124 });
125 }
127 function parseHash(url) {
128 var hashData;
129 var pos;
130 var hash;
131 var hashParts;
132 var key;
133 var value;
134 var i;
136 hashData = new StringMap();
137 pos = url.indexOf('#');
138 hash = (pos > -1) ? url.substr(pos + 1) : '';
139 // hash parts are seperated by a ';'
140 hashParts = hash.split(';');
141 for (i = 0; i < hashParts.length; i++) {
142 // key and value pairs are seperated by a '=', an empty value will
143 // cause the key to be ignored
144 pos = hashParts[i].indexOf('=');
145 if (pos > -1) {
146 key = decodeURIComponent(hashParts[i].substr(0, pos));
147 value = decodeURIComponent(hashParts[i].substr(pos + 1));
148 hashData.set(key, value);
149 }
150 }
152 return hashData;
153 }
155 function serializeHash(url, hashData) {
156 var hashParts = [];
157 var pos;
159 pos = url.indexOf('#');
160 if (pos > -1) {
161 url = url.substr(0, pos);
162 }
164 hashData.forEach(function (value, key) {
165 if (value !== '') {
166 hashParts.push(encodeURIComponent(key) + '=' +
167 encodeURIComponent(value));
168 }
169 });
171 // only append a '#' if there are any hash parts
172 return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : '');
173 }
175 function getAncestorElementDatasetItem(node, item) {
176 while ((node = node.parentNode) !== null) {
177 if (node.dataset && node.dataset[item] !== undefined) {
178 return node.dataset[item];
179 }
180 }
182 return undefined;
183 }
185 // for use with Node.querySelector() and Node.querySelectorAll()
186 function createDatasetSelector(name, value) {
187 return '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]';
188 }
190 function extend(targetObject, sourceObject) {
191 var propertyName;
193 for (propertyName in sourceObject.prototype) {
194 if (!Object.prototype.hasOwnProperty.call(targetObject.prototype,
195 propertyName)) {
196 targetObject.prototype[propertyName] =
197 sourceObject.prototype[propertyName];
198 }
199 }
200 }
203 var ObservableMixin = function () {
204 this._eventsObservers = {};
205 };
207 ObservableMixin.prototype.addObserver = function (eventName, observer) {
208 var i;
210 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
211 eventName)) {
212 this._eventsObservers[eventName] = [];
213 }
215 // prevent observers for an event from being called more than once
216 for (i = 0; i < this._eventsObservers[eventName].length; i++) {
217 if (this._eventsObservers[eventName][i] === observer) {
218 return;
219 }
220 }
221 this._eventsObservers[eventName].push(observer);
222 };
224 ObservableMixin.prototype.deleteObserver = function (eventName, observer) {
225 var i = 0;
227 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
228 eventName)) {
229 return;
230 }
232 while (i < this._eventsObservers[eventName].length) {
233 if (this._eventsObservers[eventName][i] === observer) {
234 this._eventsObservers[eventName].splice(i, 1);
235 }
236 }
237 };
239 ObservableMixin.prototype.notify = function (eventName) {
240 var origArguments;
242 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
243 eventName)) {
244 return;
245 }
247 origArguments = Array.prototype.slice.call(arguments, 1);
248 this._eventsObservers[eventName].forEach(function (observer, i) {
249 // call the observer function and pass on any additional arguments
250 observer.apply(undefined, origArguments);
251 });
252 };
255 var StringMap = function (iter) {
256 this._stringMap = Object.create(null);
258 if (iter !== undefined) {
259 if (Array.isArray(iter)) {
260 iter.forEach(function (pair) {
261 if (Array.isArray(pair)) {
262 this.set(pair[0], pair[1]);
263 } else {
264 throw new TypeError(typeof pair + ' is not an array');
265 }
266 }, this);
267 } else {
268 throw new TypeError(typeof iter + ' is not iterable');
269 }
270 }
271 };
273 Object.defineProperty(StringMap.prototype, 'size', {
274 get: function () {
275 var size = 0;
276 var key;
278 for (key in this._stringMap) {
279 if (key.charAt(0) === '@') {
280 size++;
281 }
282 }
284 return size;
285 }
286 });
288 StringMap.prototype.set = function (key, value) {
289 this._stringMap['@' + key] = value;
291 return this;
292 };
294 StringMap.prototype.get = function (key) {
295 return this._stringMap['@' + key];
296 };
298 StringMap.prototype.has = function (key) {
299 return (('@' + key) in this._stringMap);
300 };
302 StringMap.prototype.delete = function (key) {
303 if (this.has(key)) {
304 delete this._stringMap['@' + key];
306 return true;
307 }
309 return false;
310 };
312 StringMap.prototype.forEach = function (callbackFn, thisArg) {
313 Object.keys(this._stringMap).forEach(function (key) {
314 if (key.charAt(0) === '@') {
315 key = key.substr(1);
316 callbackFn.call(thisArg, this.get(key), key, this);
317 }
318 }, this);
319 };
321 StringMap.prototype.keys = function () {
322 return Object.keys(this._stringMap).map(function (key) {
323 return key.substr(1);
324 });
325 };
327 StringMap.prototype.toJSON = function () {
328 return this._stringMap;
329 };
331 StringMap.prototype.toString = function () {
332 return Object.prototype.toString.call(this._stringMap);
333 };
336 var StringSet = function (iter) {
337 this._stringArray = [];
338 this._stringMap = new StringMap();
339 if (iter !== undefined) {
340 if (Array.isArray(iter) || iter instanceof StringSet) {
341 iter.forEach(function (string) {
342 this.add(string);
343 }, this);
344 } else {
345 throw new TypeError(typeof iter + ' is not iterable');
346 }
347 }
348 };
350 Object.defineProperty(StringSet.prototype, 'size', {
351 get: function () {
352 return this._stringArray.length;
353 }
354 });
356 StringSet.prototype.has = function (string) {
357 return this._stringMap.has(string);
358 };
360 StringSet.prototype.add = function (string) {
361 if (!this.has(string)) {
362 this._stringMap.set(string, true);
363 this._stringArray.push(string);
364 }
365 return this;
366 };
368 StringSet.prototype.delete = function (string) {
369 if (this.has(string)) {
370 this._stringMap.delete(string);
371 this._stringArray.splice(this._stringArray.indexOf(string), 1);
372 return true;
373 }
374 return false;
375 };
377 StringSet.prototype.forEach = function (callbackFn, thisArg) {
378 this._stringArray.forEach(function (key) {
379 callbackFn.call(thisArg, key, key, this);
380 });
381 };
383 StringSet.prototype.keys = function () {
384 return this._stringArray.slice();
385 };
387 StringSet.prototype.values = function () {
388 return this._stringArray.slice();
389 };
391 StringSet.prototype.clear = function () {
392 this._stringMap = new StringMap();
393 this._stringArray = [];
394 };
396 StringSet.prototype.toJSON = function () {
397 return this._stringArray;
398 };
400 StringSet.prototype.toString = function () {
401 return this._stringArray.toString();
402 };
405 /*
406 * model
407 */
409 var Bookmark = function (url, title, favicon, tags, ctime, mtime) {
410 var parsedTime;
412 if (!isString(url)) {
413 throw new TypeError(typeof url + ' is not a string');
414 }
415 this.url = url;
417 this.title = (isString(title) && title !== '') ? title : url;
419 if (isString(favicon) && favicon.match(/^data:image\/png;base64,/)) {
420 this.favicon = favicon;
421 } else {
422 this.favicon = undefined;
423 }
425 if (Array.isArray(tags)) {
426 // remove duplicates, non-string or empty tags and tags containing
427 // commas
428 this.tags = new StringSet(tags.filter(function (tag) {
429 return (isString(tag) && tag !== '' && tag.indexOf(',') === -1);
430 }).sort());
431 } else {
432 this.tags = new StringSet();
433 }
435 if (isNumber(ctime) || isString(ctime)) {
436 parsedTime = new Date(ctime);
437 this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date();
438 } else {
439 this.ctime = new Date();
440 }
442 if (isNumber(mtime) || isString(mtime)) {
443 parsedTime = new Date(mtime);
444 // modification time must be greater than creation time
445 this.mtime = (!isNaN(parsedTime.getTime()) ||
446 parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime);
447 } else {
448 this.mtime = new Date(this.ctime);
449 }
450 };
453 var BookmarkModel = function () {
454 ObservableMixin.call(this);
456 this._unsavedChanges = false;
457 this.loadFileReader = null;
458 this.importFileReader= null;
459 this._bookmarks = new StringMap();
460 this._tagCount = new StringMap();
461 this._filterTags = new StringSet();
462 this._searchTerm = '';
463 this._filteredBookmarks = new StringSet();
464 this._searchedBookmarks = new StringSet();
465 };
467 extend(BookmarkModel, ObservableMixin);
469 Object.defineProperty(BookmarkModel.prototype, 'unsavedChanges', {
470 set: function (value) {
471 if (this._unsavedChanges !== value) {
472 this._unsavedChanges = value;
473 this.notify('unsaved-changes-changed', value)
474 }
475 },
476 get: function () {
477 return this._unsavedChanges;
478 }
479 });
481 BookmarkModel.prototype.add = function (bookmarks) {
482 var addedBookmarkUrls = new StringSet();
484 // argument can be a single bookmark or a list of bookmarks
485 if (!Array.isArray(bookmarks)) {
486 bookmarks = [bookmarks];
487 }
489 bookmarks.forEach(function (bookmark) {
490 // delete any existing bookmark for the given URL before adding the new
491 // one in order to update views
492 this.delete(bookmark.url);
493 this._bookmarks.set(bookmark.url, bookmark);
494 addedBookmarkUrls.add(bookmark.url);
495 this.unsavedChanges = true;
496 this.notify('bookmark-added', bookmark);
498 // update tag count
499 bookmark.tags.forEach(function (tag) {
500 var tagCount;
502 if (this._tagCount.has(tag)) {
503 tagCount = this._tagCount.get(tag) + 1;
504 this._tagCount.set(tag, tagCount);
505 this.notify('tag-count-changed', tag, this._tagCount);
506 } else {
507 this._tagCount.set(tag, 1);
508 this.notify('tag-added', tag, this._tagCount);
509 }
510 }, this);
511 }, this);
513 // apply tag filter and search added bookmarks
514 this.updateFilteredSearchedBookmarks(addedBookmarkUrls);
515 this.notify('filter-tags-search-changed', this._searchedBookmarks,
516 this._filterTags, this._searchTerm);
517 };
519 BookmarkModel.prototype.has = function (url) {
520 return this._bookmarks.has(url);
521 };
523 BookmarkModel.prototype.get = function (url) {
524 return this._bookmarks.get(url);
525 };
527 BookmarkModel.prototype.delete = function (urls) {
528 var needUpdateFilterTags = false;
530 // argument can be a single bookmark or a list of bookmarks
531 if (!Array.isArray(urls)) {
532 urls = [urls];
533 }
535 urls.forEach(function (url) {
536 var bookmark;
537 var tagCount;
539 if (this._bookmarks.has(url)) {
540 bookmark = this._bookmarks.get(url);
541 this._bookmarks.delete(url);
542 this.unsavedChanges = true;
543 this.notify('bookmark-deleted', bookmark.url);
545 // update tag count
546 bookmark.tags.forEach(function (tag) {
547 if (this._tagCount.has(tag)) {
548 tagCount = this._tagCount.get(tag);
549 if (tagCount > 1) {
550 tagCount--;
551 this._tagCount.set(tag, tagCount);
552 this.notify('tag-count-changed', tag, this._tagCount);
553 } else {
554 this._tagCount.delete(tag);
555 this.notify('tag-deleted', tag, this._tagCount);
557 if (this._filterTags.has(tag)) {
558 this._filterTags.delete(tag);
559 needUpdateFilterTags = true;
560 }
561 }
562 }
563 }, this);
565 // update filtered and searched bookmarks
566 if (this._filteredBookmarks.has(url)) {
567 this._filteredBookmarks.delete(url);
568 if (this._searchedBookmarks.has(url)) {
569 this._searchedBookmarks.delete(url);
570 }
571 }
572 }
573 }, this);
575 if (needUpdateFilterTags) {
576 this.updateFilteredSearchedBookmarks();
577 this.notify('filter-tags-search-changed', this._searchedBookmarks,
578 this._filterTags, this._searchTerm);
579 }
580 };
582 BookmarkModel.prototype.forEach = function (callbackFn, thisArg) {
583 this._bookmarks.keys().forEach(function (key) {
584 callbackFn.call(thisArg, this._bookmarks.get(key), key, this);
585 }, this);
586 };
588 BookmarkModel.prototype.hasTag = function (tag) {
589 return this._tagCount.has(tag);
590 };
592 BookmarkModel.prototype.getTagCount = function (tag) {
593 return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined;
594 };
596 BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) {
597 var searchUrls;
599 // additive search if urlsSubset is given
600 if (urlsSubset !== undefined) {
601 searchUrls = urlsSubset;
602 } else {
603 this._searchedBookmarks = new StringSet();
605 searchUrls = this._filteredBookmarks.values();
606 }
608 // search for the search term in title and URL
609 searchUrls.forEach(function (url) {
610 var bookmark;
612 bookmark = this.get(url);
613 if (this._searchTerm === '' ||
614 bookmark.title.indexOf(this._searchTerm) !== -1 ||
615 bookmark.url.indexOf(this._searchTerm) !== -1) {
616 this._searchedBookmarks.add(url);
617 }
618 }, this);
619 };
621 BookmarkModel.prototype.updateFilteredSearchedBookmarks =
622 function (urlsSubset) {
623 var filterUrls;
624 var searchUrls;
626 // additive filtering if urlsSubset is given
627 if (urlsSubset !== undefined) {
628 filterUrls = urlsSubset;
629 searchUrls = [];
630 } else {
631 this._filteredBookmarks = new StringSet();
633 filterUrls = this._bookmarks.keys();
634 searchUrls = undefined;
635 }
637 // apply tag filter
638 filterUrls.forEach(function (url) {
639 var bookmark;
640 var matchingTagCount = 0;
642 bookmark = this.get(url);
644 bookmark.tags.forEach(function (tag) {
645 if (this._filterTags.has(tag)) {
646 matchingTagCount++;
647 }
648 }, this);
650 if (matchingTagCount === this._filterTags.size) {
651 this._filteredBookmarks.add(url);
652 if (urlsSubset !== undefined) {
653 searchUrls.push(url);
654 }
655 }
656 }, this);
658 // search the filter results
659 this.updateSearchedBookmarks(searchUrls);
660 };
662 BookmarkModel.prototype.toggleFilterTag = function (tag) {
663 if (this._filterTags.has(tag)) {
664 this._filterTags.delete(tag);
665 } else {
666 this._filterTags.add(tag);
667 }
668 this.updateFilteredSearchedBookmarks();
669 this.notify('filter-tags-search-changed', this._searchedBookmarks,
670 this._filterTags, this._searchTerm);
671 };
673 BookmarkModel.prototype.setFilterTags = function (filterTags) {
674 if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
675 this._filterTags = new StringSet(filterTags);
676 this.updateFilteredSearchedBookmarks();
677 this.notify('filter-tags-search-changed', this._searchedBookmarks,
678 this._filterTags, this._searchTerm);
679 }
680 };
682 BookmarkModel.prototype.setSearchTerm = function (searchTerm) {
683 if (searchTerm !== this._searchTerm) {
684 this._searchTerm = searchTerm;
685 this.updateSearchedBookmarks();
686 this.notify('filter-tags-search-changed', this._searchedBookmarks,
687 this._filterTags, this._searchTerm);
688 }
689 };
691 BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags,
692 searchTerm) {
693 if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
694 this._filterTags = new StringSet(filterTags);
695 this._searchTerm = searchTerm;
696 this.updateFilteredSearchedBookmarks();
697 this.notify('filter-tags-search-changed', this._searchedBookmarks,
698 this._filterTags, this._searchTerm);
699 } else if (searchTerm !== this._searchTerm) {
700 this._searchTerm = searchTerm;
701 this.updateSearchedBookmarks();
702 this.notify('filter-tags-search-changed', this._searchedBookmarks,
703 this._filterTags, this._searchTerm);
704 }
705 };
707 BookmarkModel.prototype.parseLoadedBookmarks = function (data) {
708 var wasEmpty = !this._bookmarks.size;
709 var parsedData;
710 var bookmarks = [];
711 var bookmark;
712 var oldBookmark;
714 try {
715 parsedData = JSON.parse(data);
716 } catch (e) {
717 this.notify('load-file-error', e.message);
718 return;
719 }
721 if (!Array.isArray(parsedData.bookmarks)) {
722 this.notify('parse-file-error',
723 'This file does not contain bookmarks.');
724 return;
725 }
727 // create a temporary list of valid bookmarks
728 parsedData.bookmarks.forEach(function (bookmark) {
729 if (isString(bookmark.url) && bookmark.url !== '') {
730 bookmark = new Bookmark(bookmark.url, bookmark.title,
731 bookmark.favicon, bookmark.tags, bookmark.ctime, bookmark.mtime)
732 oldBookmark = this.get(bookmark.url);
733 if (oldBookmark === undefined ||
734 oldBookmark.mtime < bookmark.mtime) {
735 bookmarks.push(bookmark);
736 }
737 }
738 }, this);
740 // add each bookmark to the model ordered by the last modification time
741 this.add(bookmarks.sort(function (bookmark1, bookmark2) {
742 return bookmark1.ctime - bookmark2.ctime;
743 }));
744 if (wasEmpty) {
745 // if there were no bookmarks before there cannot be any unsaved changes
746 this.unsavedChanges = false;
747 }
748 };
750 BookmarkModel.prototype.parseImportedBookmarks = function (data) {
751 var wasEmpty = (this._bookmarks.size > 0);
752 var bookmarkDoc;
753 var bookmarkElements;
754 var i;
755 var url;
756 var title;
757 var favicon;
758 var tags;
759 var ctime;
760 var mtime;
761 var bookmarks = [];
762 var bookmark;
763 var oldBookmark;
765 bookmarkDoc = document.implementation.createHTMLDocument();
766 bookmarkDoc.open();
767 bookmarkDoc.write(data);
768 bookmarkDoc.close();
770 // create a temporary list of valid bookmarks
771 bookmarkElements = bookmarkDoc.querySelectorAll('dt > a[href]');
772 for (i = 0; i < bookmarkElements.length; i++) {
773 url = bookmarkElements[i].href;
774 if (url !== '') {
775 title = bookmarkElements[i].textContent;
776 favicon = bookmarkElements[i].getAttribute('icon');
777 tags = ((tags = bookmarkElements[i].getAttribute('tags')) !==
778 null) ? tags.split(',') : [];
779 ctime = !isNaN(ctime =
780 parseInt(bookmarkElements[i].getAttribute('add_date'), 10)) ?
781 ctime * 1000 : undefined;
782 mtime = !isNaN(mtime =
783 parseInt(bookmarkElements[i].getAttribute('last_modified'),
784 10)) ? mtime * 1000 : undefined;
785 bookmark = new Bookmark(url, title, favicon, tags, ctime, mtime);
786 oldBookmark = this.get(bookmark.url);
787 if (oldBookmark === undefined ||
788 oldBookmark.mtime < bookmark.mtime) {
789 bookmarks.push(bookmark);
790 }
791 }
792 }
794 // add each bookmark to the model ordered by the last modification time
795 this.add(bookmarks.sort(function (bookmark1, bookmark2) {
796 return bookmark1.ctime - bookmark2.ctime;
797 }));
798 if (!wasEmpty) {
799 // if there were no bookmarks before there cannot be any unsaved changes
800 this.unsavedChanges = false;
801 }
802 };
804 BookmarkModel.prototype.loadFile = function (bookmarkFile, merge) {
805 if (!merge) {
806 // delete all existing bookmarks first
807 this.delete(this._bookmarks.keys());
808 this.unsavedChanges = false;
809 }
811 this.loadFileReader = new FileReader();
812 this.loadFileReader.addEventListener('error', this);
813 this.loadFileReader.addEventListener('load', this);
814 this.loadFileReader.readAsText(bookmarkFile);
815 };
817 BookmarkModel.prototype.importFile = function (bookmarkFile, merge) {
818 if (!merge) {
819 // delete all existing bookmarks first
820 this.delete(this._bookmarks.keys());
821 this.unsavedChanges = false;
822 }
824 this.importFileReader = new FileReader();
825 this.importFileReader.addEventListener('error', this);
826 this.importFileReader.addEventListener('load', this);
827 this.importFileReader.readAsText(bookmarkFile);
828 };
830 BookmarkModel.prototype.saveFile = function () {
831 var jsonBlob;
832 var bookmarkData = {
833 'bookmarks': []
834 };
836 this._bookmarks.forEach(function (bookmark) {
837 bookmarkData.bookmarks.push(bookmark);
838 }, this);
840 jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type:
841 'application/json'});
842 this.notify('save-file', jsonBlob);
843 this.unsavedChanges = false;
844 };
846 BookmarkModel.prototype.exportFile = function () {
847 var htmlBlob;
848 var bookmarkDoc;
849 var commentNode;
850 var metaElement;
851 var titleElement;
852 var headingElement;
853 var bookmarkListElement;
854 var bookmarkLinkElement;
855 var bookmarkElement;
857 bookmarkDoc = document.implementation.createHTMLDocument();
859 // construct Netscape bookmarks format within body
860 commentNode = bookmarkDoc.createComment('This is an automatically ' +
861 'generated file.\nIt will be read and overwritten.\nDO NOT EDIT!');
862 bookmarkDoc.body.appendChild(commentNode);
864 metaElement = bookmarkDoc.createElement('meta');
865 metaElement.setAttribute('http-equiv', 'Content-Type');
866 metaElement.setAttribute('content', 'text/html; charset=UTF-8');
867 bookmarkDoc.body.appendChild(metaElement);
869 titleElement = bookmarkDoc.createElement('title');
870 titleElement.textContent = 'Bookmarks';
871 bookmarkDoc.body.appendChild(titleElement);
873 headingElement = bookmarkDoc.createElement('h1');
874 headingElement.textContent = 'Bookmarks';
875 bookmarkDoc.body.appendChild(headingElement);
877 bookmarkListElement = bookmarkDoc.createElement('dl');
878 bookmarkDoc.body.appendChild(bookmarkListElement);
880 this._bookmarks.forEach(function (bookmark) {
881 bookmarkElement = bookmarkDoc.createElement('dt');
883 bookmarkLinkElement = bookmarkDoc.createElement('a');
884 bookmarkLinkElement.href = bookmark.url;
885 bookmarkLinkElement.textContent = bookmark.title;
886 bookmarkLinkElement.setAttribute('icon', bookmark.favicon);
887 bookmarkLinkElement.setAttribute('tags',
888 bookmark.tags.values().join(','));
889 bookmarkLinkElement.setAttribute('add_date',
890 Math.round(bookmark.ctime.getTime() / 1000));
891 bookmarkLinkElement.setAttribute('last_modified',
892 Math.round(bookmark.mtime.getTime() / 1000));
894 bookmarkElement.appendChild(bookmarkLinkElement);
896 bookmarkListElement.appendChild(bookmarkElement);
897 bookmarkListElement.appendChild(bookmarkDoc.createElement('dd'));
898 }, this);
900 htmlBlob = new Blob(['<!DOCTYPE NETSCAPE-Bookmark-file-1>\n' +
901 bookmarkDoc.body.innerHTML], {type: 'text/html'});
902 this.notify('export-file', htmlBlob);
903 };
905 BookmarkModel.prototype.handleEvent = function (e) {
906 if (e.type === 'load') {
907 if (e.target === this.loadFileReader) {
908 this.parseLoadedBookmarks(e.target.result);
909 this.loadFileReader = null;
910 } else if (e.target === this.importFileReader) {
911 this.parseImportedBookmarks(e.target.result);
912 this.importFileReader = null;
913 }
914 } else if (e.type === 'error') {
915 this.notify('load-file-error', e.target.error.message);
916 }
917 };
920 /*
921 * view
922 */
924 var NotificationsView = function () {
925 this.unsavedChangesElement =
926 document.querySelector('#notifications .unsaved-changes-message');
928 this.shortcutKeysOverlayElement =
929 document.querySelector('#keyboard-shortcuts');
931 document.addEventListener('keydown', this);
932 document.addEventListener('keyup', this);
933 };
935 NotificationsView.prototype.handleEvent = function (e) {
936 var elements;
937 var i;
939 // keyboard shortcut hints are visible when the Alt key is pressed and
940 // hidden again when the Alt key is released or another key is pressed
941 if (this.shortcutKeysOverlayElement.dataset.overlayVisible !== undefined &&
942 (e.type === 'keyup' || e.type === 'keydown')) {
943 delete this.shortcutKeysOverlayElement.dataset.overlayVisible;
944 } else if (e.type === 'keydown' && e.keyCode === 18) {
945 this.shortcutKeysOverlayElement.dataset.overlayVisible = '';
946 }
947 };
949 NotificationsView.prototype.onUnsavedChangesChanged = function (unsavedChanges) {
950 this.unsavedChangesElement.hidden = !unsavedChanges;
951 };
954 var TagView = function () {
955 var tagsElement;
957 ObservableMixin.call(this);
959 tagsElement = document.querySelector('#tags');
960 tagsElement.addEventListener('click', this);
962 this.tagListElement = tagsElement.querySelector('ul.tag-list');
964 this.tagDatalistElement = document.querySelector('#tag-datalist');
966 this.tagTemplate = document.querySelector('#tag-template');
967 };
969 extend(TagView, ObservableMixin);
971 TagView.prototype.onTagAdded = function (tag, tagCount) {
972 var newNode;
973 var tagElement;
974 var setTagButton;
975 var toggleTagButton;
976 var tagElements;
977 var i;
978 var referenceTag = '';
979 var referenceNode;
980 var tagOptionElement;
981 var i;
982 var isInDatalist = false;
984 // create new tag element from template
985 newNode = document.importNode(this.tagTemplate.content, true);
987 tagElement = newNode.querySelector('li');
988 tagElement.dataset.tag = tag;
990 setTagButton = tagElement.querySelector('button[name="set-tag"]');
991 setTagButton.textContent = tag;
992 setTagButton.title = 'Set filter to "' + tag + '"';
994 toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]');
995 toggleTagButton.textContent = '+';
996 toggleTagButton.title = 'Add "' + tag + '" to filter';
998 // maintain alphabetical order when inserting the tag element
999 tagElements = this.tagListElement.querySelectorAll('li');
1000 for (i = 0; i < tagElements.length; i ++) {
1001 if (tagElements[i].dataset.tag > referenceTag &&
1002 tagElements[i].dataset.tag < tag) {
1003 referenceTag = tagElements[i].dataset.tag;
1004 referenceNode = tagElements[i];
1007 this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ?
1008 referenceNode.nextSibling : this.tagListElement.firstChild);
1010 // initialize tag count
1011 this.onTagCountChanged(tag, tagCount);
1013 // add to datalist
1014 for (i = 0; i < this.tagDatalistElement.options.length; i++) {
1015 if (this.tagDatalistElement.options[i].value == tag) {
1016 isInDatalist = true;
1017 break;
1020 if (!isInDatalist) {
1021 tagOptionElement = document.createElement('option');
1022 tagOptionElement.value = tag;
1023 this.tagDatalistElement.appendChild(tagOptionElement);
1025 };
1027 TagView.prototype.updateTagCloud = function (tagCount) {
1028 var tagElements;
1029 var i;
1030 var j;
1031 var tagCountMax = 1;
1033 tagCount.forEach(function (count) {
1034 if (count > tagCountMax) {
1035 tagCountMax = count;
1037 }, this);
1039 tagElements = this.tagListElement.querySelectorAll('ul.tag-list > li');
1040 for (i = 0; i < tagElements.length; i++) {
1041 for (j = 1; j <= 10; j++) {
1042 tagElements[i].classList.remove('tag-frequency-' + j);
1044 tagElements[i].classList.add('tag-frequency-' +
1045 (Math.floor(tagCount.get(tagElements[i].dataset.tag) /
1046 (tagCountMax / 9)) + 1));
1048 };
1050 TagView.prototype.onTagCountChanged = function (tag, tagCount) {
1051 this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) +
1052 ' .tag-count').textContent = '(' + tagCount.get(tag) + ')';
1053 this.updateTagCloud(tagCount);
1054 };
1056 TagView.prototype.onTagDeleted = function (tag, tagCount) {
1057 var tagElement;
1059 // remove from tag list
1060 tagElement = this.tagListElement.querySelector('li' +
1061 createDatasetSelector('tag', tag));
1062 tagElement.parentNode.removeChild(tagElement);
1064 this.updateTagCloud(tagCount);
1065 };
1067 TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
1068 newFilterTags, newSearchTerm) {
1069 var tagElements;
1070 var i;
1071 var tag;
1072 var toggleTagButton;
1074 tagElements = this.tagListElement.querySelectorAll('li');
1075 for (i = 0; i < tagElements.length; i++) {
1076 tag = tagElements[i].dataset.tag;
1077 toggleTagButton =
1078 tagElements[i].querySelector('button[name="toggle-tag"]');
1079 if (newFilterTags.has(tag)) {
1080 tagElements[i].classList.add('active-filter-tag');
1081 toggleTagButton.textContent = '\u2212';
1082 toggleTagButton.title = 'Remove "' + tag + '" from filter';
1083 } else {
1084 tagElements[i].classList.remove('active-filter-tag');
1085 toggleTagButton.textContent = '+';
1086 toggleTagButton.title = 'Add "' + tag + '" to filter';
1089 };
1091 TagView.prototype.handleEvent = function (e) {
1092 if (e.type === 'click') {
1093 if (e.target.name === 'set-tag' ||
1094 e.target.name === 'toggle-tag') {
1095 e.target.blur();
1097 this.notify(e.target.name, getAncestorElementDatasetItem(e.target,
1098 'tag'));
1099 } else if (e.target.name === 'show-tag-cloud') {
1100 this.tagListElement.classList.toggle('tag-cloud', e.target.checked);
1103 };
1106 var ActionsView = function () {
1107 var saveFormElement;
1108 var newNode;
1109 var fieldsetElement;
1110 var legendElement;
1111 var i;
1113 ObservableMixin.call(this);
1115 this.actionsElement = document.querySelector('#actions');
1117 this.tagInputTemplate = document.querySelector('#tag-input-template');
1118 saveFormElement = document.querySelector('form#save-form');
1120 this.saveLinkElement = saveFormElement.querySelector('a#save-link');
1122 // create new editor form from template
1123 newNode = document.importNode(
1124 document.querySelector('#bookmark-editor-template').content, true);
1126 this.editorFormElement = newNode.querySelector('form.bookmark-editor-form');
1127 this.editorFormElement.querySelector('label').accessKey = 'a';
1129 fieldsetElement = this.editorFormElement.querySelector('fieldset');
1130 fieldsetElement.classList.add('expander');
1132 legendElement = fieldsetElement.querySelector('legend');
1133 legendElement.textContent = 'Add Bookmark';
1134 legendElement.tabIndex = 0;
1135 legendElement.classList.add('expander-label');
1137 this.faviconImageElement =
1138 this.editorFormElement.querySelector('img.bookmark-favicon');
1139 this.faviconImageElement.addEventListener('load', this);
1140 this.faviconImageElement.addEventListener('error', this);
1142 this.missingFaviconUri = this.faviconImageElement.src;
1144 this.editTagListElement =
1145 this.editorFormElement.querySelector('ul.tag-input-list');
1146 // add four tag input elements for convenience
1147 for (i = 0; i < 4; i++) {
1148 this.editTagListElement.appendChild(this.createTagInputElement(''));
1151 saveFormElement.parentNode.insertBefore(newNode,
1152 saveFormElement.nextSibling);
1154 this.actionsElement.addEventListener('input', this);
1155 this.actionsElement.addEventListener('click', this);
1156 this.actionsElement.addEventListener('keydown', this);
1157 this.actionsElement.addEventListener('submit', this);
1158 this.actionsElement.addEventListener('reset', this);
1160 document.querySelector('a#bookmarklet-link').href = BOOKMARKLET_URI;
1161 };
1163 extend(ActionsView, ObservableMixin);
1165 ActionsView.prototype.createTagInputElement = function (tag) {
1166 var newNode;
1168 newNode = document.importNode(this.tagInputTemplate.content, true);
1169 newNode.querySelector('input[name="tag"]').value = tag;
1171 return newNode;
1172 };
1174 ActionsView.prototype.handleEvent = function (e) {
1175 var bookmarkletData;
1176 var parsedData;
1177 var tags = [];
1178 var i;
1179 var tagInputElements;
1181 switch (e.type) {
1182 case 'error':
1183 if (e.target.classList.contains('bookmark-favicon')) {
1184 if (e.target.src !== this.missingFaviconUri) {
1185 e.target.src = this.missingFaviconUri;
1188 break;
1189 case 'load':
1190 if (e.target.classList.contains('bookmark-favicon')) {
1191 this.editorFormElement.favicon.value =
1192 (e.target.src !== this.missingFaviconUri) ? e.target.src : '';
1194 break;
1195 case 'input':
1196 if (e.target.name === 'bookmarklet-import') {
1197 // get rid of any preceding text
1198 bookmarkletData = e.target.value.replace(/^[^{]*/, '');
1200 try {
1201 parsedData = JSON.parse(bookmarkletData);
1202 } catch (exception) {
1203 return;
1206 if (isString(parsedData.url) && parsedData.url !== '') {
1207 e.target.form.elements.url.value = parsedData.url;
1209 if (isString(parsedData.title) && parsedData.title !== '') {
1210 e.target.form.elements.title.value = parsedData.title;
1212 if (isString(parsedData.favicon) &&
1213 parsedData.favicon.match(/^data:image\/png;base64,/)) {
1214 this.faviconImageElement.src = parsedData.favicon;
1217 break;
1218 case 'click':
1219 if (e.target.name === 'more-tags') {
1220 e.preventDefault();
1221 e.target.blur();
1223 this.editTagListElement.appendChild(this.createTagInputElement(''));
1224 } else if (e.target.classList.contains('expander-label')) {
1225 if (e.target.parentNode.dataset.expanderOpen !== undefined) {
1226 delete e.target.parentNode.dataset.expanderOpen;
1227 } else {
1228 e.target.parentNode.dataset.expanderOpen = '';
1231 break;
1232 case 'keydown':
1233 if (e.keyCode === 32 && e.target.classList.contains('expander-label')) {
1234 if (e.target.parentNode.dataset.expanderOpen !== undefined) {
1235 delete e.target.parentNode.dataset.expanderOpen;
1236 } else {
1237 e.target.parentNode.dataset.expanderOpen = '';
1240 break;
1241 case 'submit':
1242 if (e.target.id === 'save-form') {
1243 e.preventDefault();
1244 e.target.blur();
1246 this.notify('save-file');
1247 } else if (e.target.id === 'load-form') {
1248 e.preventDefault();
1249 e.target.blur();
1251 this.notify('load-file', e.target.file.files[0],
1252 e.target.merge.checked);
1253 e.target.reset();
1254 } else if (e.target.id === 'import-form') {
1255 e.preventDefault();
1256 e.target.blur();
1258 this.notify('import-file', e.target.file.files[0],
1259 e.target.merge.checked);
1260 e.target.reset();
1261 } else if (e.target.id === 'export-form') {
1262 e.preventDefault();
1263 e.target.blur();
1265 this.notify('export-file');
1266 } else if (e.target.classList.contains('bookmark-editor-form')) {
1267 e.preventDefault();
1268 e.target.blur();
1270 if (e.target.tag.length) {
1271 for (i = 0; i < e.target.tag.length; i++) {
1272 tags.push(e.target.tag[i].value.trim());
1274 } else {
1275 tags.push(e.target.tag.value.trim());
1278 this.notify('save-bookmark', e.target.url.value,
1279 e.target.title.value, e.target.favicon.value, tags);
1281 e.target.reset();
1283 break;
1284 case 'reset':
1285 if (e.target.classList.contains('bookmark-editor-form')) {
1286 e.target.blur();
1288 e.target.querySelector('img.bookmark-favicon').src =
1289 this.missingFaviconUri;
1291 // remove all but the first four tag input elements
1292 tagInputElements =
1293 this.editTagListElement.querySelectorAll('li:nth-child(n+5)');
1294 for (i = 0; i < tagInputElements.length; i++) {
1295 this.editTagListElement.removeChild(tagInputElements[i]);
1298 break;
1300 };
1302 ActionsView.prototype.onSaveFile = function (jsonBlob) {
1303 this.saveLinkElement.href = URL.createObjectURL(jsonBlob);
1304 this.saveLinkElement.click();
1305 };
1307 ActionsView.prototype.onExportFile = function (htmlBlob) {
1308 var exportLinkElement;
1310 exportLinkElement = document.querySelector('a#export-link');
1311 exportLinkElement.href = URL.createObjectURL(htmlBlob);
1312 exportLinkElement.click();
1313 };
1315 ActionsView.prototype.confirmLoadFile = function () {
1316 return window.confirm('There are unsaved changes to your bookmarks.\n' +
1317 'Proceed loading the bookmark file?');
1318 };
1320 ActionsView.prototype.onLoadFileError = function (message) {
1321 window.alert('Failed to load bookmark file:\n' + message);
1322 };
1324 ActionsView.prototype.onParseFileError = function (message) {
1325 window.alert('Failed to parse bookmark file:\n' + message);
1326 };
1329 var BookmarkView = function () {
1330 var searchFormElement;
1332 ObservableMixin.call(this);
1334 this.bookmarkTemplate = document.querySelector('#bookmark-template');
1335 this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template');
1336 this.bookmarkEditorTemplate =
1337 document.querySelector('#bookmark-editor-template');
1338 this.tagInputTemplate = document.querySelector('#tag-input-template');
1340 this.bookmarkListElement = document.querySelector('ul#bookmark-list');
1341 this.bookmarkListElement.addEventListener('input', this);
1342 this.bookmarkListElement.addEventListener('click', this);
1343 this.bookmarkListElement.addEventListener('keydown', this);
1344 this.bookmarkListElement.addEventListener('submit', this);
1345 this.bookmarkListElement.addEventListener('reset', this);
1347 searchFormElement = document.querySelector('#search-form');
1348 searchFormElement.addEventListener('submit', this);
1349 searchFormElement.addEventListener('reset', this);
1351 this.searchTermInputElement = searchFormElement['search-term'];
1353 this.bookmarkMessageElement = document.querySelector('#bookmark-message');
1355 this.missingFaviconUri = '';
1357 this.updateBookmarkMessage();
1358 };
1360 extend(BookmarkView, ObservableMixin);
1362 BookmarkView.prototype.getAncestorClass = function (node, className) {
1363 while ((node = node.parentNode) !== null &&
1364 (!node.classList || !node.classList.contains(className)));
1366 return node;
1367 };
1369 BookmarkView.prototype.handleEvent = function (e) {
1370 var bookmarkletData;
1371 var parsedData;
1372 var i;
1373 var tags = [];
1374 var node;
1376 switch (e.type) {
1377 case 'error':
1378 if (e.target.classList.contains('bookmark-favicon')) {
1379 if (e.target.src !== this.missingFaviconUri) {
1380 e.target.src = this.missingFaviconUri;
1383 break;
1384 case 'load':
1385 if (e.target.classList.contains('bookmark-favicon')) {
1386 node = e.target;
1387 while ((node = node.parentNode) !== null) {
1388 if (node.classList.contains('bookmark-editor-form')) {
1389 node.favicon.value =
1390 (e.target.src !== this.missingFaviconUri) ?
1391 e.target.src : '';
1392 break;
1396 break;
1397 case 'input':
1398 if (e.target.name === 'bookmarklet-import') {
1399 // get rid of any preceding text
1400 bookmarkletData = e.target.value.replace(/^[^{]*/, '');
1402 try {
1403 parsedData = JSON.parse(bookmarkletData);
1404 } catch (exception) {
1405 return;
1408 if (isString(parsedData.url) && parsedData.url !== '') {
1409 e.target.form.elements.url.value = parsedData.url;
1411 if (isString(parsedData.title) && parsedData.title !== '') {
1412 e.target.form.elements.title.value = parsedData.title;
1414 if (isString(parsedData.favicon) &&
1415 parsedData.favicon.match(/^data:image\/png;base64,/)) {
1416 e.target.form.querySelector('img.bookmark-favicon').src =
1417 parsedData.favicon;
1420 break;
1421 case 'click':
1422 switch (e.target.name) {
1423 case 'edit-bookmark':
1424 e.target.blur();
1425 // fallthrough
1426 case 'delete-bookmark':
1427 this.notify(e.target.name,
1428 getAncestorElementDatasetItem(e.target, 'bookmarkUrl'));
1429 break;
1430 case 'more-tags':
1431 e.target.blur();
1433 e.target.form.querySelector('ul.tag-input-list').appendChild(
1434 this.createTagInputElement(''));
1435 break;
1436 case 'set-tag':
1437 case 'toggle-tag':
1438 e.target.blur();
1440 this.notify(e.target.name,
1441 getAncestorElementDatasetItem(e.target, 'tag'));
1442 break;
1443 default:
1444 if ((node = this.getAncestorClass(e.target, 'expander')) !== null) {
1445 if (node.dataset.expanderOpen !== undefined) {
1446 delete node.dataset.expanderOpen;
1447 } else {
1448 node.dataset.expanderOpen = '';
1451 break;
1453 break;
1454 case 'keydown':
1455 if (e.keyCode === 32 &&
1456 (node = this.getAncestorClass(e.target, 'expander')) !== null) {
1457 if (node.dataset.expanderOpen !== undefined) {
1458 delete node.dataset.expanderOpen;
1459 } else {
1460 node.dataset.expanderOpen = '';
1463 break;
1464 case 'submit':
1465 if (e.target.classList.contains('bookmark-editor-form')) {
1466 // save bookmark-editor-form form contents
1467 e.preventDefault();
1469 if (e.target.tag.length) {
1470 for (i = 0; i < e.target.tag.length; i++) {
1471 tags.push(e.target.tag[i].value.trim());
1473 } else {
1474 tags.push(e.target.tag.value.trim());
1477 this.notify('save-bookmark', e.target.url.value,
1478 e.target.title.value, e.target.favicon.value, tags,
1479 e.target['original-url'].value);
1480 } else if (e.target.id === 'search-form') {
1481 // search
1482 e.preventDefault();
1483 e.target.blur();
1485 this.notify('search', e.target['search-term'].value);
1487 break;
1488 case 'reset':
1489 if (e.target.classList.contains('bookmark-editor-form')) {
1490 // cancel bookmark-editor-form form
1491 e.preventDefault();
1493 // re-enable edit button
1494 this.bookmarkListElement.querySelector('li' +
1495 createDatasetSelector('bookmark-url',
1496 e.target['original-url'].value) +
1497 ' button[name="edit-bookmark"]').disabled = false;
1499 e.target.parentNode.removeChild(e.target);
1500 } else if (e.target.id === 'search-form') {
1501 // clear search
1502 e.preventDefault();
1503 e.target.blur();
1505 this.notify('search', '');
1507 break;
1509 };
1511 BookmarkView.prototype.updateBookmarkMessage = function () {
1512 this.bookmarkMessageElement.textContent = 'Showing ' +
1513 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
1514 'li:not([hidden])').length + ' of ' +
1515 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
1516 'li').length + ' bookmarks.';
1517 };
1519 BookmarkView.prototype.onBookmarkAdded = function (bookmark) {
1520 var newNode;
1521 var bookmarkElement;
1522 var faviconElement;
1523 var linkElement;
1524 var hostnameElement;
1525 var urlElement;
1526 var ctimeElement;
1527 var mtimeElement;
1528 var tagListElement;
1530 newNode = document.importNode(this.bookmarkTemplate.content, true);
1532 bookmarkElement = newNode.querySelector('li');
1533 bookmarkElement.dataset.bookmarkUrl = bookmark.url;
1535 faviconElement = bookmarkElement.querySelector('img.bookmark-favicon');
1536 faviconElement.src = (bookmark.favicon) ? bookmark.favicon :
1537 this.missingFaviconUri;
1538 faviconElement.alt = '';
1540 linkElement = bookmarkElement.querySelector('a.bookmark-link');
1541 linkElement.textContent = linkElement.title = bookmark.title;
1542 linkElement.href = bookmark.url;
1544 hostnameElement = bookmarkElement.querySelector('.bookmark-hostname');
1545 hostnameElement.textContent = (linkElement.hostname !== '') ?
1546 '[' + linkElement.hostname + ']' : '';
1548 urlElement = bookmarkElement.querySelector('.bookmark-url');
1549 urlElement.textContent = bookmark.url;
1551 ctimeElement = bookmarkElement.querySelector('.ctime');
1552 ctimeElement.dateTime = bookmark.ctime.toISOString();
1553 ctimeElement.textContent = bookmark.ctime.toString();
1555 mtimeElement = bookmarkElement.querySelector('.mtime');
1556 mtimeElement.dateTime = bookmark.mtime.toISOString();
1557 mtimeElement.textContent = bookmark.mtime.toString();
1559 tagListElement = bookmarkElement.querySelector('ul.tag-list');
1560 bookmark.tags.forEach(function (tag) {
1561 var newNode;
1562 var tagElement;
1563 var setTagButton;
1564 var toggleTagButton;
1566 newNode = document.importNode(this.bookmarkTagTemplate.content, true);
1568 tagElement = newNode.querySelector('li');
1569 tagElement.dataset.tag = tag;
1571 setTagButton = newNode.querySelector('button[name="set-tag"]');
1572 setTagButton.textContent = tag;
1573 setTagButton.title = 'Set filter to "' + tag + '"';
1575 toggleTagButton = newNode.querySelector('button[name="toggle-tag"]');
1576 toggleTagButton.textContent = '+';
1577 toggleTagButton.title = 'Add "' + tag + '" to filter';
1579 tagListElement.appendChild(newNode);
1580 }, this);
1582 // insert new or last modified bookmark on top of the list
1583 this.bookmarkListElement.insertBefore(newNode,
1584 this.bookmarkListElement.firstChild);
1586 this.updateBookmarkMessage();
1587 };
1589 BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) {
1590 var bookmarkElement;
1592 bookmarkElement = this.bookmarkListElement.querySelector('li' +
1593 createDatasetSelector('bookmark-url', bookmarkUrl));
1594 if (bookmarkElement !== null) {
1595 this.bookmarkListElement.removeChild(bookmarkElement);
1597 this.updateBookmarkMessage();
1599 };
1601 BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
1602 newFilterTags, newSearchTerm) {
1603 var bookmarkElements;
1604 var i;
1605 var tagElements;
1606 var toggleTagButton;
1607 var j;
1608 var tag;
1610 this.searchTermInputElement.value = newSearchTerm;
1612 bookmarkElements =
1613 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li');
1614 for (i = 0; i < bookmarkElements.length; i++) {
1615 // update visibility of bookmarks
1616 if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) {
1617 // update tag elements of visible bookmarks
1618 tagElements =
1619 bookmarkElements[i].querySelectorAll('ul.tag-list > li');
1620 for (j = 0; j < tagElements.length; j++) {
1621 tag = tagElements[j].dataset.tag;
1622 toggleTagButton =
1623 tagElements[j].querySelector('button[name="toggle-tag"]');
1624 if (newFilterTags.has(tag)) {
1625 tagElements[j].classList.add('active-filter-tag');
1626 toggleTagButton.textContent = '\u2212';
1627 toggleTagButton.title = 'Remove "' + tag + '" from filter';
1628 } else {
1629 tagElements[j].classList.remove('active-filter-tag');
1630 toggleTagButton.textContent = '+';
1631 toggleTagButton.title = 'Add "' + tag + '" to filter';
1634 bookmarkElements[i].hidden = false;
1635 } else {
1636 bookmarkElements[i].hidden = true;
1640 this.updateBookmarkMessage();
1641 };
1643 BookmarkView.prototype.createTagInputElement = function (tag) {
1644 var newNode;
1646 newNode = document.importNode(this.tagInputTemplate.content, true);
1647 newNode.querySelector('input[name="tag"]').value = tag;
1649 return newNode;
1650 };
1652 BookmarkView.prototype.displayBookmarkEditor = function (bookmark) {
1653 var bookmarkElement;
1654 var newNode;
1655 var formElement;
1656 var faviconImageElement;
1657 var editTagListElement;
1659 bookmarkElement =
1660 this.bookmarkListElement.querySelector('ul#bookmark-list > li' +
1661 createDatasetSelector('bookmark-url', bookmark.url));
1663 // disable edit button while editing
1664 bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled =
1665 true;
1667 // create new editor form from template
1668 newNode = document.importNode(this.bookmarkEditorTemplate.content, true);
1670 // fill with data of given bookmark
1671 formElement = newNode.querySelector('form.bookmark-editor-form');
1672 formElement.querySelector('legend').textContent = 'Edit Bookmark';
1673 formElement['original-url'].value = bookmark.url;
1674 formElement.url.value = bookmark.url;
1675 formElement.title.value = bookmark.title;
1677 faviconImageElement = formElement.querySelector('img.bookmark-favicon');
1678 faviconImageElement.addEventListener('load', this);
1679 faviconImageElement.addEventListener('error', this);
1680 this.missingFaviconUri = faviconImageElement.src;
1681 if (bookmark.favicon) {
1682 faviconImageElement.src = bookmark.favicon;
1685 editTagListElement = formElement.querySelector('ul.tag-input-list');
1686 bookmark.tags.forEach(function (tag) {
1687 editTagListElement.appendChild(this.createTagInputElement(tag));
1688 }, this);
1689 editTagListElement.appendChild(this.createTagInputElement(''));
1691 // insert editor form into bookmark item
1692 bookmarkElement.appendChild(newNode);
1694 // focus first input element
1695 formElement.querySelector('input').focus();
1696 };
1698 BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) {
1699 return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' +
1700 bookmark.url + ']?');
1701 };
1703 BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) {
1704 return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' +
1705 bookmark.url + ']?');
1706 };
1709 /*
1710 * controller
1711 */
1713 var BooketController = function(bookmarkModel, notificationsView,
1714 actionsView, tagView, bookmarkView) {
1715 this.bookmarkModel = bookmarkModel;
1716 this.notificationsView = notificationsView;
1717 this.actionsView = actionsView;
1718 this.tagView = tagView;
1719 this.bookmarkView = bookmarkView;
1721 /* connect the views to the model */
1722 this.bookmarkModel.addObserver('bookmark-added',
1723 this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView));
1724 this.bookmarkModel.addObserver('bookmark-deleted',
1725 this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView));
1726 this.bookmarkModel.addObserver('filter-tags-search-changed',
1727 this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView));
1728 this.bookmarkModel.addObserver('load-file-error',
1729 this.actionsView.onLoadFileError.bind(this.actionsView));
1730 this.bookmarkModel.addObserver('parse-file-error',
1731 this.actionsView.onParseFileError.bind(this.actionsView));
1732 this.bookmarkModel.addObserver('save-file',
1733 this.actionsView.onSaveFile.bind(this.actionsView));
1734 this.bookmarkModel.addObserver('export-file',
1735 this.actionsView.onExportFile.bind(this.actionsView));
1736 this.bookmarkModel.addObserver('unsaved-changes-changed',
1737 this.notificationsView.onUnsavedChangesChanged.bind(this.notificationsView));
1738 this.bookmarkModel.addObserver('tag-added',
1739 this.tagView.onTagAdded.bind(this.tagView));
1740 this.bookmarkModel.addObserver('tag-count-changed',
1741 this.tagView.onTagCountChanged.bind(this.tagView));
1742 this.bookmarkModel.addObserver('tag-deleted',
1743 this.tagView.onTagDeleted.bind(this.tagView));
1744 this.bookmarkModel.addObserver('filter-tags-search-changed',
1745 this.tagView.onFilterTagsSearchChanged.bind(this.tagView));
1746 this.bookmarkModel.addObserver('filter-tags-search-changed',
1747 this.onFilterTagsSearchChanged.bind(this));
1749 /* handle input */
1750 window.addEventListener('hashchange', this.onHashChange.bind(this));
1751 window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
1752 this.actionsView.addObserver('save-file',
1753 this.bookmarkModel.saveFile.bind(this.bookmarkModel));
1754 this.actionsView.addObserver('export-file',
1755 this.bookmarkModel.exportFile.bind(this.bookmarkModel));
1756 this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
1757 this.actionsView.addObserver('import-file', this.onImportFile.bind(this));
1758 this.actionsView.addObserver('save-bookmark',
1759 this.onSaveBookmark.bind(this));
1760 this.bookmarkView.addObserver('edit-bookmark',
1761 this.onEditBookmark.bind(this));
1762 this.bookmarkView.addObserver('save-bookmark',
1763 this.onSaveBookmark.bind(this));
1764 this.bookmarkView.addObserver('delete-bookmark',
1765 this.onDeleteBookmark.bind(this));
1766 this.bookmarkView.addObserver('toggle-tag',
1767 this.onToggleFilterTag.bind(this));
1768 this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this));
1769 this.bookmarkView.addObserver('search', this.onSearch.bind(this));
1770 this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this));
1771 this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this));
1772 };
1774 BooketController.prototype.parseTagsParameter = function (tagsString) {
1775 var tags;
1777 tags = tagsString.split(',').filter(function (tag) {
1778 return (tag !== '') && this.bookmarkModel.hasTag(tag);
1779 }, this).sort();
1781 return new StringSet(tags);
1782 };
1784 BooketController.prototype.onHashChange = function (e) {
1785 var hashData;
1786 var filterTags;
1787 var searchTerm;
1789 hashData = parseHash(window.location.href);
1791 filterTags = hashData.has('tags') ?
1792 this.parseTagsParameter(hashData.get('tags')) : new StringSet();
1794 searchTerm = hashData.has('search') ? hashData.get('search') : '';
1796 this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm);
1797 };
1799 BooketController.prototype.onBeforeUnload = function (e) {
1800 var confirmationMessage = 'There are unsaved changes to your bookmarks.';
1802 if (this.bookmarkModel.unsavedChanges) {
1803 if (e) {
1804 e.returnValue = confirmationMessage;
1806 if (window.event) {
1807 window.event.returnValue = confirmationMessage;
1809 return confirmationMessage;
1811 };
1813 BooketController.prototype.onFilterTagsSearchChanged =
1814 function (filteredBookmarks, newFilterTags, newSearchTerm) {
1815 var url = window.location.href;
1816 var hashData;
1818 // serialize tag filter and search term and update window.location
1819 hashData = parseHash(url);
1820 hashData.set('tags', newFilterTags.values().join(','));
1821 hashData.set('search', newSearchTerm);
1822 history.pushState(null, null, serializeHash(url, hashData));
1823 };
1825 BooketController.prototype.onLoadFile = function (bookmarkFile, merge) {
1826 if (this.bookmarkModel.unsavedChanges) {
1827 if (!this.actionsView.confirmLoadFile()) {
1828 return;
1830 this.bookmarkModel.unsavedChanges = false;
1833 this.bookmarkModel.loadFile(bookmarkFile, merge);
1834 };
1836 BooketController.prototype.onImportFile = function (bookmarkFile, merge) {
1837 if (this.bookmarkModel.unsavedChanges) {
1838 if (!this.actionsView.confirmLoadFile()) {
1839 return;
1841 this.bookmarkModel.unsavedChanges = false;
1844 this.bookmarkModel.importFile(bookmarkFile, merge);
1845 };
1847 BooketController.prototype.onEditBookmark = function (bookmarkUrl) {
1848 this.bookmarkView.displayBookmarkEditor(
1849 this.bookmarkModel.get(bookmarkUrl));
1850 };
1852 BooketController.prototype.onSaveBookmark = function (url, title,
1853 favicon, tags, originalUrl) {
1854 var ctime;
1856 if (originalUrl === undefined) {
1857 // saving new bookmark, get confirmation before replacing existing one
1858 if (this.bookmarkModel.has(url)) {
1859 if (this.bookmarkView.confirmReplaceBookmark(
1860 this.bookmarkModel.get(url))) {
1861 this.bookmarkModel.delete(url);
1862 } else {
1863 return;
1867 ctime = new Date();
1868 } else {
1869 // saving edited bookmark, preserve creation time of any replaced
1870 // bookmark
1871 ctime = (this.bookmarkModel.has(url)) ?
1872 this.bookmarkModel.get(url).ctime : new Date();
1874 this.bookmarkModel.delete(originalUrl);
1876 this.bookmarkModel.add(new Bookmark(url, title, favicon, tags, ctime));
1877 };
1879 BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) {
1880 if (this.bookmarkView.confirmDeleteBookmark(
1881 this.bookmarkModel.get(bookmarkUrl))) {
1882 this.bookmarkModel.delete(bookmarkUrl);
1884 };
1886 BooketController.prototype.onToggleFilterTag = function (tag) {
1887 this.bookmarkModel.toggleFilterTag(tag);
1888 };
1890 BooketController.prototype.onSetTagFilter = function (tag) {
1891 this.bookmarkModel.setFilterTags(new StringSet([tag]));
1892 };
1894 BooketController.prototype.onSearch = function (searchTerm) {
1895 this.bookmarkModel.setSearchTerm(searchTerm);
1896 };
1899 document.addEventListener('DOMContentLoaded', function (e) {
1900 var controller;
1901 var bookmarkModel;
1902 var notificationsView;
1903 var actionsView;
1904 var tagView;
1905 var bookmarkView;
1906 var hashChangeEvent;
1908 bookmarkModel = new BookmarkModel();
1909 notificationsView = new NotificationsView();
1910 tagView = new TagView();
1911 actionsView = new ActionsView();
1912 bookmarkView = new BookmarkView();
1913 controller = new BooketController(bookmarkModel, notificationsView,
1914 actionsView, tagView, bookmarkView);
1916 // initialize state from the current URL
1917 hashChangeEvent = new Event('hashchange');
1918 hashChangeEvent.oldURL = window.location.href;
1919 hashChangeEvent.newURL = window.location.href;
1920 window.dispatchEvent(hashChangeEvent);
1921 });
1922 }());