Source code

Revision control

Other Tools

1
/* -*- indent-tabs-mode: nil; js-indent-level: 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 { XPCOMUtils } = ChromeUtils.import(
8
);
9
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
10
const { PlacesUtils } = ChromeUtils.import(
12
);
13
14
const TOPIC_SHUTDOWN = "places-shutdown";
15
16
/**
17
* The Places Tagging Service
18
*/
19
function TaggingService() {
20
this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
21
22
// Observe bookmarks changes.
23
PlacesUtils.bookmarks.addObserver(this);
24
PlacesUtils.observers.addListener(
25
["bookmark-added"],
26
this.handlePlacesEvents
27
);
28
29
// Cleanup on shutdown.
30
Services.obs.addObserver(this, TOPIC_SHUTDOWN);
31
}
32
33
TaggingService.prototype = {
34
/**
35
* Creates a tag container under the tags-root with the given name.
36
*
37
* @param aTagName
38
* the name for the new tag.
39
* @param aSource
40
* a change source constant from nsINavBookmarksService::SOURCE_*.
41
* @returns the id of the new tag container.
42
*/
43
_createTag: function TS__createTag(aTagName, aSource) {
44
var newFolderId = PlacesUtils.bookmarks.createFolder(
45
PlacesUtils.tagsFolderId,
46
aTagName,
47
PlacesUtils.bookmarks.DEFAULT_INDEX,
48
/* aGuid */ null,
49
aSource
50
);
51
// Add the folder to our local cache, so we can avoid doing this in the
52
// observer that would have to check itemType.
53
this._tagFolders[newFolderId] = aTagName;
54
55
return newFolderId;
56
},
57
58
/**
59
* Checks whether the given uri is tagged with the given tag.
60
*
61
* @param [in] aURI
62
* url to check for
63
* @param [in] aTagName
64
* the tag to check for
65
* @returns the item id if the URI is tagged with the given tag, -1
66
* otherwise.
67
*/
68
_getItemIdForTaggedURI: function TS__getItemIdForTaggedURI(aURI, aTagName) {
69
var tagId = this._getItemIdForTag(aTagName);
70
if (tagId == -1) {
71
return -1;
72
}
73
// Using bookmarks service API for this would be a pain.
74
// Until tags implementation becomes sane, go the query way.
75
let db = PlacesUtils.history.DBConnection;
76
let stmt = db.createStatement(
77
`SELECT id FROM moz_bookmarks
78
WHERE parent = :tag_id
79
AND fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)`
80
);
81
stmt.params.tag_id = tagId;
82
stmt.params.page_url = aURI.spec;
83
try {
84
if (stmt.executeStep()) {
85
return stmt.row.id;
86
}
87
} finally {
88
stmt.finalize();
89
}
90
return -1;
91
},
92
93
/**
94
* Returns the folder id for a tag, or -1 if not found.
95
* @param [in] aTag
96
* string tag to search for
97
* @returns integer id for the bookmark folder for the tag
98
*/
99
_getItemIdForTag: function TS_getItemIdForTag(aTagName) {
100
for (var i in this._tagFolders) {
101
if (aTagName.toLowerCase() == this._tagFolders[i].toLowerCase()) {
102
return parseInt(i);
103
}
104
}
105
return -1;
106
},
107
108
/**
109
* Makes a proper array of tag objects like { id: number, name: string }.
110
*
111
* @param aTags
112
* Array of tags. Entries can be tag names or concrete item id.
113
* @param trim [optional]
114
* Whether to trim passed-in named tags. Defaults to false.
115
* @return Array of tag objects like { id: number, name: string }.
116
*
117
* @throws Cr.NS_ERROR_INVALID_ARG if any element of the input array is not
118
* a valid tag.
119
*/
120
_convertInputMixedTagsArray(aTags, trim = false) {
121
// Handle sparse array with a .filter.
122
return aTags
123
.filter(tag => tag !== undefined)
124
.map(idOrName => {
125
let tag = {};
126
if (typeof idOrName == "number" && this._tagFolders[idOrName]) {
127
// This is a tag folder id.
128
tag.id = idOrName;
129
// We can't know the name at this point, since a previous tag could
130
// want to change it.
131
tag.__defineGetter__("name", () => this._tagFolders[tag.id]);
132
} else if (
133
typeof idOrName == "string" &&
134
!!idOrName.length &&
135
idOrName.length <= PlacesUtils.bookmarks.MAX_TAG_LENGTH
136
) {
137
// This is a tag name.
138
tag.name = trim ? idOrName.trim() : idOrName;
139
// We can't know the id at this point, since a previous tag could
140
// have created it.
141
tag.__defineGetter__("id", () => this._getItemIdForTag(tag.name));
142
} else {
143
throw Components.Exception(
144
"Invalid tag value",
145
Cr.NS_ERROR_INVALID_ARG
146
);
147
}
148
return tag;
149
});
150
},
151
152
// nsITaggingService
153
tagURI: function TS_tagURI(aURI, aTags, aSource) {
154
if (!aURI || !aTags || !Array.isArray(aTags) || !aTags.length) {
155
throw Components.Exception(
156
"Invalid value for tags",
157
Cr.NS_ERROR_INVALID_ARG
158
);
159
}
160
161
// This also does some input validation.
162
let tags = this._convertInputMixedTagsArray(aTags, true);
163
164
for (let tag of tags) {
165
if (tag.id == -1) {
166
// Tag does not exist yet, create it.
167
this._createTag(tag.name, aSource);
168
}
169
170
let itemId = this._getItemIdForTaggedURI(aURI, tag.name);
171
if (itemId == -1) {
172
// The provided URI is not yet tagged, add a tag for it.
173
// Note that bookmarks under tag containers must have null titles.
174
PlacesUtils.bookmarks.insertBookmark(
175
tag.id,
176
aURI,
177
PlacesUtils.bookmarks.DEFAULT_INDEX,
178
/* aTitle */ null,
179
/* aGuid */ null,
180
aSource
181
);
182
} else {
183
// Otherwise, bump the tag's timestamp, so that we can increment the
184
// sync change counter for all bookmarks with the URI.
185
PlacesUtils.bookmarks.setItemLastModified(
186
itemId,
187
PlacesUtils.toPRTime(Date.now()),
188
aSource
189
);
190
}
191
192
// Try to preserve user's tag name casing.
193
// Rename the tag container so the Places view matches the most-recent
194
// user-typed value.
195
if (PlacesUtils.bookmarks.getItemTitle(tag.id) != tag.name) {
196
// this._tagFolders is updated by the bookmarks observer.
197
PlacesUtils.bookmarks.setItemTitle(tag.id, tag.name, aSource);
198
}
199
}
200
},
201
202
/**
203
* Removes the tag container from the tags root if the given tag is empty.
204
*
205
* @param aTagId
206
* the itemId of the tag element under the tags root
207
* @param aSource
208
* a change source constant from nsINavBookmarksService::SOURCE_*
209
*/
210
_removeTagIfEmpty: function TS__removeTagIfEmpty(aTagId, aSource) {
211
let count = 0;
212
let db = PlacesUtils.history.DBConnection;
213
let stmt = db.createStatement(
214
`SELECT count(*) AS count FROM moz_bookmarks
215
WHERE parent = :tag_id`
216
);
217
stmt.params.tag_id = aTagId;
218
try {
219
if (stmt.executeStep()) {
220
count = stmt.row.count;
221
}
222
} finally {
223
stmt.finalize();
224
}
225
226
if (count == 0) {
227
PlacesUtils.bookmarks.removeItem(aTagId, aSource);
228
}
229
},
230
231
// nsITaggingService
232
untagURI: function TS_untagURI(aURI, aTags, aSource) {
233
if (!aURI || (aTags && (!Array.isArray(aTags) || !aTags.length))) {
234
throw Components.Exception(
235
"Invalid value for tags",
236
Cr.NS_ERROR_INVALID_ARG
237
);
238
}
239
240
if (!aTags) {
241
// Passing null should clear all tags for aURI, see the IDL.
242
// XXXmano: write a perf-sensitive version of this code path...
243
aTags = this.getTagsForURI(aURI);
244
}
245
246
// This also does some input validation.
247
let tags = this._convertInputMixedTagsArray(aTags);
248
249
let isAnyTagNotTrimmed = tags.some(tag => /^\s|\s$/.test(tag.name));
250
if (isAnyTagNotTrimmed) {
251
throw Components.Exception(
252
"At least one tag passed to untagURI was not trimmed",
253
Cr.NS_ERROR_INVALID_ARG
254
);
255
}
256
257
for (let tag of tags) {
258
if (tag.id != -1) {
259
// A tag could exist.
260
let itemId = this._getItemIdForTaggedURI(aURI, tag.name);
261
if (itemId != -1) {
262
// There is a tagged item.
263
PlacesUtils.bookmarks.removeItem(itemId, aSource);
264
}
265
}
266
}
267
},
268
269
// nsITaggingService
270
getTagsForURI: function TS_getTagsForURI(aURI) {
271
if (!aURI) {
272
throw Components.Exception("Invalid uri", Cr.NS_ERROR_INVALID_ARG);
273
}
274
275
let tags = [];
276
let db = PlacesUtils.history.DBConnection;
277
let stmt = db.createStatement(
278
`SELECT t.id AS folderId
279
FROM moz_bookmarks b
280
JOIN moz_bookmarks t on t.id = b.parent
281
WHERE b.fk = (SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url) AND
282
t.parent = :tags_root
283
ORDER BY b.lastModified DESC, b.id DESC`
284
);
285
stmt.params.url = aURI.spec;
286
stmt.params.tags_root = PlacesUtils.tagsFolderId;
287
try {
288
while (stmt.executeStep()) {
289
try {
290
tags.push(this._tagFolders[stmt.row.folderId]);
291
} catch (ex) {}
292
}
293
} finally {
294
stmt.finalize();
295
}
296
297
// sort the tag list
298
tags.sort(function(a, b) {
299
return a.toLowerCase().localeCompare(b.toLowerCase());
300
});
301
return tags;
302
},
303
304
__tagFolders: null,
305
get _tagFolders() {
306
if (!this.__tagFolders) {
307
this.__tagFolders = [];
308
309
let db = PlacesUtils.history.DBConnection;
310
let stmt = db.createStatement(
311
"SELECT id, title FROM moz_bookmarks WHERE parent = :tags_root "
312
);
313
stmt.params.tags_root = PlacesUtils.tagsFolderId;
314
try {
315
while (stmt.executeStep()) {
316
this.__tagFolders[stmt.row.id] = stmt.row.title;
317
}
318
} finally {
319
stmt.finalize();
320
}
321
}
322
323
return this.__tagFolders;
324
},
325
326
// nsIObserver
327
observe: function TS_observe(aSubject, aTopic, aData) {
328
if (aTopic == TOPIC_SHUTDOWN) {
329
PlacesUtils.bookmarks.removeObserver(this);
330
PlacesUtils.observers.removeListener(
331
["bookmark-added"],
332
this.handlePlacesEvents
333
);
334
Services.obs.removeObserver(this, TOPIC_SHUTDOWN);
335
}
336
},
337
338
/**
339
* If the only bookmark items associated with aURI are contained in tag
340
* folders, returns the IDs of those items. This can be the case if
341
* the URI was bookmarked and tagged at some point, but the bookmark was
342
* removed, leaving only the bookmark items in tag folders. If the URI is
343
* either properly bookmarked or not tagged just returns and empty array.
344
*
345
* @param aURI
346
* A URI (string) that may or may not be bookmarked
347
* @returns an array of item ids
348
*/
349
_getTaggedItemIdsIfUnbookmarkedURI: function TS__getTaggedItemIdsIfUnbookmarkedURI(
350
aURI
351
) {
352
var itemIds = [];
353
var isBookmarked = false;
354
355
// Using bookmarks service API for this would be a pain.
356
// Until tags implementation becomes sane, go the query way.
357
let db = PlacesUtils.history.DBConnection;
358
let stmt = db.createStatement(
359
`SELECT id, parent
360
FROM moz_bookmarks
361
WHERE fk = (SELECT id FROM moz_places WHERE url_hash = hash(:page_url) AND url = :page_url)`
362
);
363
stmt.params.page_url = aURI.spec;
364
try {
365
while (stmt.executeStep() && !isBookmarked) {
366
if (this._tagFolders[stmt.row.parent]) {
367
// This is a tag entry.
368
itemIds.push(stmt.row.id);
369
} else {
370
// This is a real bookmark, so the bookmarked URI is not an orphan.
371
isBookmarked = true;
372
}
373
}
374
} finally {
375
stmt.finalize();
376
}
377
378
return isBookmarked ? [] : itemIds;
379
},
380
381
handlePlacesEvents(events) {
382
for (let event of events) {
383
if (
384
!event.isTagging ||
385
event.itemType != PlacesUtils.bookmarks.TYPE_FOLDER
386
) {
387
continue;
388
}
389
390
this._tagFolders[event.id] = event.title;
391
}
392
},
393
394
// nsINavBookmarkObserver
395
onItemRemoved: function TS_onItemRemoved(
396
aItemId,
397
aFolderId,
398
aIndex,
399
aItemType,
400
aURI,
401
aGuid,
402
aParentGuid,
403
aSource
404
) {
405
// Item is a tag folder.
406
if (aFolderId == PlacesUtils.tagsFolderId && this._tagFolders[aItemId]) {
407
delete this._tagFolders[aItemId];
408
} else if (aURI && !this._tagFolders[aFolderId]) {
409
// Item is a bookmark that was removed from a non-tag folder.
410
// If the only bookmark items now associated with the bookmark's URI are
411
// contained in tag folders, the URI is no longer properly bookmarked, so
412
// untag it.
413
let itemIds = this._getTaggedItemIdsIfUnbookmarkedURI(aURI);
414
for (let i = 0; i < itemIds.length; i++) {
415
try {
416
PlacesUtils.bookmarks.removeItem(itemIds[i], aSource);
417
} catch (ex) {}
418
}
419
} else if (aURI && this._tagFolders[aFolderId]) {
420
// Item is a tag entry. If this was the last entry for this tag, remove it.
421
this._removeTagIfEmpty(aFolderId, aSource);
422
}
423
},
424
425
onItemChanged: function TS_onItemChanged(
426
aItemId,
427
aProperty,
428
aIsAnnotationProperty,
429
aNewValue,
430
aLastModified,
431
aItemType
432
) {
433
if (aProperty == "title" && this._tagFolders[aItemId]) {
434
this._tagFolders[aItemId] = aNewValue;
435
}
436
},
437
438
onItemMoved: function TS_onItemMoved(
439
aItemId,
440
aOldParent,
441
aOldIndex,
442
aNewParent,
443
aNewIndex,
444
aItemType
445
) {
446
if (
447
this._tagFolders[aItemId] &&
448
PlacesUtils.tagsFolderId == aOldParent &&
449
PlacesUtils.tagsFolderId != aNewParent
450
) {
451
delete this._tagFolders[aItemId];
452
}
453
},
454
455
onItemVisited() {},
456
onBeginUpdateBatch() {},
457
onEndUpdateBatch() {},
458
459
// nsISupports
460
461
classID: Components.ID("{bbc23860-2553-479d-8b78-94d9038334f7}"),
462
463
_xpcom_factory: XPCOMUtils.generateSingletonFactory(TaggingService),
464
465
QueryInterface: ChromeUtils.generateQI([
466
Ci.nsITaggingService,
467
Ci.nsINavBookmarkObserver,
468
Ci.nsIObserver,
469
]),
470
};
471
472
/**
473
* Class tracking a single tag autocomplete search.
474
*/
475
class TagSearch {
476
constructor(searchString, autocompleteSearch, listener) {
477
// We need a result regardless of having matches.
478
this._result = Cc[
479
"@mozilla.org/autocomplete/simple-result;1"
480
].createInstance(Ci.nsIAutoCompleteSimpleResult);
481
this._result.setDefaultIndex(0);
482
this._result.setSearchString(searchString);
483
484
this._autocompleteSearch = autocompleteSearch;
485
this._listener = listener;
486
}
487
488
async start() {
489
if (this._canceled) {
490
throw new Error("Can't restart a canceled search");
491
}
492
493
let searchString = this._result.searchString;
494
// Only search on characters for the last tag.
495
let index = Math.max(
496
searchString.lastIndexOf(","),
497
searchString.lastIndexOf(";")
498
);
499
let before = "";
500
if (index != -1) {
501
before = searchString.slice(0, index + 1);
502
searchString = searchString.slice(index + 1);
503
// skip past whitespace
504
var m = searchString.match(/\s+/);
505
if (m) {
506
before += m[0];
507
searchString = searchString.slice(m[0].length);
508
}
509
}
510
511
if (searchString.length) {
512
let tags = await PlacesUtils.bookmarks.fetchTags();
513
if (this._canceled) {
514
return;
515
}
516
517
let lcSearchString = searchString.toLowerCase();
518
let matchingTags = tags
519
.filter(t => t.name.toLowerCase().startsWith(lcSearchString))
520
.map(t => t.name);
521
522
for (let i = 0; i < matchingTags.length; ++i) {
523
let tag = matchingTags[i];
524
// For each match, prepend what the user has typed so far.
525
this._result.appendMatch(before + tag, tag);
526
// In case of many tags, notify once every 10.
527
if (i % 10 == 0) {
528
this._notifyResult(true);
529
// yield to avoid monopolizing the main-thread
530
await new Promise(resolve =>
531
Services.tm.dispatchToMainThread(resolve)
532
);
533
if (this._canceled) {
534
return;
535
}
536
}
537
}
538
}
539
540
// Search is done.
541
this._notifyResult(false);
542
}
543
544
cancel() {
545
this._canceled = true;
546
}
547
548
_notifyResult(searchOngoing) {
549
let resultCode = this._result.matchCount
550
? "RESULT_SUCCESS"
551
: "RESULT_NOMATCH";
552
if (searchOngoing) {
553
resultCode += "_ONGOING";
554
}
555
this._result.setSearchResult(Ci.nsIAutoCompleteResult[resultCode]);
556
this._listener.onSearchResult(this._autocompleteSearch, this._result);
557
}
558
}
559
560
// Implements nsIAutoCompleteSearch
561
function TagAutoCompleteSearch() {}
562
563
TagAutoCompleteSearch.prototype = {
564
/*
565
* Search for a given string and notify a listener of the result.
566
*
567
* @param searchString - The string to search for
568
* @param searchParam - An extra parameter
569
* @param previousResult - A previous result to use for faster searching
570
* @param listener - A listener to notify when the search is complete
571
*/
572
startSearch(searchString, searchParam, previousResult, listener) {
573
if (this._search) {
574
this._search.cancel();
575
}
576
this._search = new TagSearch(searchString, this, listener);
577
this._search.start().catch(Cu.reportError);
578
},
579
580
/**
581
* Stop an asynchronous search that is in progress
582
*/
583
stopSearch() {
584
this._search.cancel();
585
this._search = null;
586
},
587
588
classID: Components.ID("{1dcc23b0-d4cb-11dc-9ad6-479d56d89593}"),
589
QueryInterface: ChromeUtils.generateQI([Ci.nsIAutoCompleteSearch]),
590
};
591
592
var EXPORTED_SYMBOLS = ["TaggingService", "TagAutoCompleteSearch"];