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);