toolkit/components/places/nsTaggingService.js

branch
TOR_BUG_3246
changeset 7
129ffea94266
equal deleted inserted replaced
-1:000000000000 0:52bc62ea262a
1 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*-
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6 const Cc = Components.classes;
7 const Ci = Components.interfaces;
8 const Cr = Components.results;
9
10 Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
11 Components.utils.import("resource://gre/modules/Services.jsm");
12 Components.utils.import("resource://gre/modules/PlacesUtils.jsm");
13
14 const TOPIC_SHUTDOWN = "places-shutdown";
15
16 /**
17 * The Places Tagging Service
18 */
19 function TaggingService() {
20 // Observe bookmarks changes.
21 PlacesUtils.bookmarks.addObserver(this, false);
22
23 // Cleanup on shutdown.
24 Services.obs.addObserver(this, TOPIC_SHUTDOWN, false);
25 }
26
27 TaggingService.prototype = {
28 /**
29 * Creates a tag container under the tags-root with the given name.
30 *
31 * @param aTagName
32 * the name for the new tag.
33 * @returns the id of the new tag container.
34 */
35 _createTag: function TS__createTag(aTagName) {
36 var newFolderId = PlacesUtils.bookmarks.createFolder(
37 PlacesUtils.tagsFolderId, aTagName, PlacesUtils.bookmarks.DEFAULT_INDEX
38 );
39 // Add the folder to our local cache, so we can avoid doing this in the
40 // observer that would have to check itemType.
41 this._tagFolders[newFolderId] = aTagName;
42
43 return newFolderId;
44 },
45
46 /**
47 * Checks whether the given uri is tagged with the given tag.
48 *
49 * @param [in] aURI
50 * url to check for
51 * @param [in] aTagName
52 * the tag to check for
53 * @returns the item id if the URI is tagged with the given tag, -1
54 * otherwise.
55 */
56 _getItemIdForTaggedURI: function TS__getItemIdForTaggedURI(aURI, aTagName) {
57 var tagId = this._getItemIdForTag(aTagName);
58 if (tagId == -1)
59 return -1;
60 // Using bookmarks service API for this would be a pain.
61 // Until tags implementation becomes sane, go the query way.
62 let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
63 .DBConnection;
64 let stmt = db.createStatement(
65 "SELECT id FROM moz_bookmarks "
66 + "WHERE parent = :tag_id "
67 + "AND fk = (SELECT id FROM moz_places WHERE url = :page_url)"
68 );
69 stmt.params.tag_id = tagId;
70 stmt.params.page_url = aURI.spec;
71 try {
72 if (stmt.executeStep()) {
73 return stmt.row.id;
74 }
75 }
76 finally {
77 stmt.finalize();
78 }
79 return -1;
80 },
81
82 /**
83 * Returns the folder id for a tag, or -1 if not found.
84 * @param [in] aTag
85 * string tag to search for
86 * @returns integer id for the bookmark folder for the tag
87 */
88 _getItemIdForTag: function TS_getItemIdForTag(aTagName) {
89 for (var i in this._tagFolders) {
90 if (aTagName.toLowerCase() == this._tagFolders[i].toLowerCase())
91 return parseInt(i);
92 }
93 return -1;
94 },
95
96 /**
97 * Makes a proper array of tag objects like { id: number, name: string }.
98 *
99 * @param aTags
100 * Array of tags. Entries can be tag names or concrete item id.
101 * @return Array of tag objects like { id: number, name: string }.
102 *
103 * @throws Cr.NS_ERROR_INVALID_ARG if any element of the input array is not
104 * a valid tag.
105 */
106 _convertInputMixedTagsArray: function TS__convertInputMixedTagsArray(aTags)
107 {
108 return aTags.map(function (val)
109 {
110 let tag = { _self: this };
111 if (typeof(val) == "number" && this._tagFolders[val]) {
112 // This is a tag folder id.
113 tag.id = val;
114 // We can't know the name at this point, since a previous tag could
115 // want to change it.
116 tag.__defineGetter__("name", function () this._self._tagFolders[this.id]);
117 }
118 else if (typeof(val) == "string" && val.length > 0) {
119 // This is a tag name.
120 tag.name = val;
121 // We can't know the id at this point, since a previous tag could
122 // have created it.
123 tag.__defineGetter__("id", function () this._self._getItemIdForTag(this.name));
124 }
125 else {
126 throw Cr.NS_ERROR_INVALID_ARG;
127 }
128 return tag;
129 }, this);
130 },
131
132 // nsITaggingService
133 tagURI: function TS_tagURI(aURI, aTags)
134 {
135 if (!aURI || !aTags || !Array.isArray(aTags)) {
136 throw Cr.NS_ERROR_INVALID_ARG;
137 }
138
139 // This also does some input validation.
140 let tags = this._convertInputMixedTagsArray(aTags);
141
142 let taggingService = this;
143 PlacesUtils.bookmarks.runInBatchMode({
144 runBatched: function (aUserData)
145 {
146 tags.forEach(function (tag)
147 {
148 if (tag.id == -1) {
149 // Tag does not exist yet, create it.
150 this._createTag(tag.name);
151 }
152
153 if (this._getItemIdForTaggedURI(aURI, tag.name) == -1) {
154 // The provided URI is not yet tagged, add a tag for it.
155 // Note that bookmarks under tag containers must have null titles.
156 PlacesUtils.bookmarks.insertBookmark(
157 tag.id, aURI, PlacesUtils.bookmarks.DEFAULT_INDEX, null
158 );
159 }
160
161 // Try to preserve user's tag name casing.
162 // Rename the tag container so the Places view matches the most-recent
163 // user-typed value.
164 if (PlacesUtils.bookmarks.getItemTitle(tag.id) != tag.name) {
165 // this._tagFolders is updated by the bookmarks observer.
166 PlacesUtils.bookmarks.setItemTitle(tag.id, tag.name);
167 }
168 }, taggingService);
169 }
170 }, null);
171 },
172
173 /**
174 * Removes the tag container from the tags root if the given tag is empty.
175 *
176 * @param aTagId
177 * the itemId of the tag element under the tags root
178 */
179 _removeTagIfEmpty: function TS__removeTagIfEmpty(aTagId) {
180 let count = 0;
181 let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
182 .DBConnection;
183 let stmt = db.createStatement(
184 "SELECT count(*) AS count FROM moz_bookmarks "
185 + "WHERE parent = :tag_id"
186 );
187 stmt.params.tag_id = aTagId;
188 try {
189 if (stmt.executeStep()) {
190 count = stmt.row.count;
191 }
192 }
193 finally {
194 stmt.finalize();
195 }
196
197 if (count == 0) {
198 PlacesUtils.bookmarks.removeItem(aTagId);
199 }
200 },
201
202 // nsITaggingService
203 untagURI: function TS_untagURI(aURI, aTags)
204 {
205 if (!aURI || (aTags && !Array.isArray(aTags))) {
206 throw Cr.NS_ERROR_INVALID_ARG;
207 }
208
209 if (!aTags) {
210 // Passing null should clear all tags for aURI, see the IDL.
211 // XXXmano: write a perf-sensitive version of this code path...
212 aTags = this.getTagsForURI(aURI);
213 }
214
215 // This also does some input validation.
216 let tags = this._convertInputMixedTagsArray(aTags);
217
218 let taggingService = this;
219 PlacesUtils.bookmarks.runInBatchMode({
220 runBatched: function (aUserData)
221 {
222 tags.forEach(function (tag)
223 {
224 if (tag.id != -1) {
225 // A tag could exist.
226 let itemId = this._getItemIdForTaggedURI(aURI, tag.name);
227 if (itemId != -1) {
228 // There is a tagged item.
229 PlacesUtils.bookmarks.removeItem(itemId);
230 }
231 }
232 }, taggingService);
233 }
234 }, null);
235 },
236
237 // nsITaggingService
238 getURIsForTag: function TS_getURIsForTag(aTagName) {
239 if (!aTagName || aTagName.length == 0)
240 throw Cr.NS_ERROR_INVALID_ARG;
241
242 let uris = [];
243 let tagId = this._getItemIdForTag(aTagName);
244 if (tagId == -1)
245 return uris;
246
247 let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
248 .DBConnection;
249 let stmt = db.createStatement(
250 "SELECT h.url FROM moz_places h "
251 + "JOIN moz_bookmarks b ON b.fk = h.id "
252 + "WHERE b.parent = :tag_id "
253 );
254 stmt.params.tag_id = tagId;
255 try {
256 while (stmt.executeStep()) {
257 try {
258 uris.push(Services.io.newURI(stmt.row.url, null, null));
259 } catch (ex) {}
260 }
261 }
262 finally {
263 stmt.finalize();
264 }
265
266 return uris;
267 },
268
269 // nsITaggingService
270 getTagsForURI: function TS_getTagsForURI(aURI, aCount) {
271 if (!aURI)
272 throw Cr.NS_ERROR_INVALID_ARG;
273
274 var tags = [];
275 var bookmarkIds = PlacesUtils.bookmarks.getBookmarkIdsForURI(aURI);
276 for (var i=0; i < bookmarkIds.length; i++) {
277 var folderId = PlacesUtils.bookmarks.getFolderIdForItem(bookmarkIds[i]);
278 if (this._tagFolders[folderId])
279 tags.push(this._tagFolders[folderId]);
280 }
281
282 // sort the tag list
283 tags.sort(function(a, b) {
284 return a.toLowerCase().localeCompare(b.toLowerCase());
285 });
286 if (aCount)
287 aCount.value = tags.length;
288 return tags;
289 },
290
291 __tagFolders: null,
292 get _tagFolders() {
293 if (!this.__tagFolders) {
294 this.__tagFolders = [];
295
296 let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
297 .DBConnection;
298 let stmt = db.createStatement(
299 "SELECT id, title FROM moz_bookmarks WHERE parent = :tags_root "
300 );
301 stmt.params.tags_root = PlacesUtils.tagsFolderId;
302 try {
303 while (stmt.executeStep()) {
304 this.__tagFolders[stmt.row.id] = stmt.row.title;
305 }
306 }
307 finally {
308 stmt.finalize();
309 }
310 }
311
312 return this.__tagFolders;
313 },
314
315 // nsITaggingService
316 get allTags() {
317 var allTags = [];
318 for (var i in this._tagFolders)
319 allTags.push(this._tagFolders[i]);
320 // sort the tag list
321 allTags.sort(function(a, b) {
322 return a.toLowerCase().localeCompare(b.toLowerCase());
323 });
324 return allTags;
325 },
326
327 // nsITaggingService
328 get hasTags() {
329 return this._tagFolders.length > 0;
330 },
331
332 // nsIObserver
333 observe: function TS_observe(aSubject, aTopic, aData) {
334 if (aTopic == TOPIC_SHUTDOWN) {
335 PlacesUtils.bookmarks.removeObserver(this);
336 Services.obs.removeObserver(this, TOPIC_SHUTDOWN);
337 }
338 },
339
340 /**
341 * If the only bookmark items associated with aURI are contained in tag
342 * folders, returns the IDs of those items. This can be the case if
343 * the URI was bookmarked and tagged at some point, but the bookmark was
344 * removed, leaving only the bookmark items in tag folders. If the URI is
345 * either properly bookmarked or not tagged just returns and empty array.
346 *
347 * @param aURI
348 * A URI (string) that may or may not be bookmarked
349 * @returns an array of item ids
350 */
351 _getTaggedItemIdsIfUnbookmarkedURI:
352 function TS__getTaggedItemIdsIfUnbookmarkedURI(aURI) {
353 var itemIds = [];
354 var isBookmarked = false;
355
356 // Using bookmarks service API for this would be a pain.
357 // Until tags implementation becomes sane, go the query way.
358 let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
359 .DBConnection;
360 let stmt = db.createStatement(
361 "SELECT id, parent "
362 + "FROM moz_bookmarks "
363 + "WHERE fk = (SELECT id FROM moz_places WHERE url = :page_url)"
364 );
365 stmt.params.page_url = aURI.spec;
366 try {
367 while (stmt.executeStep() && !isBookmarked) {
368 if (this._tagFolders[stmt.row.parent]) {
369 // This is a tag entry.
370 itemIds.push(stmt.row.id);
371 }
372 else {
373 // This is a real bookmark, so the bookmarked URI is not an orphan.
374 isBookmarked = true;
375 }
376 }
377 }
378 finally {
379 stmt.finalize();
380 }
381
382 return isBookmarked ? [] : itemIds;
383 },
384
385 // nsINavBookmarkObserver
386 onItemAdded: function TS_onItemAdded(aItemId, aFolderId, aIndex, aItemType,
387 aURI, aTitle) {
388 // Nothing to do if this is not a tag.
389 if (aFolderId != PlacesUtils.tagsFolderId ||
390 aItemType != PlacesUtils.bookmarks.TYPE_FOLDER)
391 return;
392
393 this._tagFolders[aItemId] = aTitle;
394 },
395
396 onItemRemoved: function TS_onItemRemoved(aItemId, aFolderId, aIndex,
397 aItemType, aURI) {
398 // Item is a tag folder.
399 if (aFolderId == PlacesUtils.tagsFolderId && this._tagFolders[aItemId]) {
400 delete this._tagFolders[aItemId];
401 }
402 // Item is a bookmark that was removed from a non-tag folder.
403 else if (aURI && !this._tagFolders[aFolderId]) {
404 // If the only bookmark items now associated with the bookmark's URI are
405 // contained in tag folders, the URI is no longer properly bookmarked, so
406 // untag it.
407 let itemIds = this._getTaggedItemIdsIfUnbookmarkedURI(aURI);
408 for (let i = 0; i < itemIds.length; i++) {
409 try {
410 PlacesUtils.bookmarks.removeItem(itemIds[i]);
411 } catch (ex) {}
412 }
413 }
414 // Item is a tag entry. If this was the last entry for this tag, remove it.
415 else if (aURI && this._tagFolders[aFolderId]) {
416 this._removeTagIfEmpty(aFolderId);
417 }
418 },
419
420 onItemChanged: function TS_onItemChanged(aItemId, aProperty,
421 aIsAnnotationProperty, aNewValue,
422 aLastModified, aItemType) {
423 if (aProperty == "title" && this._tagFolders[aItemId])
424 this._tagFolders[aItemId] = aNewValue;
425 },
426
427 onItemMoved: function TS_onItemMoved(aItemId, aOldParent, aOldIndex,
428 aNewParent, aNewIndex, aItemType) {
429 if (this._tagFolders[aItemId] && PlacesUtils.tagsFolderId == aOldParent &&
430 PlacesUtils.tagsFolderId != aNewParent)
431 delete this._tagFolders[aItemId];
432 },
433
434 onItemVisited: function () {},
435 onBeginUpdateBatch: function () {},
436 onEndUpdateBatch: function () {},
437
438 //////////////////////////////////////////////////////////////////////////////
439 //// nsISupports
440
441 classID: Components.ID("{bbc23860-2553-479d-8b78-94d9038334f7}"),
442
443 _xpcom_factory: XPCOMUtils.generateSingletonFactory(TaggingService),
444
445 QueryInterface: XPCOMUtils.generateQI([
446 Ci.nsITaggingService
447 , Ci.nsINavBookmarkObserver
448 , Ci.nsIObserver
449 ])
450 };
451
452
453 function TagAutoCompleteResult(searchString, searchResult,
454 defaultIndex, errorDescription,
455 results, comments) {
456 this._searchString = searchString;
457 this._searchResult = searchResult;
458 this._defaultIndex = defaultIndex;
459 this._errorDescription = errorDescription;
460 this._results = results;
461 this._comments = comments;
462 }
463
464 TagAutoCompleteResult.prototype = {
465
466 /**
467 * The original search string
468 */
469 get searchString() {
470 return this._searchString;
471 },
472
473 /**
474 * The result code of this result object, either:
475 * RESULT_IGNORED (invalid searchString)
476 * RESULT_FAILURE (failure)
477 * RESULT_NOMATCH (no matches found)
478 * RESULT_SUCCESS (matches found)
479 */
480 get searchResult() {
481 return this._searchResult;
482 },
483
484 /**
485 * Index of the default item that should be entered if none is selected
486 */
487 get defaultIndex() {
488 return this._defaultIndex;
489 },
490
491 /**
492 * A string describing the cause of a search failure
493 */
494 get errorDescription() {
495 return this._errorDescription;
496 },
497
498 /**
499 * The number of matches
500 */
501 get matchCount() {
502 return this._results.length;
503 },
504
505 get typeAheadResult() false,
506
507 /**
508 * Get the value of the result at the given index
509 */
510 getValueAt: function PTACR_getValueAt(index) {
511 return this._results[index];
512 },
513
514 getLabelAt: function PTACR_getLabelAt(index) {
515 return this.getValueAt(index);
516 },
517
518 /**
519 * Get the comment of the result at the given index
520 */
521 getCommentAt: function PTACR_getCommentAt(index) {
522 return this._comments[index];
523 },
524
525 /**
526 * Get the style hint for the result at the given index
527 */
528 getStyleAt: function PTACR_getStyleAt(index) {
529 if (!this._comments[index])
530 return null; // not a category label, so no special styling
531
532 if (index == 0)
533 return "suggestfirst"; // category label on first line of results
534
535 return "suggesthint"; // category label on any other line of results
536 },
537
538 /**
539 * Get the image for the result at the given index
540 */
541 getImageAt: function PTACR_getImageAt(index) {
542 return null;
543 },
544
545 /**
546 * Get the image for the result at the given index
547 */
548 getFinalCompleteValueAt: function PTACR_getFinalCompleteValueAt(index) {
549 return this.getValueAt(index);
550 },
551
552 /**
553 * Remove the value at the given index from the autocomplete results.
554 * If removeFromDb is set to true, the value should be removed from
555 * persistent storage as well.
556 */
557 removeValueAt: function PTACR_removeValueAt(index, removeFromDb) {
558 this._results.splice(index, 1);
559 this._comments.splice(index, 1);
560 },
561
562 // nsISupports
563 QueryInterface: XPCOMUtils.generateQI([
564 Ci.nsIAutoCompleteResult
565 ])
566 };
567
568 // Implements nsIAutoCompleteSearch
569 function TagAutoCompleteSearch() {
570 XPCOMUtils.defineLazyServiceGetter(this, "tagging",
571 "@mozilla.org/browser/tagging-service;1",
572 "nsITaggingService");
573 }
574
575 TagAutoCompleteSearch.prototype = {
576 _stopped : false,
577
578 /*
579 * Search for a given string and notify a listener (either synchronously
580 * or asynchronously) of the result
581 *
582 * @param searchString - The string to search for
583 * @param searchParam - An extra parameter
584 * @param previousResult - A previous result to use for faster searching
585 * @param listener - A listener to notify when the search is complete
586 */
587 startSearch: function PTACS_startSearch(searchString, searchParam, result, listener) {
588 var searchResults = this.tagging.allTags;
589 var results = [];
590 var comments = [];
591 this._stopped = false;
592
593 // only search on characters for the last tag
594 var index = Math.max(searchString.lastIndexOf(","),
595 searchString.lastIndexOf(";"));
596 var before = '';
597 if (index != -1) {
598 before = searchString.slice(0, index+1);
599 searchString = searchString.slice(index+1);
600 // skip past whitespace
601 var m = searchString.match(/\s+/);
602 if (m) {
603 before += m[0];
604 searchString = searchString.slice(m[0].length);
605 }
606 }
607
608 if (!searchString.length) {
609 var newResult = new TagAutoCompleteResult(searchString,
610 Ci.nsIAutoCompleteResult.RESULT_NOMATCH, 0, "", results, comments);
611 listener.onSearchResult(self, newResult);
612 return;
613 }
614
615 var self = this;
616 // generator: if yields true, not done
617 function doSearch() {
618 var i = 0;
619 while (i < searchResults.length) {
620 if (self._stopped)
621 yield false;
622 // for each match, prepend what the user has typed so far
623 if (searchResults[i].toLowerCase()
624 .indexOf(searchString.toLowerCase()) == 0 &&
625 comments.indexOf(searchResults[i]) == -1) {
626 results.push(before + searchResults[i]);
627 comments.push(searchResults[i]);
628 }
629
630 ++i;
631
632 /* TODO: bug 481451
633 * For each yield we pass a new result to the autocomplete
634 * listener. The listener appends instead of replacing previous results,
635 * causing invalid matchCount values.
636 *
637 * As a workaround, all tags are searched through in a single batch,
638 * making this synchronous until the above issue is fixed.
639 */
640
641 /*
642 // 100 loops per yield
643 if ((i % 100) == 0) {
644 var newResult = new TagAutoCompleteResult(searchString,
645 Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING, 0, "", results, comments);
646 listener.onSearchResult(self, newResult);
647 yield true;
648 }
649 */
650 }
651
652 let searchResult = results.length > 0 ?
653 Ci.nsIAutoCompleteResult.RESULT_SUCCESS :
654 Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
655 var newResult = new TagAutoCompleteResult(searchString, searchResult, 0,
656 "", results, comments);
657 listener.onSearchResult(self, newResult);
658 yield false;
659 }
660
661 // chunk the search results via the generator
662 var gen = doSearch();
663 while (gen.next());
664 gen.close();
665 },
666
667 /**
668 * Stop an asynchronous search that is in progress
669 */
670 stopSearch: function PTACS_stopSearch() {
671 this._stopped = true;
672 },
673
674 //////////////////////////////////////////////////////////////////////////////
675 //// nsISupports
676
677 classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}"),
678
679 _xpcom_factory: XPCOMUtils.generateSingletonFactory(TagAutoCompleteSearch),
680
681 QueryInterface: XPCOMUtils.generateQI([
682 Ci.nsIAutoCompleteSearch
683 ])
684 };
685
686 let component = [TaggingService, TagAutoCompleteSearch];
687 this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);

mercurial