comparison booket.js @ 0:c2248f662a2c version-1

Initial revision
author Guido Berhoerster <guido+booket@berhoerster.name>
date Sat, 06 Sep 2014 18:18:29 +0200
parents
children 82c50265c8dc
comparison
equal deleted inserted replaced
-1:000000000000 0:c2248f662a2c
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 */
23
24 (function () {
25 'use strict';
26
27 /*
28 * utility stuff
29 */
30
31 function isNumber(number) {
32 return (Object.prototype.toString.call(number) === '[object Number]');
33 }
34
35 function isString(number) {
36 return (Object.prototype.toString.call(number) === '[object String]');
37 }
38
39 function arrayEqual(array1, array2) {
40 if (!Array.isArray(array1)) {
41 throw new TypeError(typeof array1 + ' is not an array');
42 } else if (!Array.isArray(array2)) {
43 throw new TypeError(typeof array2 + ' is not an array');
44 }
45
46 if (array1.length !== array2.length) {
47 return false;
48 } else if (array1.length === 0 && array2.length === 0) {
49 return true;
50 }
51
52 return array1.slice().sort().every(function (value, i) {
53 return value === array2[i];
54 });
55 }
56
57 function parseHash(url) {
58 var hashData;
59 var pos;
60 var hash;
61 var hashParts;
62 var key;
63 var value;
64 var i;
65
66 hashData = new StringMap();
67 pos = url.indexOf('#');
68 hash = (pos > -1) ? url.substr(pos + 1) : '';
69 // hash parts are seperated by a ';'
70 hashParts = hash.split(';');
71 for (i = 0; i < hashParts.length; i++) {
72 // key and value pairs are seperated by a '=', an empty value will
73 // cause the key to be ignored
74 pos = hashParts[i].indexOf('=');
75 if (pos > -1) {
76 key = decodeURIComponent(hashParts[i].substr(0, pos));
77 value = decodeURIComponent(hashParts[i].substr(pos + 1));
78 hashData.set(key, value);
79 }
80 }
81
82 return hashData;
83 }
84
85 function serializeHash(url, hashData) {
86 var hashParts = [];
87 var pos;
88
89 pos = url.indexOf('#');
90 if (pos > -1) {
91 url = url.substr(0, pos);
92 }
93
94 hashData.forEach(function (value, key) {
95 if (value !== '') {
96 hashParts.push(encodeURIComponent(key) + '=' +
97 encodeURIComponent(value));
98 }
99 });
100
101 // only append a '#' if there are any hash parts
102 return url + (hashParts.length > 0 ? '#' + hashParts.join(';') : '');
103 }
104
105 function getAncestorElementDatasetItem(node, item) {
106 while ((node = node.parentNode) !== null) {
107 if (node.dataset && node.dataset[item] !== undefined) {
108 return node.dataset[item];
109 }
110 }
111
112 return undefined;
113 }
114
115 // for use with Node.querySelector() and Node.querySelectorAll()
116 function createDatasetSelector(name, value) {
117 return '[data-' + name + '="' + value.replace(/["\\]/g, '\\$&') + '"]';
118 }
119
120 function extend(targetObject, sourceObject) {
121 var propertyName;
122
123 for (propertyName in sourceObject.prototype) {
124 if (!Object.prototype.hasOwnProperty.call(targetObject.prototype,
125 propertyName)) {
126 targetObject.prototype[propertyName] =
127 sourceObject.prototype[propertyName];
128 }
129 }
130 }
131
132
133 var ObservableMixin = function () {
134 this._eventsObservers = {};
135 };
136
137 ObservableMixin.prototype.addObserver = function (eventName, observer) {
138 var i;
139
140 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
141 eventName)) {
142 this._eventsObservers[eventName] = [];
143 }
144
145 // prevent observers for an event from being called more than once
146 for (i = 0; i < this._eventsObservers[eventName].length; i++) {
147 if (this._eventsObservers[eventName][i] === observer) {
148 return;
149 }
150 }
151 this._eventsObservers[eventName].push(observer);
152 };
153
154 ObservableMixin.prototype.deleteObserver = function (eventName, observer) {
155 var i = 0;
156
157 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
158 eventName)) {
159 return;
160 }
161
162 while (i < this._eventsObservers[eventName].length) {
163 if (this._eventsObservers[eventName][i] === observer) {
164 this._eventsObservers[eventName].splice(i, 1);
165 }
166 }
167 };
168
169 ObservableMixin.prototype.notify = function (eventName) {
170 var origArguments;
171
172 if (!Object.prototype.hasOwnProperty.call(this._eventsObservers,
173 eventName)) {
174 return;
175 }
176
177 origArguments = Array.prototype.slice.call(arguments, 1);
178 this._eventsObservers[eventName].forEach(function (observer, i) {
179 // call the observer function and pass on any additional arguments
180 observer.apply(undefined, origArguments);
181 });
182 };
183
184
185 var StringMap = function (iter) {
186 this._stringMap = Object.create(null);
187
188 if (iter !== undefined) {
189 if (Array.isArray(iter)) {
190 iter.forEach(function (pair) {
191 if (Array.isArray(pair)) {
192 this.set(pair[0], pair[1]);
193 } else {
194 throw new TypeError(typeof pair + ' is not an array');
195 }
196 }, this);
197 } else {
198 throw new TypeError(typeof iter + ' is not iterable');
199 }
200 }
201 };
202
203 Object.defineProperty(StringMap.prototype, 'size', {
204 get: function () {
205 var size = 0;
206 var key;
207
208 for (key in this._stringMap) {
209 if (key.charAt(0) === '@') {
210 size++;
211 }
212 }
213
214 return size;
215 }
216 });
217
218 StringMap.prototype.set = function (key, value) {
219 this._stringMap['@' + key] = value;
220
221 return this;
222 };
223
224 StringMap.prototype.get = function (key) {
225 return this._stringMap['@' + key];
226 };
227
228 StringMap.prototype.has = function (key) {
229 return (('@' + key) in this._stringMap);
230 };
231
232 StringMap.prototype.delete = function (key) {
233 if (this.has(key)) {
234 delete this._stringMap['@' + key];
235
236 return true;
237 }
238
239 return false;
240 };
241
242 StringMap.prototype.forEach = function (callbackFn, thisArg) {
243 Object.keys(this._stringMap).forEach(function (key) {
244 if (key.charAt(0) === '@') {
245 key = key.substr(1);
246 callbackFn.call(thisArg, this.get(key), key, this);
247 }
248 }, this);
249 };
250
251 StringMap.prototype.keys = function () {
252 return Object.keys(this._stringMap).map(function (key) {
253 return key.substr(1);
254 });
255 };
256
257 StringMap.prototype.toJSON = function () {
258 return this._stringMap;
259 };
260
261 StringMap.prototype.toString = function () {
262 return Object.prototype.toString.call(this._stringMap);
263 };
264
265
266 var StringSet = function (iter) {
267 this._stringArray = [];
268 this._stringMap = new StringMap();
269 if (iter !== undefined) {
270 if (Array.isArray(iter) || iter instanceof StringSet) {
271 iter.forEach(function (string) {
272 this.add(string);
273 }, this);
274 } else {
275 throw new TypeError(typeof iter + ' is not iterable');
276 }
277 }
278 };
279
280 Object.defineProperty(StringSet.prototype, 'size', {
281 get: function () {
282 return this._stringArray.length;
283 }
284 });
285
286 StringSet.prototype.has = function (string) {
287 return this._stringMap.has(string);
288 };
289
290 StringSet.prototype.add = function (string) {
291 if (!this.has(string)) {
292 this._stringMap.set(string, true);
293 this._stringArray.push(string);
294 }
295 return this;
296 };
297
298 StringSet.prototype.delete = function (string) {
299 if (this.has(string)) {
300 this._stringMap.delete(string);
301 this._stringArray.splice(this._stringArray.indexOf(string), 1);
302 return true;
303 }
304 return false;
305 };
306
307 StringSet.prototype.forEach = function (callbackFn, thisArg) {
308 this._stringArray.forEach(function (key) {
309 callbackFn.call(thisArg, key, key, this);
310 });
311 };
312
313 StringSet.prototype.keys = function () {
314 return this._stringArray.slice();
315 };
316
317 StringSet.prototype.values = function () {
318 return this._stringArray.slice();
319 };
320
321 StringSet.prototype.clear = function () {
322 this._stringMap = new StringMap();
323 this._stringArray = [];
324 };
325
326 StringSet.prototype.toJSON = function () {
327 return this._stringArray;
328 };
329
330 StringSet.prototype.toString = function () {
331 return this._stringArray.toString();
332 };
333
334
335 /*
336 * model
337 */
338
339 var Bookmark = function (url, title, tags, ctime, mtime) {
340 var parsedTime;
341
342 if (!isString(url)) {
343 throw new TypeError(typeof url + ' is not a string');
344 }
345 this.url = url;
346
347 this.title = (isString(title) && title !== '') ? title : url;
348
349 if (Array.isArray(tags)) {
350 // remove duplicates, non-string or empty tags and tags containing
351 // commas
352 this.tags = new StringSet(tags.filter(function (tag) {
353 return (isString(tag) && tag !== '' && tag.indexOf(',') === -1);
354 }).sort());
355 } else {
356 this.tags = new StringSet();
357 }
358
359 if (isNumber(ctime) || isString(ctime)) {
360 parsedTime = new Date(ctime);
361 this.ctime = !isNaN(parsedTime.getTime()) ? parsedTime : new Date();
362 } else {
363 this.ctime = new Date();
364 }
365
366 if (isNumber(mtime) || isString(mtime)) {
367 parsedTime = new Date(mtime);
368 // modification time must be greater than creation time
369 this.mtime = (!isNaN(parsedTime.getTime()) ||
370 parsedTime >= this.ctime) ? parsedTime : new Date(this.ctime);
371 } else {
372 this.mtime = new Date(this.ctime);
373 }
374 };
375
376
377 var BookmarkModel = function () {
378 ObservableMixin.call(this);
379
380 this.unsavedChanges = false;
381 this._bookmarks = new StringMap();
382 this._tagCount = new StringMap();
383 this._filterTags = new StringSet();
384 this._searchTerm = '';
385 this._filteredBookmarks = new StringSet();
386 this._searchedBookmarks = new StringSet();
387 };
388
389 extend(BookmarkModel, ObservableMixin);
390
391 BookmarkModel.prototype.add = function (bookmarks) {
392 var addedBookmarkUrls = new StringSet();
393
394 // argument can be a single bookmark or a list of bookmarks
395 if (!Array.isArray(bookmarks)) {
396 bookmarks = [bookmarks];
397 }
398
399 bookmarks.forEach(function (bookmark) {
400 // delete any existing bookmark for the given URL before adding the new
401 // one in order to update views
402 this.delete(bookmark.url);
403 this._bookmarks.set(bookmark.url, bookmark);
404 addedBookmarkUrls.add(bookmark.url);
405 this.unsavedChanges = true;
406 this.notify('bookmark-added', bookmark);
407
408 // update tag count
409 bookmark.tags.forEach(function (tag) {
410 var tagCount;
411
412 if (this._tagCount.has(tag)) {
413 tagCount = this._tagCount.get(tag) + 1;
414 this._tagCount.set(tag, tagCount);
415 this.notify('tag-count-changed', tag, tagCount);
416 } else {
417 this._tagCount.set(tag, 1);
418 this.notify('tag-added', tag);
419 }
420 }, this);
421 }, this);
422
423 // apply tag filter and search added bookmarks
424 this.updateFilteredSearchedBookmarks(addedBookmarkUrls);
425 this.notify('filter-tags-search-changed', this._searchedBookmarks,
426 this._filterTags, this._searchTerm);
427 };
428
429 BookmarkModel.prototype.has = function (url) {
430 return this._bookmarks.has(url);
431 };
432
433 BookmarkModel.prototype.get = function (url) {
434 return this._bookmarks.get(url);
435 };
436
437 BookmarkModel.prototype.delete = function (urls) {
438 var needUpdateFilterTags = false;
439
440 // argument can be a single bookmark or a list of bookmarks
441 if (!Array.isArray(urls)) {
442 urls = [urls];
443 }
444
445 urls.forEach(function (url) {
446 var bookmark;
447 var tagCount;
448
449 if (this._bookmarks.has(url)) {
450 bookmark = this._bookmarks.get(url);
451 this._bookmarks.delete(url);
452 this.unsavedChanges = true;
453 this.notify('bookmark-deleted', bookmark.url);
454
455 // update tag count
456 bookmark.tags.forEach(function (tag) {
457 if (this._tagCount.has(tag)) {
458 tagCount = this._tagCount.get(tag);
459 if (tagCount > 1) {
460 tagCount--;
461 this._tagCount.set(tag, tagCount);
462 this.notify('tag-count-changed', tag, tagCount);
463 } else {
464 this._tagCount.delete(tag);
465 this.notify('tag-deleted', tag);
466
467 if (this._filterTags.has(tag)) {
468 this._filterTags.delete(tag);
469 needUpdateFilterTags = true;
470 }
471 }
472 }
473 }, this);
474
475 // update filtered and searched bookmarks
476 if (this._filteredBookmarks.has(url)) {
477 this._filteredBookmarks.delete(url);
478 if (this._searchedBookmarks.has(url)) {
479 this._searchedBookmarks.delete(url);
480 }
481 }
482 }
483 }, this);
484
485 if (needUpdateFilterTags) {
486 this.updateFilteredSearchedBookmarks();
487 this.notify('filter-tags-search-changed', this._searchedBookmarks,
488 this._filterTags, this._searchTerm);
489 }
490 };
491
492 BookmarkModel.prototype.forEach = function (callbackFn, thisArg) {
493 this._bookmarks.keys().forEach(function (key) {
494 callbackFn.call(thisArg, this._bookmarks.get(key), key, this);
495 }, this);
496 };
497
498 BookmarkModel.prototype.hasTag = function (tag) {
499 return this._tagCount.has(tag);
500 };
501
502 BookmarkModel.prototype.getTagCount = function (tag) {
503 return (this._tagCount.has(tag)) ? this._tagCount.get(tag) : undefined;
504 };
505
506 BookmarkModel.prototype.updateSearchedBookmarks = function (urlsSubset) {
507 var searchUrls;
508
509 // additive search if urlsSubset is given
510 if (urlsSubset !== undefined) {
511 searchUrls = urlsSubset;
512 } else {
513 this._searchedBookmarks = new StringSet();
514
515 searchUrls = this._filteredBookmarks.values();
516 }
517
518 // search for the search term in title and URL
519 searchUrls.forEach(function (url) {
520 var bookmark;
521
522 bookmark = this.get(url);
523 if (this._searchTerm === '' ||
524 bookmark.title.indexOf(this._searchTerm) !== -1 ||
525 bookmark.url.indexOf(this._searchTerm) !== -1) {
526 this._searchedBookmarks.add(url);
527 }
528 }, this);
529 };
530
531 BookmarkModel.prototype.updateFilteredSearchedBookmarks =
532 function (urlsSubset) {
533 var filterUrls;
534 var searchUrls;
535
536 // additive filtering if urlsSubset is given
537 if (urlsSubset !== undefined) {
538 filterUrls = urlsSubset;
539 searchUrls = [];
540 } else {
541 this._filteredBookmarks = new StringSet();
542
543 filterUrls = this._bookmarks.keys();
544 searchUrls = undefined;
545 }
546
547 // apply tag filter
548 filterUrls.forEach(function (url) {
549 var bookmark;
550 var matchingTagCount = 0;
551
552 bookmark = this.get(url);
553
554 bookmark.tags.forEach(function (tag) {
555 if (this._filterTags.has(tag)) {
556 matchingTagCount++;
557 }
558 }, this);
559
560 if (matchingTagCount === this._filterTags.size) {
561 this._filteredBookmarks.add(url);
562 if (urlsSubset !== undefined) {
563 searchUrls.push(url);
564 }
565 }
566 }, this);
567
568 // search the filter results
569 this.updateSearchedBookmarks(searchUrls);
570 };
571
572 BookmarkModel.prototype.toggleFilterTag = function (tag) {
573 if (this._filterTags.has(tag)) {
574 this._filterTags.delete(tag);
575 } else {
576 this._filterTags.add(tag);
577 }
578 this.updateFilteredSearchedBookmarks();
579 this.notify('filter-tags-search-changed', this._searchedBookmarks,
580 this._filterTags, this._searchTerm);
581 };
582
583 BookmarkModel.prototype.setFilterTags = function (filterTags) {
584 if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
585 this._filterTags = new StringSet(filterTags);
586 this.updateFilteredSearchedBookmarks();
587 this.notify('filter-tags-search-changed', this._searchedBookmarks,
588 this._filterTags, this._searchTerm);
589 }
590 };
591
592 BookmarkModel.prototype.setSearchTerm = function (searchTerm) {
593 if (searchTerm !== this._searchTerm) {
594 this._searchTerm = searchTerm;
595 this.updateSearchedBookmarks();
596 this.notify('filter-tags-search-changed', this._searchedBookmarks,
597 this._filterTags, this._searchTerm);
598 }
599 };
600
601 BookmarkModel.prototype.setFilterTagsSearchTerm = function (filterTags,
602 searchTerm) {
603 if (!arrayEqual(filterTags.values(), this._filterTags.values())) {
604 this._filterTags = new StringSet(filterTags);
605 this._searchTerm = searchTerm;
606 this.updateFilteredSearchedBookmarks();
607 this.notify('filter-tags-search-changed', this._searchedBookmarks,
608 this._filterTags, this._searchTerm);
609 } else if (searchTerm !== this._searchTerm) {
610 this._searchTerm = searchTerm;
611 this.updateSearchedBookmarks();
612 this.notify('filter-tags-search-changed', this._searchedBookmarks,
613 this._filterTags, this._searchTerm);
614 }
615 };
616
617 BookmarkModel.prototype.parseLoadedBookmarks = function (data) {
618 var parsedData;
619 var bookmarks = [];
620
621 try {
622 parsedData = JSON.parse(data);
623 } catch (e) {
624 this.notify('load-file-error', e.message);
625 return;
626 }
627
628 if (!Array.isArray(parsedData.bookmarks)) {
629 this.notify('parse-file-error',
630 'This file does not contain bookmarks.');
631 return;
632 }
633
634 // create a temporary list of valid bookmarks
635 parsedData.bookmarks.forEach(function (bookmark) {
636 if (isString(bookmark.url) && bookmark.url !== '') {
637 bookmarks.push(new Bookmark(bookmark.url, bookmark.title,
638 bookmark.tags, bookmark.ctime, bookmark.mtime));
639 }
640 }, this);
641
642 // add each bookmark to the model ordered by the last modification time
643 this.add(bookmarks.sort(function (bookmark1, bookmark2) {
644 return bookmark1.ctime - bookmark2.ctime;
645 }));
646 this.unsavedChanges = false;
647 };
648
649 BookmarkModel.prototype.loadFile = function (bookmarkFile) {
650 var bookmarkFileReader;
651
652 // delete all existing bookmarks first
653 this.delete(this._bookmarks.keys());
654 this.unsavedChanges = false;
655
656 bookmarkFileReader = new FileReader();
657 bookmarkFileReader.addEventListener('error', this);
658 bookmarkFileReader.addEventListener('load', this);
659 bookmarkFileReader.readAsText(bookmarkFile);
660 };
661
662 BookmarkModel.prototype.saveFile = function () {
663 var jsonBlob;
664 var bookmarkData = {
665 'bookmarks': []
666 };
667
668 this._bookmarks.forEach(function (bookmark) {
669 bookmarkData.bookmarks.push(bookmark);
670 }, this);
671
672 jsonBlob = new Blob([JSON.stringify(bookmarkData)], {type:
673 'application/json'});
674 this.notify('save-file', jsonBlob);
675 this.unsavedChanges = false;
676 };
677
678 BookmarkModel.prototype.handleEvent = function (e) {
679 if (e.type === 'load') {
680 this.parseLoadedBookmarks(e.target.result);
681 } else if (e.type === 'error') {
682 this.notify('load-file-error', e.target.error.message);
683 }
684 };
685
686
687 /*
688 * view
689 */
690
691 var TagView = function () {
692 ObservableMixin.call(this);
693
694 this.tagListElement = document.querySelector('#tags ul.tag-list');
695 this.tagListElement.addEventListener('click', this);
696
697 this.tagTemplate = document.querySelector('#tag-template');
698 };
699
700 extend(TagView, ObservableMixin);
701
702 TagView.prototype.onTagAdded = function (tag) {
703 var newNode;
704 var tagElement;
705 var setTagButton;
706 var toggleTagButton;
707 var tagElements;
708 var i;
709 var referenceTag = '';
710 var referenceNode;
711
712 // create new tag element from template
713 newNode = document.importNode(this.tagTemplate.content, true);
714
715 tagElement = newNode.querySelector('li');
716 tagElement.dataset.tag = tag;
717
718 setTagButton = tagElement.querySelector('button[name="set-tag"]');
719 setTagButton.textContent = tag;
720 setTagButton.title = 'Set filter to "' + tag + '"';
721
722 toggleTagButton = tagElement.querySelector('button[name="toggle-tag"]');
723 toggleTagButton.textContent = '+';
724 toggleTagButton.title = 'Add "' + tag + '" to filter';
725
726 // maintain alphabetical order when inserting the tag element
727 tagElements = this.tagListElement.querySelectorAll('li');
728 for (i = 0; i < tagElements.length; i ++) {
729 if (tagElements[i].dataset.tag > referenceTag &&
730 tagElements[i].dataset.tag < tag) {
731 referenceTag = tagElements[i].dataset.tag;
732 referenceNode = tagElements[i];
733 }
734 }
735 this.tagListElement.insertBefore(newNode, (referenceNode !== undefined) ?
736 referenceNode.nextSibling : this.tagListElement.firstChild);
737
738 // initialize tag count
739 this.onTagCountChanged(tag, 1);
740 };
741
742 TagView.prototype.onTagCountChanged = function (tag, tagCount) {
743 this.tagListElement.querySelector('li' + createDatasetSelector('tag', tag) +
744 ' .tag-count').textContent = '(' + tagCount + ')';
745 };
746
747 TagView.prototype.onTagDeleted = function (tag) {
748 var tagElement;
749
750 tagElement = this.tagListElement.querySelector('li' +
751 createDatasetSelector('tag', tag));
752 if (tagElement !== null) {
753 tagElement.parentNode.removeChild(tagElement);
754 }
755 };
756
757 TagView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
758 newFilterTags, newSearchTerm) {
759 var tagElements;
760 var i;
761 var tag;
762 var toggleTagButton;
763
764 tagElements = this.tagListElement.querySelectorAll('li');
765 for (i = 0; i < tagElements.length; i++) {
766 tag = tagElements[i].dataset.tag;
767 toggleTagButton =
768 tagElements[i].querySelector('button[name="toggle-tag"]');
769 if (newFilterTags.has(tag)) {
770 tagElements[i].classList.add('active-filter-tag');
771 toggleTagButton.textContent = '\u2212';
772 toggleTagButton.title = 'Remove "' + tag + '" from filter';
773 } else {
774 tagElements[i].classList.remove('active-filter-tag');
775 toggleTagButton.textContent = '+';
776 toggleTagButton.title = 'Add "' + tag + '" to filter';
777 }
778 }
779 };
780
781 TagView.prototype.handleEvent = function (e) {
782 if (e.type === 'click' && (e.target.name === 'set-tag' ||
783 e.target.name === 'toggle-tag')) {
784 e.target.blur();
785
786 this.notify(e.target.name, getAncestorElementDatasetItem(e.target,
787 'tag'));
788 }
789 };
790
791
792 var ActionsView = function () {
793 var saveFormElement;
794 var loadFormElement;
795 var newNode;
796 var editorFormElement;
797
798 ObservableMixin.call(this);
799
800 this.tagInputTemplate = document.querySelector('#tag-input-template');
801 saveFormElement = document.querySelector('form#save-form');
802 saveFormElement.addEventListener('submit', this);
803
804 this.saveLinkElement = saveFormElement.querySelector('a#save-link');
805
806 loadFormElement = document.querySelector('form#load-form');
807 loadFormElement.addEventListener('submit', this);
808
809 // create new editor form from template
810 newNode = document.importNode(
811 document.querySelector('#bookmark-editor-template').content, true);
812
813 editorFormElement = newNode.querySelector('form.bookmark-editor-form');
814 editorFormElement.querySelector('legend').textContent = 'Add Bookmark';
815 editorFormElement.querySelector('input:not([type="hidden"])').accessKey =
816 'a';
817 editorFormElement.addEventListener('click', this);
818 editorFormElement.addEventListener('submit', this);
819 editorFormElement.addEventListener('reset', this);
820
821 this.editTagListElement =
822 editorFormElement.querySelector('ul.tag-input-list');
823 this.editTagListElement.appendChild(this.createTagInputElement(''));
824
825 saveFormElement.parentNode.insertBefore(newNode,
826 saveFormElement.nextSibling);
827 };
828
829 extend(ActionsView, ObservableMixin);
830
831 ActionsView.prototype.createTagInputElement = function (tag) {
832 var newNode;
833
834 newNode = document.importNode(this.tagInputTemplate.content, true);
835 newNode.querySelector('input[name="tag"]').value = tag;
836
837 return newNode;
838 };
839
840 ActionsView.prototype.handleEvent = function (e) {
841 var tags = [];
842 var i;
843
844 switch (e.type) {
845 case 'click':
846 if (e.target.name === 'more-tags') {
847 e.preventDefault();
848 e.target.blur();
849
850 this.editTagListElement.appendChild(this.createTagInputElement(''));
851 }
852 break;
853 case 'submit':
854 if (e.target.id === 'save-form') {
855 e.preventDefault();
856 e.target.blur();
857
858 this.notify('save-file');
859 } else if (e.target.id === 'load-form') {
860 e.preventDefault();
861 e.target.blur();
862
863 this.notify('load-file', e.target.file.files[0]);
864 e.target.reset();
865 } else if (e.target.classList.contains('bookmark-editor-form')) {
866 e.preventDefault();
867 e.target.blur();
868
869 if (e.target.tag.length) {
870 for (i = 0; i < e.target.tag.length; i++) {
871 tags.push(e.target.tag[i].value.trim());
872 }
873 } else {
874 tags.push(e.target.tag.value.trim());
875 }
876
877 this.notify('save-bookmark', e.target.url.value,
878 e.target.title.value, tags);
879
880 e.target.reset();
881 }
882 break;
883 case 'reset':
884 if (e.target.classList.contains('bookmark-editor-form')) {
885 e.target.blur();
886
887 // remove all but one tag input element
888 while (this.editTagListElement.firstChild !== null) {
889 this.editTagListElement.removeChild(
890 this.editTagListElement.firstChild);
891 }
892 this.editTagListElement.appendChild(this.createTagInputElement(''));
893 }
894 break;
895 }
896 };
897
898 ActionsView.prototype.onSaveFile = function (jsonBlob) {
899 this.saveLinkElement.href = URL.createObjectURL(jsonBlob);
900 this.saveLinkElement.click();
901 };
902
903 ActionsView.prototype.confirmLoadFile = function () {
904 return window.confirm('There are unsaved changes to your bookmarks.\n' +
905 'Proceed loading the bookmark file?');
906 };
907
908 ActionsView.prototype.onLoadFileError = function (message) {
909 window.alert('Failed to load bookmark file:\n' + message);
910 };
911
912 ActionsView.prototype.onParseFileError = function (message) {
913 window.alert('Failed to parse bookmark file:\n' + message);
914 };
915
916
917 var BookmarkView = function () {
918 var searchFormElement;
919
920 ObservableMixin.call(this);
921
922 this.bookmarkTemplate = document.querySelector('#bookmark-template');
923 this.bookmarkTagTemplate = document.querySelector('#bookmark-tag-template');
924 this.bookmarkEditorTemplate =
925 document.querySelector('#bookmark-editor-template');
926 this.tagInputTemplate = document.querySelector('#tag-input-template');
927
928 this.bookmarkListElement = document.querySelector('ul#bookmark-list');
929 this.bookmarkListElement.addEventListener('click', this);
930 this.bookmarkListElement.addEventListener('submit', this);
931 this.bookmarkListElement.addEventListener('reset', this);
932
933 searchFormElement = document.querySelector('#search-form');
934 searchFormElement.addEventListener('submit', this);
935 searchFormElement.addEventListener('reset', this);
936
937 this.searchTermInputElement = searchFormElement['search-term'];
938
939 this.bookmarkMessageElement = document.querySelector('#bookmark-message');
940
941 this.updateBookmarkMessage();
942 };
943
944 extend(BookmarkView, ObservableMixin);
945
946 BookmarkView.prototype.handleEvent = function (e) {
947 var i;
948 var tags = [];
949 var node;
950
951 switch (e.type) {
952 case 'click':
953 switch (e.target.name) {
954 case 'edit-bookmark':
955 e.target.blur();
956 // fallthrough
957 case 'delete-bookmark':
958 this.notify(e.target.name,
959 getAncestorElementDatasetItem(e.target, 'bookmarkUrl'));
960 break;
961 case 'more-tags':
962 e.target.blur();
963
964 e.target.form.querySelector('ul.tag-input-list').appendChild(
965 this.createTagInputElement(''));
966 break;
967 case 'set-tag':
968 case 'toggle-tag':
969 e.target.blur();
970
971 this.notify(e.target.name,
972 getAncestorElementDatasetItem(e.target, 'tag'));
973 break;
974 }
975 break;
976 case 'submit':
977 if (e.target.classList.contains('bookmark-editor-form')) {
978 // save bookmark-editor-form form contents
979 e.preventDefault();
980
981 if (e.target.tag.length) {
982 for (i = 0; i < e.target.tag.length; i++) {
983 tags.push(e.target.tag[i].value.trim());
984 }
985 } else {
986 tags.push(e.target.tag.value.trim());
987 }
988
989 this.notify('save-bookmark', e.target.url.value,
990 e.target.title.value, tags, e.target['original-url'].value);
991 } else if (e.target.id === 'search-form') {
992 // search
993 e.preventDefault();
994 e.target.blur();
995
996 this.notify('search', e.target['search-term'].value);
997 }
998 break;
999 case 'reset':
1000 if (e.target.classList.contains('bookmark-editor-form')) {
1001 // cancel bookmark-editor-form form
1002 e.preventDefault();
1003
1004 // re-enable edit button
1005 this.bookmarkListElement.querySelector('li' +
1006 createDatasetSelector('bookmark-url',
1007 e.target['original-url'].value) +
1008 ' button[name="edit-bookmark"]').disabled = false;
1009
1010 e.target.parentNode.removeChild(e.target);
1011 } else if (e.target.id === 'search-form') {
1012 // clear search
1013 e.preventDefault();
1014 e.target.blur();
1015
1016 this.notify('search', '');
1017 }
1018 break;
1019 }
1020 };
1021
1022 BookmarkView.prototype.updateBookmarkMessage = function () {
1023 this.bookmarkMessageElement.textContent = 'Showing ' +
1024 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
1025 'li:not([hidden])').length + ' of ' +
1026 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > ' +
1027 'li').length + ' bookmarks.';
1028 };
1029
1030 BookmarkView.prototype.onBookmarkAdded = function (bookmark) {
1031 var newNode;
1032 var bookmarkElement;
1033 var linkElement;
1034 var tagListElement;
1035
1036 newNode = document.importNode(this.bookmarkTemplate.content, true);
1037
1038 bookmarkElement = newNode.querySelector('li');
1039 bookmarkElement.dataset.bookmarkUrl = bookmark.url;
1040
1041 linkElement = bookmarkElement.querySelector('a.bookmark-link');
1042 linkElement.textContent = linkElement.title = bookmark.title;
1043 linkElement.href = bookmark.url;
1044
1045 tagListElement = bookmarkElement.querySelector('ul.tag-list');
1046 bookmark.tags.forEach(function (tag) {
1047 var newNode;
1048 var tagElement;
1049 var setTagButton;
1050 var toggleTagButton;
1051
1052 newNode = document.importNode(this.bookmarkTagTemplate.content, true);
1053
1054 tagElement = newNode.querySelector('li');
1055 tagElement.dataset.tag = tag;
1056
1057 setTagButton = newNode.querySelector('button[name="set-tag"]');
1058 setTagButton.textContent = tag;
1059 setTagButton.title = 'Set filter to "' + tag + '"';
1060
1061 toggleTagButton = newNode.querySelector('button[name="toggle-tag"]');
1062 toggleTagButton.textContent = '+';
1063 toggleTagButton.title = 'Add "' + tag + '" to filter';
1064
1065 tagListElement.appendChild(newNode);
1066 }, this);
1067
1068 // insert new or last modified bookmark on top of the list
1069 this.bookmarkListElement.insertBefore(newNode,
1070 this.bookmarkListElement.firstChild);
1071
1072 this.updateBookmarkMessage();
1073 };
1074
1075 BookmarkView.prototype.onBookmarkDeleted = function (bookmarkUrl) {
1076 var bookmarkElement;
1077
1078 bookmarkElement = this.bookmarkListElement.querySelector('li' +
1079 createDatasetSelector('bookmark-url', bookmarkUrl));
1080 if (bookmarkElement !== null) {
1081 this.bookmarkListElement.removeChild(bookmarkElement);
1082
1083 this.updateBookmarkMessage();
1084 }
1085 };
1086
1087 BookmarkView.prototype.onFilterTagsSearchChanged = function (filteredBookmarks,
1088 newFilterTags, newSearchTerm) {
1089 var bookmarkElements;
1090 var i;
1091 var tagElements;
1092 var toggleTagButton;
1093 var j;
1094 var tag;
1095
1096 this.searchTermInputElement.value = newSearchTerm;
1097
1098 bookmarkElements =
1099 this.bookmarkListElement.querySelectorAll('ul#bookmark-list > li');
1100 for (i = 0; i < bookmarkElements.length; i++) {
1101 // update visibility of bookmarks
1102 if (filteredBookmarks.has(bookmarkElements[i].dataset.bookmarkUrl)) {
1103 // update tag elements of visible bookmarks
1104 tagElements =
1105 bookmarkElements[i].querySelectorAll('ul.tag-list > li');
1106 for (j = 0; j < tagElements.length; j++) {
1107 tag = tagElements[j].dataset.tag;
1108 toggleTagButton =
1109 tagElements[j].querySelector('button[name="toggle-tag"]');
1110 if (newFilterTags.has(tag)) {
1111 tagElements[j].classList.add('active-filter-tag');
1112 toggleTagButton.textContent = '\u2212';
1113 toggleTagButton.title = 'Remove "' + tag + '" from filter';
1114 } else {
1115 tagElements[j].classList.remove('active-filter-tag');
1116 toggleTagButton.textContent = '+';
1117 toggleTagButton.title = 'Add "' + tag + '" to filter';
1118 }
1119 }
1120 bookmarkElements[i].hidden = false;
1121 } else {
1122 bookmarkElements[i].hidden = true;
1123 }
1124 }
1125
1126 this.updateBookmarkMessage();
1127 };
1128
1129 BookmarkView.prototype.createTagInputElement = function (tag) {
1130 var newNode;
1131
1132 newNode = document.importNode(this.tagInputTemplate.content, true);
1133 newNode.querySelector('input[name="tag"]').value = tag;
1134
1135 return newNode;
1136 };
1137
1138 BookmarkView.prototype.displayBookmarkEditor = function (bookmark) {
1139 var bookmarkElement;
1140 var newNode;
1141 var formElement;
1142 var editTagListElement;
1143
1144 bookmarkElement =
1145 this.bookmarkListElement.querySelector('ul#bookmark-list > li' +
1146 createDatasetSelector('bookmark-url', bookmark.url));
1147
1148 // disable edit button while editing
1149 bookmarkElement.querySelector('button[name="edit-bookmark"]').disabled =
1150 true;
1151
1152 // create new editor form from template
1153 newNode = document.importNode(this.bookmarkEditorTemplate.content, true);
1154
1155 // fill with data of given bookmark
1156 formElement = newNode.querySelector('form.bookmark-editor-form');
1157 formElement.querySelector('legend').textContent = 'Edit Bookmark';
1158 formElement['original-url'].value = bookmark.url;
1159 formElement.url.value = bookmark.url;
1160 formElement.title.value = bookmark.title;
1161
1162 editTagListElement = formElement.querySelector('ul.tag-input-list');
1163 bookmark.tags.forEach(function (tag) {
1164 editTagListElement.appendChild(this.createTagInputElement(tag));
1165 }, this);
1166 editTagListElement.appendChild(this.createTagInputElement(''));
1167
1168 // insert editor form into bookmark item
1169 bookmarkElement.appendChild(newNode);
1170
1171 // focus first input element
1172 formElement.querySelector('input').focus();
1173 };
1174
1175 BookmarkView.prototype.confirmReplaceBookmark = function (bookmark) {
1176 return window.confirm('Replace bookmark "' + bookmark.title + '"\n[' +
1177 bookmark.url + ']?');
1178 };
1179
1180 BookmarkView.prototype.confirmDeleteBookmark = function (bookmark) {
1181 return window.confirm('Delete bookmark "' + bookmark.title + '"\n[' +
1182 bookmark.url + ']?');
1183 };
1184
1185
1186 /*
1187 * controller
1188 */
1189
1190 var BooketController = function(bookmarkModel, actionsView, tagView,
1191 bookmarkView) {
1192 this.bookmarkModel = bookmarkModel;
1193 this.actionsView = actionsView;
1194 this.tagView = tagView;
1195 this.bookmarkView = bookmarkView;
1196
1197 /* connect the views to the model */
1198 this.bookmarkModel.addObserver('bookmark-added',
1199 this.bookmarkView.onBookmarkAdded.bind(this.bookmarkView));
1200 this.bookmarkModel.addObserver('bookmark-deleted',
1201 this.bookmarkView.onBookmarkDeleted.bind(this.bookmarkView));
1202 this.bookmarkModel.addObserver('filter-tags-search-changed',
1203 this.bookmarkView.onFilterTagsSearchChanged.bind(this.bookmarkView));
1204 this.bookmarkModel.addObserver('load-file-error',
1205 this.actionsView.onLoadFileError.bind(this.actionsView));
1206 this.bookmarkModel.addObserver('parse-file-error',
1207 this.actionsView.onParseFileError.bind(this.actionsView));
1208 this.bookmarkModel.addObserver('save-file',
1209 this.actionsView.onSaveFile.bind(this.actionsView));
1210 this.bookmarkModel.addObserver('tag-added',
1211 this.tagView.onTagAdded.bind(this.tagView));
1212 this.bookmarkModel.addObserver('tag-count-changed',
1213 this.tagView.onTagCountChanged.bind(this.tagView));
1214 this.bookmarkModel.addObserver('tag-deleted',
1215 this.tagView.onTagDeleted.bind(this.tagView));
1216 this.bookmarkModel.addObserver('filter-tags-search-changed',
1217 this.tagView.onFilterTagsSearchChanged.bind(this.tagView));
1218 this.bookmarkModel.addObserver('filter-tags-search-changed',
1219 this.onFilterTagsSearchChanged.bind(this));
1220
1221 /* handle input */
1222 window.addEventListener('hashchange', this.onHashChange.bind(this));
1223 window.addEventListener('beforeunload', this.onBeforeUnload.bind(this));
1224 this.actionsView.addObserver('save-file',
1225 this.bookmarkModel.saveFile.bind(this.bookmarkModel));
1226 this.actionsView.addObserver('load-file', this.onLoadFile.bind(this));
1227 this.actionsView.addObserver('save-bookmark',
1228 this.onSaveBookmark.bind(this));
1229 this.bookmarkView.addObserver('edit-bookmark',
1230 this.onEditBookmark.bind(this));
1231 this.bookmarkView.addObserver('save-bookmark',
1232 this.onSaveBookmark.bind(this));
1233 this.bookmarkView.addObserver('delete-bookmark',
1234 this.onDeleteBookmark.bind(this));
1235 this.bookmarkView.addObserver('toggle-tag',
1236 this.onToggleFilterTag.bind(this));
1237 this.bookmarkView.addObserver('set-tag', this.onSetTagFilter.bind(this));
1238 this.bookmarkView.addObserver('search', this.onSearch.bind(this));
1239 this.tagView.addObserver('toggle-tag', this.onToggleFilterTag.bind(this));
1240 this.tagView.addObserver('set-tag', this.onSetTagFilter.bind(this));
1241 };
1242
1243 BooketController.prototype.parseTagsParameter = function (tagsString) {
1244 var tags;
1245
1246 tags = tagsString.split(',').filter(function (tag) {
1247 return (tag !== '') && this.bookmarkModel.hasTag(tag);
1248 }, this).sort();
1249
1250 return new StringSet(tags);
1251 };
1252
1253 BooketController.prototype.onHashChange = function (e) {
1254 var hashData;
1255 var filterTags;
1256 var searchTerm;
1257
1258 hashData = parseHash(window.location.href);
1259
1260 filterTags = hashData.has('tags') ?
1261 this.parseTagsParameter(hashData.get('tags')) : new StringSet();
1262
1263 searchTerm = hashData.has('search') ? hashData.get('search') : '';
1264
1265 this.bookmarkModel.setFilterTagsSearchTerm(filterTags, searchTerm);
1266 };
1267
1268 BooketController.prototype.onBeforeUnload = function (e) {
1269 var confirmationMessage = 'There are unsaved changes to your bookmarks.';
1270
1271 if (this.bookmarkModel.unsavedChanges) {
1272 if (e) {
1273 e.returnValue = confirmationMessage;
1274 }
1275 if (window.event) {
1276 window.event.returnValue = confirmationMessage;
1277 }
1278 return confirmationMessage;
1279 }
1280 };
1281
1282 BooketController.prototype.onFilterTagsSearchChanged =
1283 function (filteredBookmarks, newFilterTags, newSearchTerm) {
1284 var url = window.location.href;
1285 var hashData;
1286
1287 // serialize tag filter and search term and update window.location
1288 hashData = parseHash(url);
1289 hashData.set('tags', newFilterTags.values().join(','));
1290 hashData.set('search', newSearchTerm);
1291 history.pushState(null, null, serializeHash(url, hashData));
1292 };
1293
1294 BooketController.prototype.onLoadFile = function (bookmarkFile) {
1295 if (this.bookmarkModel.unsavedChanges) {
1296 if (!this.actionsView.confirmLoadFile()) {
1297 return;
1298 }
1299 this.bookmarkModel.unsavedChanges = false;
1300 }
1301
1302 this.bookmarkModel.loadFile(bookmarkFile);
1303 };
1304
1305 BooketController.prototype.onEditBookmark = function (bookmarkUrl) {
1306 this.bookmarkView.displayBookmarkEditor(
1307 this.bookmarkModel.get(bookmarkUrl));
1308 };
1309
1310 BooketController.prototype.onSaveBookmark = function (url, title, tags,
1311 originalUrl) {
1312 var ctime;
1313
1314 if (originalUrl === undefined) {
1315 // saving new bookmark, get confirmation before replacing existing one
1316 if (this.bookmarkModel.has(url)) {
1317 if (this.bookmarkView.confirmReplaceBookmark(
1318 this.bookmarkModel.get(url))) {
1319 this.bookmarkModel.delete(url);
1320 } else {
1321 return;
1322 }
1323 }
1324
1325 ctime = new Date();
1326 } else {
1327 // saving edited bookmark, preserve creation time of any replaced
1328 // bookmark
1329 ctime = (this.bookmarkModel.has(url)) ?
1330 this.bookmarkModel.get(url).ctime : new Date();
1331
1332 this.bookmarkModel.delete(originalUrl);
1333 }
1334 this.bookmarkModel.add(new Bookmark(url, title, tags, ctime));
1335 };
1336
1337 BooketController.prototype.onDeleteBookmark = function (bookmarkUrl) {
1338 if (this.bookmarkView.confirmDeleteBookmark(
1339 this.bookmarkModel.get(bookmarkUrl))) {
1340 this.bookmarkModel.delete(bookmarkUrl);
1341 }
1342 };
1343
1344 BooketController.prototype.onToggleFilterTag = function (tag) {
1345 this.bookmarkModel.toggleFilterTag(tag);
1346 };
1347
1348 BooketController.prototype.onSetTagFilter = function (tag) {
1349 this.bookmarkModel.setFilterTags(new StringSet([tag]));
1350 };
1351
1352 BooketController.prototype.onSearch = function (searchTerm) {
1353 this.bookmarkModel.setSearchTerm(searchTerm);
1354 };
1355
1356
1357 document.addEventListener('DOMContentLoaded', function (e) {
1358 var controller;
1359 var bookmarkModel;
1360 var actionsView;
1361 var tagView;
1362 var bookmarkView;
1363 var hashChangeEvent;
1364
1365 bookmarkModel = new BookmarkModel();
1366 tagView = new TagView();
1367 actionsView = new ActionsView();
1368 bookmarkView = new BookmarkView();
1369 controller = new BooketController(bookmarkModel, actionsView,
1370 tagView, bookmarkView);
1371
1372 // initialize state from the current URL
1373 hashChangeEvent = new Event('hashchange');
1374 hashChangeEvent.oldURL = window.location.href;
1375 hashChangeEvent.newURL = window.location.href;
1376 window.dispatchEvent(hashChangeEvent);
1377 });
1378 }());
1379