/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const Cc = Components.classes; const Ci = Components.interfaces; const Cr = Components.results; Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); Components.utils.import("resource://gre/modules/Services.jsm"); Components.utils.import("resource://gre/modules/PlacesUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Deprecated", "resource://gre/modules/Deprecated.jsm"); const TOPIC_SHUTDOWN = "places-shutdown"; /** * The Places Tagging Service */ function TaggingService() { // Observe bookmarks changes. PlacesUtils.bookmarks.addObserver(this, false); // Cleanup on shutdown. Services.obs.addObserver(this, TOPIC_SHUTDOWN, false); } TaggingService.prototype = { /** * Creates a tag container under the tags-root with the given name. * * @param aTagName * the name for the new tag. * @returns the id of the new tag container. */ _createTag: function TS__createTag(aTagName) { var newFolderId = PlacesUtils.bookmarks.createFolder( PlacesUtils.tagsFolderId, aTagName, PlacesUtils.bookmarks.DEFAULT_INDEX ); // Add the folder to our local cache, so we can avoid doing this in the // observer that would have to check itemType. this._tagFolders[newFolderId] = aTagName; return newFolderId; }, /** * Checks whether the given uri is tagged with the given tag. * * @param [in] aURI * url to check for * @param [in] aTagName * the tag to check for * @returns the item id if the URI is tagged with the given tag, -1 * otherwise. */ _getItemIdForTaggedURI: function TS__getItemIdForTaggedURI(aURI, aTagName) { var tagId = this._getItemIdForTag(aTagName); if (tagId == -1) return -1; // Using bookmarks service API for this would be a pain. // Until tags implementation becomes sane, go the query way. let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) .DBConnection; let stmt = db.createStatement( `SELECT id FROM moz_bookmarks WHERE parent = :tag_id AND fk = (SELECT id FROM moz_places WHERE url = :page_url)` ); stmt.params.tag_id = tagId; stmt.params.page_url = aURI.spec; try { if (stmt.executeStep()) { return stmt.row.id; } } finally { stmt.finalize(); } return -1; }, /** * Returns the folder id for a tag, or -1 if not found. * @param [in] aTag * string tag to search for * @returns integer id for the bookmark folder for the tag */ _getItemIdForTag: function TS_getItemIdForTag(aTagName) { for (var i in this._tagFolders) { if (aTagName.toLowerCase() == this._tagFolders[i].toLowerCase()) return parseInt(i); } return -1; }, /** * Makes a proper array of tag objects like { id: number, name: string }. * * @param aTags * Array of tags. Entries can be tag names or concrete item id. * @param trim [optional] * Whether to trim passed-in named tags. Defaults to false. * @return Array of tag objects like { id: number, name: string }. * * @throws Cr.NS_ERROR_INVALID_ARG if any element of the input array is not * a valid tag. */ _convertInputMixedTagsArray(aTags, trim=false) { // Handle sparse array with a .filter. return aTags.filter(tag => tag !== undefined) .map(idOrName => { let tag = {}; if (typeof(idOrName) == "number" && this._tagFolders[idOrName]) { // This is a tag folder id. tag.id = idOrName; // We can't know the name at this point, since a previous tag could // want to change it. tag.__defineGetter__("name", () => this._tagFolders[tag.id]); } else if (typeof(idOrName) == "string" && idOrName.length > 0 && idOrName.length <= Ci.nsITaggingService.MAX_TAG_LENGTH) { // This is a tag name. tag.name = trim ? idOrName.trim() : idOrName; // We can't know the id at this point, since a previous tag could // have created it. tag.__defineGetter__("id", () => this._getItemIdForTag(tag.name)); } else { throw Cr.NS_ERROR_INVALID_ARG; } return tag; }); }, // nsITaggingService tagURI: function TS_tagURI(aURI, aTags) { if (!aURI || !aTags || !Array.isArray(aTags)) { throw Cr.NS_ERROR_INVALID_ARG; } // This also does some input validation. let tags = this._convertInputMixedTagsArray(aTags, true); let taggingFunction = () => { for (let tag of tags) { if (tag.id == -1) { // Tag does not exist yet, create it. this._createTag(tag.name); } if (this._getItemIdForTaggedURI(aURI, tag.name) == -1) { // The provided URI is not yet tagged, add a tag for it. // Note that bookmarks under tag containers must have null titles. PlacesUtils.bookmarks.insertBookmark( tag.id, aURI, PlacesUtils.bookmarks.DEFAULT_INDEX, null ); } // Try to preserve user's tag name casing. // Rename the tag container so the Places view matches the most-recent // user-typed value. if (PlacesUtils.bookmarks.getItemTitle(tag.id) != tag.name) { // this._tagFolders is updated by the bookmarks observer. PlacesUtils.bookmarks.setItemTitle(tag.id, tag.name); } } }; // Use a batch only if creating more than 2 tags. if (tags.length < 3) { taggingFunction(); } else { PlacesUtils.bookmarks.runInBatchMode(taggingFunction, null); } }, /** * Removes the tag container from the tags root if the given tag is empty. * * @param aTagId * the itemId of the tag element under the tags root */ _removeTagIfEmpty: function TS__removeTagIfEmpty(aTagId) { let count = 0; let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) .DBConnection; let stmt = db.createStatement( `SELECT count(*) AS count FROM moz_bookmarks WHERE parent = :tag_id` ); stmt.params.tag_id = aTagId; try { if (stmt.executeStep()) { count = stmt.row.count; } } finally { stmt.finalize(); } if (count == 0) { PlacesUtils.bookmarks.removeItem(aTagId); } }, // nsITaggingService untagURI: function TS_untagURI(aURI, aTags) { if (!aURI || (aTags && !Array.isArray(aTags))) { throw Cr.NS_ERROR_INVALID_ARG; } if (!aTags) { // Passing null should clear all tags for aURI, see the IDL. // XXXmano: write a perf-sensitive version of this code path... aTags = this.getTagsForURI(aURI); } // This also does some input validation. let tags = this._convertInputMixedTagsArray(aTags); let isAnyTagNotTrimmed = tags.some(tag => /^\s|\s$/.test(tag.name)); if (isAnyTagNotTrimmed) { Deprecated.warning("At least one tag passed to untagURI was not trimmed", "https://bugzilla.mozilla.org/show_bug.cgi?id=967196"); } let untaggingFunction = () => { for (let tag of tags) { if (tag.id != -1) { // A tag could exist. let itemId = this._getItemIdForTaggedURI(aURI, tag.name); if (itemId != -1) { // There is a tagged item. PlacesUtils.bookmarks.removeItem(itemId); } } } }; // Use a batch only if creating more than 2 tags. if (tags.length < 3) { untaggingFunction(); } else { PlacesUtils.bookmarks.runInBatchMode(untaggingFunction, null); } }, // nsITaggingService getURIsForTag: function TS_getURIsForTag(aTagName) { if (!aTagName || aTagName.length == 0) throw Cr.NS_ERROR_INVALID_ARG; if (/^\s|\s$/.test(aTagName)) { Deprecated.warning("Tag passed to getURIsForTag was not trimmed", "https://bugzilla.mozilla.org/show_bug.cgi?id=967196"); } let uris = []; let tagId = this._getItemIdForTag(aTagName); if (tagId == -1) return uris; let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) .DBConnection; let stmt = db.createStatement( `SELECT h.url FROM moz_places h JOIN moz_bookmarks b ON b.fk = h.id WHERE b.parent = :tag_id` ); stmt.params.tag_id = tagId; try { while (stmt.executeStep()) { try { uris.push(Services.io.newURI(stmt.row.url, null, null)); } catch (ex) {} } } finally { stmt.finalize(); } return uris; }, // nsITaggingService getTagsForURI: function TS_getTagsForURI(aURI, aCount) { if (!aURI) throw Cr.NS_ERROR_INVALID_ARG; var tags = []; var bookmarkIds = PlacesUtils.bookmarks.getBookmarkIdsForURI(aURI); for (var i=0; i < bookmarkIds.length; i++) { var folderId = PlacesUtils.bookmarks.getFolderIdForItem(bookmarkIds[i]); if (this._tagFolders[folderId]) tags.push(this._tagFolders[folderId]); } // sort the tag list tags.sort(function(a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); }); if (aCount) aCount.value = tags.length; return tags; }, __tagFolders: null, get _tagFolders() { if (!this.__tagFolders) { this.__tagFolders = []; let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) .DBConnection; let stmt = db.createStatement( "SELECT id, title FROM moz_bookmarks WHERE parent = :tags_root " ); stmt.params.tags_root = PlacesUtils.tagsFolderId; try { while (stmt.executeStep()) { this.__tagFolders[stmt.row.id] = stmt.row.title; } } finally { stmt.finalize(); } } return this.__tagFolders; }, // nsITaggingService get allTags() { var allTags = []; for (var i in this._tagFolders) allTags.push(this._tagFolders[i]); // sort the tag list allTags.sort(function(a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); }); return allTags; }, // nsITaggingService get hasTags() { return this._tagFolders.length > 0; }, // nsIObserver observe: function TS_observe(aSubject, aTopic, aData) { if (aTopic == TOPIC_SHUTDOWN) { PlacesUtils.bookmarks.removeObserver(this); Services.obs.removeObserver(this, TOPIC_SHUTDOWN); } }, /** * If the only bookmark items associated with aURI are contained in tag * folders, returns the IDs of those items. This can be the case if * the URI was bookmarked and tagged at some point, but the bookmark was * removed, leaving only the bookmark items in tag folders. If the URI is * either properly bookmarked or not tagged just returns and empty array. * * @param aURI * A URI (string) that may or may not be bookmarked * @returns an array of item ids */ _getTaggedItemIdsIfUnbookmarkedURI: function TS__getTaggedItemIdsIfUnbookmarkedURI(aURI) { var itemIds = []; var isBookmarked = false; // Using bookmarks service API for this would be a pain. // Until tags implementation becomes sane, go the query way. let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase) .DBConnection; let stmt = db.createStatement( `SELECT id, parent FROM moz_bookmarks WHERE fk = (SELECT id FROM moz_places WHERE url = :page_url)` ); stmt.params.page_url = aURI.spec; try { while (stmt.executeStep() && !isBookmarked) { if (this._tagFolders[stmt.row.parent]) { // This is a tag entry. itemIds.push(stmt.row.id); } else { // This is a real bookmark, so the bookmarked URI is not an orphan. isBookmarked = true; } } } finally { stmt.finalize(); } return isBookmarked ? [] : itemIds; }, // nsINavBookmarkObserver onItemAdded: function TS_onItemAdded(aItemId, aFolderId, aIndex, aItemType, aURI, aTitle) { // Nothing to do if this is not a tag. if (aFolderId != PlacesUtils.tagsFolderId || aItemType != PlacesUtils.bookmarks.TYPE_FOLDER) return; this._tagFolders[aItemId] = aTitle; }, onItemRemoved: function TS_onItemRemoved(aItemId, aFolderId, aIndex, aItemType, aURI) { // Item is a tag folder. if (aFolderId == PlacesUtils.tagsFolderId && this._tagFolders[aItemId]) { delete this._tagFolders[aItemId]; } // Item is a bookmark that was removed from a non-tag folder. else if (aURI && !this._tagFolders[aFolderId]) { // If the only bookmark items now associated with the bookmark's URI are // contained in tag folders, the URI is no longer properly bookmarked, so // untag it. let itemIds = this._getTaggedItemIdsIfUnbookmarkedURI(aURI); for (let i = 0; i < itemIds.length; i++) { try { PlacesUtils.bookmarks.removeItem(itemIds[i]); } catch (ex) {} } } // Item is a tag entry. If this was the last entry for this tag, remove it. else if (aURI && this._tagFolders[aFolderId]) { this._removeTagIfEmpty(aFolderId); } }, onItemChanged: function TS_onItemChanged(aItemId, aProperty, aIsAnnotationProperty, aNewValue, aLastModified, aItemType) { if (aProperty == "title" && this._tagFolders[aItemId]) this._tagFolders[aItemId] = aNewValue; }, onItemMoved: function TS_onItemMoved(aItemId, aOldParent, aOldIndex, aNewParent, aNewIndex, aItemType) { if (this._tagFolders[aItemId] && PlacesUtils.tagsFolderId == aOldParent && PlacesUtils.tagsFolderId != aNewParent) delete this._tagFolders[aItemId]; }, onItemVisited: function () {}, onBeginUpdateBatch: function () {}, onEndUpdateBatch: function () {}, ////////////////////////////////////////////////////////////////////////////// //// nsISupports classID: Components.ID("{bbc23860-2553-479d-8b78-94d9038334f7}"), _xpcom_factory: XPCOMUtils.generateSingletonFactory(TaggingService), QueryInterface: XPCOMUtils.generateQI([ Ci.nsITaggingService , Ci.nsINavBookmarkObserver , Ci.nsIObserver ]) }; function TagAutoCompleteResult(searchString, searchResult, defaultIndex, errorDescription, results, comments) { this._searchString = searchString; this._searchResult = searchResult; this._defaultIndex = defaultIndex; this._errorDescription = errorDescription; this._results = results; this._comments = comments; } TagAutoCompleteResult.prototype = { /** * The original search string */ get searchString() { return this._searchString; }, /** * The result code of this result object, either: * RESULT_IGNORED (invalid searchString) * RESULT_FAILURE (failure) * RESULT_NOMATCH (no matches found) * RESULT_SUCCESS (matches found) */ get searchResult() { return this._searchResult; }, /** * Index of the default item that should be entered if none is selected */ get defaultIndex() { return this._defaultIndex; }, /** * A string describing the cause of a search failure */ get errorDescription() { return this._errorDescription; }, /** * The number of matches */ get matchCount() { return this._results.length; }, get typeAheadResult() { return false; }, /** * Get the value of the result at the given index */ getValueAt: function PTACR_getValueAt(index) { return this._results[index]; }, getLabelAt: function PTACR_getLabelAt(index) { return this.getValueAt(index); }, /** * Get the comment of the result at the given index */ getCommentAt: function PTACR_getCommentAt(index) { return this._comments[index]; }, /** * Get the style hint for the result at the given index */ getStyleAt: function PTACR_getStyleAt(index) { if (!this._comments[index]) return null; // not a category label, so no special styling if (index == 0) return "suggestfirst"; // category label on first line of results return "suggesthint"; // category label on any other line of results }, /** * Get the image for the result at the given index */ getImageAt: function PTACR_getImageAt(index) { return null; }, /** * Get the image for the result at the given index */ getFinalCompleteValueAt: function PTACR_getFinalCompleteValueAt(index) { return this.getValueAt(index); }, /** * Remove the value at the given index from the autocomplete results. * If removeFromDb is set to true, the value should be removed from * persistent storage as well. */ removeValueAt: function PTACR_removeValueAt(index, removeFromDb) { this._results.splice(index, 1); this._comments.splice(index, 1); }, // nsISupports QueryInterface: XPCOMUtils.generateQI([ Ci.nsIAutoCompleteResult ]) }; // Implements nsIAutoCompleteSearch function TagAutoCompleteSearch() { XPCOMUtils.defineLazyServiceGetter(this, "tagging", "@mozilla.org/browser/tagging-service;1", "nsITaggingService"); } TagAutoCompleteSearch.prototype = { _stopped : false, /* * Search for a given string and notify a listener (either synchronously * or asynchronously) of the result * * @param searchString - The string to search for * @param searchParam - An extra parameter * @param previousResult - A previous result to use for faster searching * @param listener - A listener to notify when the search is complete */ startSearch: function PTACS_startSearch(searchString, searchParam, result, listener) { var searchResults = this.tagging.allTags; var results = []; var comments = []; this._stopped = false; // only search on characters for the last tag var index = Math.max(searchString.lastIndexOf(","), searchString.lastIndexOf(";")); var before = ''; if (index != -1) { before = searchString.slice(0, index+1); searchString = searchString.slice(index+1); // skip past whitespace var m = searchString.match(/\s+/); if (m) { before += m[0]; searchString = searchString.slice(m[0].length); } } if (!searchString.length) { var newResult = new TagAutoCompleteResult(searchString, Ci.nsIAutoCompleteResult.RESULT_NOMATCH, 0, "", results, comments); listener.onSearchResult(self, newResult); return; } var self = this; // generator: if yields true, not done function doSearch() { var i = 0; while (i < searchResults.length) { if (self._stopped) yield false; // for each match, prepend what the user has typed so far if (searchResults[i].toLowerCase() .indexOf(searchString.toLowerCase()) == 0 && !comments.includes(searchResults[i])) { results.push(before + searchResults[i]); comments.push(searchResults[i]); } ++i; /* TODO: bug 481451 * For each yield we pass a new result to the autocomplete * listener. The listener appends instead of replacing previous results, * causing invalid matchCount values. * * As a workaround, all tags are searched through in a single batch, * making this synchronous until the above issue is fixed. */ /* // 100 loops per yield if ((i % 100) == 0) { var newResult = new TagAutoCompleteResult(searchString, Ci.nsIAutoCompleteResult.RESULT_SUCCESS_ONGOING, 0, "", results, comments); listener.onSearchResult(self, newResult); yield true; } */ } let searchResult = results.length > 0 ? Ci.nsIAutoCompleteResult.RESULT_SUCCESS : Ci.nsIAutoCompleteResult.RESULT_NOMATCH; var newResult = new TagAutoCompleteResult(searchString, searchResult, 0, "", results, comments); listener.onSearchResult(self, newResult); yield false; } // chunk the search results via the generator var gen = doSearch(); while (gen.next()); gen.close(); }, /** * Stop an asynchronous search that is in progress */ stopSearch: function PTACS_stopSearch() { this._stopped = true; }, ////////////////////////////////////////////////////////////////////////////// //// nsISupports classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}"), _xpcom_factory: XPCOMUtils.generateSingletonFactory(TagAutoCompleteSearch), QueryInterface: XPCOMUtils.generateQI([ Ci.nsIAutoCompleteSearch ]) }; var component = [TaggingService, TagAutoCompleteSearch]; this.NSGetFactory = XPCOMUtils.generateNSGetFactory(component);