Source code
Revision control
Copy as Markdown
Other Tools
/* -*- 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
import { PlacesUtils } from "resource://gre/modules/PlacesUtils.sys.mjs";
const TOPIC_SHUTDOWN = "places-shutdown";
/**
* The Places Tagging Service
*/
export function TaggingService() {
this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
// Observe bookmarks changes.
PlacesUtils.observers.addListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-title-changed",
],
this.handlePlacesEvents
);
// Cleanup on shutdown.
Services.obs.addObserver(this, TOPIC_SHUTDOWN);
}
TaggingService.prototype = {
/**
* Creates a tag container under the tags-root with the given name.
*
* @param aTagName
* the name for the new tag.
* @param aSource
* a change source constant from nsINavBookmarksService::SOURCE_*.
* @returns the id of the new tag container.
*/
_createTag: function TS__createTag(aTagName, aSource) {
var newFolderId = PlacesUtils.bookmarks.createFolder(
PlacesUtils.tagsFolderId,
aTagName,
PlacesUtils.bookmarks.DEFAULT_INDEX,
/* aGuid */ null,
aSource
);
// 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.DBConnection;
let stmt = db.createStatement(
`SELECT id FROM moz_bookmarks
WHERE parent = :tag_id
AND fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND 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 &&
idOrName.length <= PlacesUtils.bookmarks.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 Components.Exception(
"Invalid tag value",
Cr.NS_ERROR_INVALID_ARG
);
}
return tag;
});
},
// nsITaggingService
tagURI: function TS_tagURI(aURI, aTags, aSource) {
if (!aURI || !aTags || !Array.isArray(aTags) || !aTags.length) {
throw Components.Exception(
"Invalid value for tags",
Cr.NS_ERROR_INVALID_ARG
);
}
// This also does some input validation.
let tags = this._convertInputMixedTagsArray(aTags, true);
for (let tag of tags) {
if (tag.id == -1) {
// Tag does not exist yet, create it.
this._createTag(tag.name, aSource);
}
let itemId = this._getItemIdForTaggedURI(aURI, tag.name);
if (itemId == -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,
/* aTitle */ null,
/* aGuid */ null,
aSource
);
} else {
// Otherwise, bump the tag's timestamp, so that we can increment the
// sync change counter for all bookmarks with the URI.
PlacesUtils.bookmarks.setItemLastModified(
itemId,
PlacesUtils.toPRTime(Date.now()),
aSource
);
}
// 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, aSource);
}
}
},
/**
* 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
* @param aSource
* a change source constant from nsINavBookmarksService::SOURCE_*
*/
_removeTagIfEmpty: function TS__removeTagIfEmpty(aTagId, aSource) {
let count = 0;
let db = PlacesUtils.history.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, aSource);
}
},
// nsITaggingService
untagURI: function TS_untagURI(aURI, aTags, aSource) {
if (!aURI || (aTags && (!Array.isArray(aTags) || !aTags.length))) {
throw Components.Exception(
"Invalid value for tags",
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) {
throw Components.Exception(
"At least one tag passed to untagURI was not trimmed",
Cr.NS_ERROR_INVALID_ARG
);
}
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, aSource);
}
}
}
},
// nsITaggingService
getTagsForURI: function TS_getTagsForURI(aURI) {
if (!aURI) {
throw Components.Exception("Invalid uri", Cr.NS_ERROR_INVALID_ARG);
}
let tags = [];
let db = PlacesUtils.history.DBConnection;
let stmt = db.createStatement(
`SELECT t.id AS folderId
FROM moz_bookmarks b
JOIN moz_bookmarks t on t.id = b.parent
WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) AND
t.parent = :tags_root
ORDER BY b.lastModified DESC, b.id DESC`
);
stmt.params.url = aURI.spec;
stmt.params.tags_root = PlacesUtils.tagsFolderId;
try {
while (stmt.executeStep()) {
try {
tags.push(this._tagFolders[stmt.row.folderId]);
} catch (ex) {}
}
} finally {
stmt.finalize();
}
// sort the tag list
tags.sort(function (a, b) {
return a.toLowerCase().localeCompare(b.toLowerCase());
});
return tags;
},
__tagFolders: null,
get _tagFolders() {
if (!this.__tagFolders) {
this.__tagFolders = [];
let db = PlacesUtils.history.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;
},
// nsIObserver
observe: function TS_observe(aSubject, aTopic) {
if (aTopic == TOPIC_SHUTDOWN) {
PlacesUtils.observers.removeListener(
[
"bookmark-added",
"bookmark-removed",
"bookmark-moved",
"bookmark-title-changed",
],
this.handlePlacesEvents
);
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(url) {
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.DBConnection;
let stmt = db.createStatement(
`SELECT id, parent
FROM moz_bookmarks
WHERE fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)`
);
stmt.params.page_url = url;
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;
},
handlePlacesEvents(events) {
for (let event of events) {
switch (event.type) {
case "bookmark-added":
if (
!event.isTagging ||
event.itemType != PlacesUtils.bookmarks.TYPE_FOLDER
) {
continue;
}
this._tagFolders[event.id] = event.title;
break;
case "bookmark-removed":
// Item is a tag folder.
if (
event.parentId == PlacesUtils.tagsFolderId &&
this._tagFolders[event.id]
) {
delete this._tagFolders[event.id];
break;
}
Services.tm.dispatchToMainThread(() => {
if (event.url && !this._tagFolders[event.parentId]) {
// Item is a bookmark that was removed from a non-tag folder.
// 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(event.url);
for (let i = 0; i < itemIds.length; i++) {
try {
PlacesUtils.bookmarks.removeItem(itemIds[i], event.source);
} catch (ex) {}
}
} else if (event.url && this._tagFolders[event.parentId]) {
// Item is a tag entry. If this was the last entry for this tag, remove it.
this._removeTagIfEmpty(event.parentId, event.source);
}
});
break;
case "bookmark-moved":
if (
this._tagFolders[event.id] &&
PlacesUtils.bookmarks.tagsGuid === event.oldParentGuid &&
PlacesUtils.bookmarks.tagsGuid !== event.parentGuid
) {
delete this._tagFolders[event.id];
}
break;
case "bookmark-title-changed":
if (this._tagFolders[event.id]) {
this._tagFolders[event.id] = event.title;
}
break;
}
}
},
// nsISupports
classID: Components.ID("{bbc23860-2553-479d-8b78-94d9038334f7}"),
QueryInterface: ChromeUtils.generateQI(["nsITaggingService", "nsIObserver"]),
};
/**
* Class tracking a single tag autocomplete search.
*/
class TagSearch {
constructor(searchString, autocompleteSearch, listener) {
// We need a result regardless of having matches.
this._result = Cc[
"@mozilla.org/autocomplete/simple-result;1"
].createInstance(Ci.nsIAutoCompleteSimpleResult);
this._result.setDefaultIndex(0);
this._result.setSearchString(searchString);
this._autocompleteSearch = autocompleteSearch;
this._listener = listener;
}
async start() {
if (this._canceled) {
throw new Error("Can't restart a canceled search");
}
let searchString = this._result.searchString;
// Only search on characters for the last tag.
let index = Math.max(
searchString.lastIndexOf(","),
searchString.lastIndexOf(";")
);
let 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) {
let tags = await PlacesUtils.bookmarks.fetchTags();
if (this._canceled) {
return;
}
let lcSearchString = searchString.toLowerCase();
let matchingTags = tags
.filter(t => t.name.toLowerCase().startsWith(lcSearchString))
.map(t => t.name);
for (let i = 0; i < matchingTags.length; ++i) {
let tag = matchingTags[i];
// For each match, prepend what the user has typed so far.
this._result.appendMatch(before + tag, null, null, null, null, tag);
// In case of many tags, notify once every 10.
if (i % 10 == 0) {
this._notifyResult(true);
// yield to avoid monopolizing the main-thread
await new Promise(resolve =>
Services.tm.dispatchToMainThread(resolve)
);
if (this._canceled) {
return;
}
}
}
}
// Search is done.
this._notifyResult(false);
}
cancel() {
this._canceled = true;
}
_notifyResult(searchOngoing) {
let resultCode = this._result.matchCount
? "RESULT_SUCCESS"
: "RESULT_NOMATCH";
if (searchOngoing) {
resultCode += "_ONGOING";
}
this._result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]);
this._listener.onSearchResult(this._autocompleteSearch, this._result);
}
}
// Implements nsIAutoCompleteSearch
export function TagAutoCompleteSearch() {}
TagAutoCompleteSearch.prototype = {
/*
* Search for a given string and notify a listener 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(searchString, searchParam, previousResult, listener) {
if (this._search) {
this._search.cancel();
}
this._search = new TagSearch(searchString, this, listener);
this._search.start().catch(console.error);
},
/**
* Stop an asynchronous search that is in progress
*/
stopSearch() {
this._search.cancel();
this._search = null;
},
classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}"),
QueryInterface: ChromeUtils.generateQI(["nsIAutoCompleteSearch"]),
};