Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
* You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5
"use strict";
6
7
/**
8
* Asynchronous API for managing history.
9
*
10
*
11
* The API makes use of `PageInfo` and `VisitInfo` objects, defined as follows.
12
*
13
* A `PageInfo` object is any object that contains A SUBSET of the
14
* following properties:
15
* - guid: (string)
16
* The globally unique id of the page.
17
* - url: (URL)
18
* or (nsIURI)
19
* or (string)
20
* The full URI of the page. Note that `PageInfo` values passed as
21
* argument may hold `nsIURI` or `string` values for property `url`,
22
* but `PageInfo` objects returned by this module always hold `URL`
23
* values.
24
* - title: (string)
25
* The title associated with the page, if any.
26
* - description: (string)
27
* The description of the page, if any.
28
* - previewImageURL: (URL)
29
* or (nsIURI)
30
* or (string)
31
* The preview image URL of the page, if any.
32
* - frecency: (number)
33
* The frecency of the page, if any.
35
* Note that this property may not be used to change the actualy frecency
36
* score of a page, only to retrieve it. In other words, any `frecency` field
37
* passed as argument to a function of this API will be ignored.
38
* - visits: (Array<VisitInfo>)
39
* All the visits for this page, if any.
40
* - annotations: (Map)
41
* A map containing key/value pairs of the annotations for this page, if any.
42
*
43
* See the documentation of individual methods to find out which properties
44
* are required for `PageInfo` arguments or returned for `PageInfo` results.
45
*
46
* A `VisitInfo` object is any object that contains A SUBSET of the following
47
* properties:
48
* - date: (Date)
49
* The time the visit occurred.
50
* - transition: (number)
51
* How the user reached the page. See constants `TRANSITIONS.*`
52
* for the possible transition types.
53
* - referrer: (URL)
54
* or (nsIURI)
55
* or (string)
56
* The referring URI of this visit. Note that `VisitInfo` passed
57
* as argument may hold `nsIURI` or `string` values for property `referrer`,
58
* but `VisitInfo` objects returned by this module always hold `URL`
59
* values.
60
* See the documentation of individual methods to find out which properties
61
* are required for `VisitInfo` arguments or returned for `VisitInfo` results.
62
*
63
*
64
*
65
* Each successful operation notifies through the nsINavHistoryObserver
66
* interface. To listen to such notifications you must register using
67
* nsINavHistoryService `addObserver` and `removeObserver` methods.
68
* @see nsINavHistoryObserver
69
*/
70
71
var EXPORTED_SYMBOLS = ["History"];
72
73
const { XPCOMUtils } = ChromeUtils.import(
75
);
76
ChromeUtils.defineModuleGetter(
77
this,
78
"NetUtil",
80
);
81
ChromeUtils.defineModuleGetter(
82
this,
83
"PlacesUtils",
85
);
86
87
XPCOMUtils.defineLazyServiceGetter(
88
this,
89
"asyncHistory",
90
"@mozilla.org/browser/history;1",
91
"mozIAsyncHistory"
92
);
93
94
XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
95
96
/**
97
* Whenever we update or remove numerous pages, it is preferable
98
* to yield time to the main thread every so often to avoid janking.
99
* These constants determine the maximal number of notifications we
100
* may emit before we yield.
101
*/
102
const NOTIFICATION_CHUNK_SIZE = 300;
103
const ONRESULT_CHUNK_SIZE = 300;
104
105
// This constant determines the maximum number of remove pages before we cycle.
106
const REMOVE_PAGES_CHUNKLEN = 300;
107
108
/**
109
* Sends a bookmarks notification through the given observers.
110
*
111
* @param observers
112
* array of nsINavBookmarkObserver objects.
113
* @param notification
114
* the notification name.
115
* @param args
116
* array of arguments to pass to the notification.
117
*/
118
function notify(observers, notification, args = []) {
119
for (let observer of observers) {
120
try {
121
observer[notification](...args);
122
} catch (ex) {}
123
}
124
}
125
126
var History = Object.freeze({
127
/**
128
* Fetch the available information for one page.
129
*
130
* @param guidOrURI: (string) or (URL, nsIURI or href)
131
* Either the full URI of the page or the GUID of the page.
132
* @param [optional] options (object)
133
* An optional object whose properties describe options:
134
* - `includeVisits` (boolean) set this to true if `visits` in the
135
* PageInfo needs to contain VisitInfo in a reverse chronological order.
136
* By default, `visits` is undefined inside the returned `PageInfo`.
137
* - `includeMeta` (boolean) set this to true to fetch page meta fields,
138
* i.e. `description` and `preview_image_url`.
139
* - `includeAnnotations` (boolean) set this to true to fetch any
140
* annotations that are associated with the page.
141
*
142
* @return (Promise)
143
* A promise resolved once the operation is complete.
144
* @resolves (PageInfo | null) If the page could be found, the information
145
* on that page.
146
* @note the VisitInfo objects returned while fetching visits do not
147
* contain the property `referrer`.
148
* TODO: Add `referrer` to VisitInfo. See Bug #1365913.
149
* @note the visits returned will not contain `TRANSITION_EMBED` visits.
150
*
151
* @throws (Error)
152
* If `guidOrURI` does not have the expected type or if it is a string
153
* that may be parsed neither as a valid URL nor as a valid GUID.
154
*/
155
fetch(guidOrURI, options = {}) {
156
// First, normalize to guid or string, and throw if not possible
157
guidOrURI = PlacesUtils.normalizeToURLOrGUID(guidOrURI);
158
159
// See if options exists and make sense
160
if (!options || typeof options !== "object") {
161
throw new TypeError("options should be an object and not null");
162
}
163
164
let hasIncludeVisits = "includeVisits" in options;
165
if (hasIncludeVisits && typeof options.includeVisits !== "boolean") {
166
throw new TypeError("includeVisits should be a boolean if exists");
167
}
168
169
let hasIncludeMeta = "includeMeta" in options;
170
if (hasIncludeMeta && typeof options.includeMeta !== "boolean") {
171
throw new TypeError("includeMeta should be a boolean if exists");
172
}
173
174
let hasIncludeAnnotations = "includeAnnotations" in options;
175
if (
176
hasIncludeAnnotations &&
177
typeof options.includeAnnotations !== "boolean"
178
) {
179
throw new TypeError("includeAnnotations should be a boolean if exists");
180
}
181
182
return PlacesUtils.promiseDBConnection().then(db =>
183
fetch(db, guidOrURI, options)
184
);
185
},
186
187
/**
188
* Fetches all pages which have one or more of the specified annotations.
189
*
190
* @param annotations: An array of strings containing the annotation names to
191
* find.
192
* @return (Promise)
193
* A promise resolved once the operation is complete.
194
* @resolves (Map)
195
* A Map containing the annotations, pages and their contents, e.g.
196
* Map("anno1" => [{page, content}, {page, content}]), "anno2" => ....);
197
* @rejects (Error) XXX
198
* Rejects if the insert was unsuccessful.
199
*/
200
fetchAnnotatedPages(annotations) {
201
// See if options exists and make sense
202
if (!annotations || !Array.isArray(annotations)) {
203
throw new TypeError("annotations should be an Array and not null");
204
}
205
if (annotations.some(name => typeof name !== "string")) {
206
throw new TypeError("all annotation values should be strings");
207
}
208
209
return PlacesUtils.promiseDBConnection().then(db =>
210
fetchAnnotatedPages(db, annotations)
211
);
212
},
213
214
/**
215
* Adds a number of visits for a single page.
216
*
217
* Any change may be observed through nsINavHistoryObserver
218
*
219
* @param pageInfo: (PageInfo)
220
* Information on a page. This `PageInfo` MUST contain
221
* - a property `url`, as specified by the definition of `PageInfo`.
222
* - a property `visits`, as specified by the definition of
223
* `PageInfo`, which MUST contain at least one visit.
224
* If a property `title` is provided, the title of the page
225
* is updated.
226
* If the `date` of a visit is not provided, it defaults
227
* to now.
228
* If the `transition` of a visit is not provided, it defaults to
229
* TRANSITION_LINK.
230
*
231
* @return (Promise)
232
* A promise resolved once the operation is complete.
233
* @resolves (PageInfo)
234
* A PageInfo object populated with data after the insert is complete.
235
* @rejects (Error)
236
* Rejects if the insert was unsuccessful.
237
*
238
* @throws (Error)
239
* If the `url` specified was for a protocol that should not be
240
* stored (@see nsNavHistory::CanAddURI).
241
* @throws (Error)
242
* If `pageInfo` has an unexpected type.
243
* @throws (Error)
244
* If `pageInfo` does not have a `url`.
245
* @throws (Error)
246
* If `pageInfo` does not have a `visits` property or if the
247
* value of `visits` is ill-typed or is an empty array.
248
* @throws (Error)
249
* If an element of `visits` has an invalid `date`.
250
* @throws (Error)
251
* If an element of `visits` has an invalid `transition`.
252
*/
253
insert(pageInfo) {
254
let info = PlacesUtils.validatePageInfo(pageInfo);
255
256
return PlacesUtils.withConnectionWrapper("History.jsm: insert", db =>
257
insert(db, info)
258
);
259
},
260
261
/**
262
* Adds a number of visits for a number of pages.
263
*
264
* Any change may be observed through nsINavHistoryObserver
265
*
266
* @param pageInfos: (Array<PageInfo>)
267
* Information on a page. This `PageInfo` MUST contain
268
* - a property `url`, as specified by the definition of `PageInfo`.
269
* - a property `visits`, as specified by the definition of
270
* `PageInfo`, which MUST contain at least one visit.
271
* If a property `title` is provided, the title of the page
272
* is updated.
273
* If the `date` of a visit is not provided, it defaults
274
* to now.
275
* If the `transition` of a visit is not provided, it defaults to
276
* TRANSITION_LINK.
277
* @param onResult: (function(PageInfo))
278
* A callback invoked for each page inserted.
279
* @param onError: (function(PageInfo))
280
* A callback invoked for each page which generated an error
281
* when an insert was attempted.
282
*
283
* @return (Promise)
284
* A promise resolved once the operation is complete.
285
* @resolves (null)
286
* @rejects (Error)
287
* Rejects if all of the inserts were unsuccessful.
288
*
289
* @throws (Error)
290
* If the `url` specified was for a protocol that should not be
291
* stored (@see nsNavHistory::CanAddURI).
292
* @throws (Error)
293
* If `pageInfos` has an unexpected type.
294
* @throws (Error)
295
* If a `pageInfo` does not have a `url`.
296
* @throws (Error)
297
* If a `PageInfo` does not have a `visits` property or if the
298
* value of `visits` is ill-typed or is an empty array.
299
* @throws (Error)
300
* If an element of `visits` has an invalid `date`.
301
* @throws (Error)
302
* If an element of `visits` has an invalid `transition`.
303
*/
304
insertMany(pageInfos, onResult, onError) {
305
let infos = [];
306
307
if (!Array.isArray(pageInfos)) {
308
throw new TypeError("pageInfos must be an array");
309
}
310
if (!pageInfos.length) {
311
throw new TypeError("pageInfos may not be an empty array");
312
}
313
314
if (onResult && typeof onResult != "function") {
315
throw new TypeError(`onResult: ${onResult} is not a valid function`);
316
}
317
if (onError && typeof onError != "function") {
318
throw new TypeError(`onError: ${onError} is not a valid function`);
319
}
320
321
for (let pageInfo of pageInfos) {
322
let info = PlacesUtils.validatePageInfo(pageInfo);
323
infos.push(info);
324
}
325
326
return PlacesUtils.withConnectionWrapper("History.jsm: insertMany", db =>
327
insertMany(db, infos, onResult, onError)
328
);
329
},
330
331
/**
332
* Remove pages from the database.
333
*
334
* Any change may be observed through nsINavHistoryObserver
335
*
336
*
337
* @param page: (URL or nsIURI)
338
* The full URI of the page.
339
* or (string)
340
* Either the full URI of the page or the GUID of the page.
341
* or (Array<URL|nsIURI|string>)
342
* An array of the above, to batch requests.
343
* @param onResult: (function(PageInfo))
344
* A callback invoked for each page found.
345
*
346
* @return (Promise)
347
* A promise resolved once the operation is complete.
348
* @resolve (bool)
349
* `true` if at least one page was removed, `false` otherwise.
350
* @throws (TypeError)
351
* If `pages` has an unexpected type or if a string provided
352
* is neither a valid GUID nor a valid URI or if `pages`
353
* is an empty array.
354
*/
355
remove(pages, onResult = null) {
356
// Normalize and type-check arguments
357
if (Array.isArray(pages)) {
358
if (!pages.length) {
359
throw new TypeError("Expected at least one page");
360
}
361
} else {
362
pages = [pages];
363
}
364
365
let guids = [];
366
let urls = [];
367
for (let page of pages) {
368
// Normalize to URL or GUID, or throw if `page` cannot
369
// be normalized.
370
let normalized = PlacesUtils.normalizeToURLOrGUID(page);
371
if (typeof normalized === "string") {
372
guids.push(normalized);
373
} else {
374
urls.push(normalized.href);
375
}
376
}
377
378
// At this stage, we know that either `guids` is not-empty
379
// or `urls` is not-empty.
380
381
if (onResult && typeof onResult != "function") {
382
throw new TypeError("Invalid function: " + onResult);
383
}
384
385
return (async function() {
386
let removedPages = false;
387
let count = 0;
388
while (guids.length || urls.length) {
389
if (count && count % 2 == 0) {
390
// Every few cycles, yield time back to the main
391
// thread to avoid jank.
392
await Promise.resolve();
393
}
394
count++;
395
let guidsSlice = guids.splice(0, REMOVE_PAGES_CHUNKLEN);
396
let urlsSlice = [];
397
if (guidsSlice.length < REMOVE_PAGES_CHUNKLEN) {
398
urlsSlice = urls.splice(0, REMOVE_PAGES_CHUNKLEN - guidsSlice.length);
399
}
400
401
let pages = { guids: guidsSlice, urls: urlsSlice };
402
403
let result = await PlacesUtils.withConnectionWrapper(
404
"History.jsm: remove",
405
db => remove(db, pages, onResult)
406
);
407
408
removedPages = removedPages || result;
409
}
410
return removedPages;
411
})();
412
},
413
414
/**
415
* Remove visits matching specific characteristics.
416
*
417
* Any change may be observed through nsINavHistoryObserver.
418
*
419
* @param filter: (object)
420
* The `object` may contain some of the following
421
* properties:
422
* - beginDate: (Date) Remove visits that have
423
* been added since this date (inclusive).
424
* - endDate: (Date) Remove visits that have
425
* been added before this date (inclusive).
426
* - limit: (Number) Limit the number of visits
427
* we remove to this number
428
* - url: (URL) Only remove visits to this URL
429
* - transition: (Integer)
430
* The type of the transition (see TRANSITIONS below)
431
* If both `beginDate` and `endDate` are specified,
432
* visits between `beginDate` (inclusive) and `end`
433
* (inclusive) are removed.
434
*
435
* @param onResult: (function(VisitInfo), [optional])
436
* A callback invoked for each visit found and removed.
437
* Note that the referrer property of `VisitInfo`
438
* is NOT populated.
439
*
440
* @return (Promise)
441
* @resolve (bool)
442
* `true` if at least one visit was removed, `false`
443
* otherwise.
444
* @throws (TypeError)
445
* If `filter` does not have the expected type, in
446
* particular if the `object` is empty.
447
*/
448
removeVisitsByFilter(filter, onResult = null) {
449
if (!filter || typeof filter != "object") {
450
throw new TypeError("Expected a filter");
451
}
452
453
let hasBeginDate = "beginDate" in filter;
454
let hasEndDate = "endDate" in filter;
455
let hasURL = "url" in filter;
456
let hasLimit = "limit" in filter;
457
let hasTransition = "transition" in filter;
458
if (hasBeginDate) {
459
this.ensureDate(filter.beginDate);
460
}
461
if (hasEndDate) {
462
this.ensureDate(filter.endDate);
463
}
464
if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
465
throw new TypeError("`beginDate` should be at least as old as `endDate`");
466
}
467
if (hasTransition && !this.isValidTransition(filter.transition)) {
468
throw new TypeError("`transition` should be valid");
469
}
470
if (
471
!hasBeginDate &&
472
!hasEndDate &&
473
!hasURL &&
474
!hasLimit &&
475
!hasTransition
476
) {
477
throw new TypeError("Expected a non-empty filter");
478
}
479
480
if (
481
hasURL &&
482
!(filter.url instanceof URL) &&
483
typeof filter.url != "string" &&
484
!(filter.url instanceof Ci.nsIURI)
485
) {
486
throw new TypeError("Expected a valid URL for `url`");
487
}
488
489
if (
490
hasLimit &&
491
(typeof filter.limit != "number" ||
492
filter.limit <= 0 ||
493
!Number.isInteger(filter.limit))
494
) {
495
throw new TypeError("Expected a non-zero positive integer as a limit");
496
}
497
498
if (onResult && typeof onResult != "function") {
499
throw new TypeError("Invalid function: " + onResult);
500
}
501
502
return PlacesUtils.withConnectionWrapper(
503
"History.jsm: removeVisitsByFilter",
504
db => removeVisitsByFilter(db, filter, onResult)
505
);
506
},
507
508
/**
509
* Remove pages from the database based on a filter.
510
*
511
* Any change may be observed through nsINavHistoryObserver
512
*
513
*
514
* @param filter: An object containing a non empty subset of the following
515
* properties:
516
* - host: (string)
517
* Hostname with or without subhost. Examples:
518
* "mozilla.org" removes pages from mozilla.org but not its subdomains
519
* ".mozilla.org" removes pages from mozilla.org and its subdomains
520
* "." removes local files
521
* - beginDate: (Date)
522
* The first time the page was visited (inclusive)
523
* - endDate: (Date)
524
* The last time the page was visited (inclusive)
525
* @param [optional] onResult: (function(PageInfo))
526
* A callback invoked for each page found.
527
*
528
* @note This removes pages with at least one visit inside the timeframe.
529
* Any visits outside the timeframe will also be removed with the page.
530
* @return (Promise)
531
* A promise resolved once the operation is complete.
532
* @resolve (bool)
533
* `true` if at least one page was removed, `false` otherwise.
534
* @throws (TypeError)
535
* if `filter` does not have the expected type, in particular
536
* if the `object` is empty, or its components do not satisfy the
537
* criteria given above
538
*/
539
removeByFilter(filter, onResult) {
540
if (!filter || typeof filter !== "object") {
541
throw new TypeError("Expected a filter object");
542
}
543
544
let hasHost = filter.host;
545
if (hasHost) {
546
if (typeof filter.host !== "string") {
547
throw new TypeError("`host` should be a string");
548
}
549
filter.host = filter.host.toLowerCase();
550
if (filter.host.length > 1 && filter.host.lastIndexOf(".") == 0) {
551
// The input contains only an initial period, thus it may be a
552
// wildcarded local host, like ".localhost". Ideally the consumer should
553
// pass just "localhost", because there is no concept of subhosts for
554
// it, but we are being more lenient to allow for simpler input.
555
// Anyway, in this case we remove the wildcard to avoid clearing too
556
// much if the consumer wrongly passes in things like ".com".
557
filter.host = filter.host.slice(1);
558
}
559
}
560
561
let hasBeginDate = "beginDate" in filter;
562
if (hasBeginDate) {
563
this.ensureDate(filter.beginDate);
564
}
565
566
let hasEndDate = "endDate" in filter;
567
if (hasEndDate) {
568
this.ensureDate(filter.endDate);
569
}
570
571
if (hasBeginDate && hasEndDate && filter.beginDate > filter.endDate) {
572
throw new TypeError("`beginDate` should be at least as old as `endDate`");
573
}
574
575
if (!hasBeginDate && !hasEndDate && !hasHost) {
576
throw new TypeError("Expected a non-empty filter");
577
}
578
579
// Check the host format.
580
// Either it has no dots, or has multiple dots, or it's a single dot char.
581
if (
582
hasHost &&
583
(!/^(\.?([.a-z0-9-]+\.[a-z0-9-]+)?|[a-z0-9-]+)$/.test(filter.host) ||
584
filter.host.includes(".."))
585
) {
586
throw new TypeError(
587
"Expected well formed hostname string for `host` with atmost 1 wildcard."
588
);
589
}
590
591
if (onResult && typeof onResult != "function") {
592
throw new TypeError("Invalid function: " + onResult);
593
}
594
595
return PlacesUtils.withConnectionWrapper(
596
"History.jsm: removeByFilter",
597
db => removeByFilter(db, filter, onResult)
598
);
599
},
600
601
/**
602
* Determine if a page has been visited.
603
*
604
* @param guidOrURI: (string) or (URL, nsIURI or href)
605
* Either the full URI of the page or the GUID of the page.
606
* @return (Promise)
607
* A promise resolved once the operation is complete.
608
* @resolve (bool)
609
* `true` if the page has been visited, `false` otherwise.
610
* @throws (Error)
611
* If `guidOrURI` has an unexpected type or if a string provided
612
* is neither not a valid GUID nor a valid URI.
613
*/
614
hasVisits(guidOrURI) {
615
// Quick fallback to the cpp version.
616
if (guidOrURI instanceof Ci.nsIURI) {
617
return new Promise(resolve => {
618
asyncHistory.isURIVisited(guidOrURI, (aURI, aIsVisited) => {
619
resolve(aIsVisited);
620
});
621
});
622
}
623
624
guidOrURI = PlacesUtils.normalizeToURLOrGUID(guidOrURI);
625
let isGuid = typeof guidOrURI == "string";
626
let sqlFragment = isGuid
627
? "guid = :val"
628
: "url_hash = hash(:val) AND url = :val ";
629
630
return PlacesUtils.promiseDBConnection().then(async db => {
631
let rows = await db.executeCached(
632
`SELECT 1 FROM moz_places
633
WHERE ${sqlFragment}
634
AND last_visit_date NOTNULL`,
635
{ val: isGuid ? guidOrURI : guidOrURI.href }
636
);
637
return !!rows.length;
638
});
639
},
640
641
/**
642
* Clear all history.
643
*
644
* @return (Promise)
645
* A promise resolved once the operation is complete.
646
*/
647
clear() {
648
return PlacesUtils.withConnectionWrapper("History.jsm: clear", clear);
649
},
650
651
/**
652
* Is a value a valid transition type?
653
*
654
* @param transition: (String)
655
* @return (Boolean)
656
*/
657
isValidTransition(transition) {
658
return Object.values(History.TRANSITIONS).includes(transition);
659
},
660
661
/**
662
* Throw if an object is not a Date object.
663
*/
664
ensureDate(arg) {
665
if (!arg || typeof arg != "object" || arg.constructor.name != "Date") {
666
throw new TypeError("Expected a Date, got " + arg);
667
}
668
},
669
670
/**
671
* Update information for a page.
672
*
673
* Currently, it supports updating the description, preview image URL and annotations
674
* for a page, any other fields will be ignored.
675
*
676
* Note that this function will ignore the update if the target page has not
677
* yet been stored in the database. `History.fetch` could be used to check
678
* whether the page and its meta information exist or not. Beware that
679
* fetch&update might fail as they are not executed in a single transaction.
680
*
681
* @param pageInfo: (PageInfo)
682
* pageInfo must contain a URL of the target page. It will be ignored
683
* if a valid page `guid` is also provided.
684
*
685
* If a property `description` is provided, the description of the
686
* page is updated. Note that:
687
* 1). An empty string or null `description` will clear the existing
688
* value in the database.
689
* 2). Descriptions longer than DB_DESCRIPTION_LENGTH_MAX will be
690
* truncated.
691
*
692
* If a property `previewImageURL` is provided, the preview image
693
* URL of the page is updated. Note that:
694
* 1). A null `previewImageURL` will clear the existing value in the
695
* database.
696
* 2). It throws if its length is greater than DB_URL_LENGTH_MAX
697
* defined in PlacesUtils.jsm.
698
*
699
* If a property `annotations` is provided, the annotations will be
700
* updated. Note that:
701
* 1). It should be a Map containing key/value pairs to be updated.
702
* 2). If the value is falsy, the annotation will be removed.
703
* 3). If the value is non-falsy, the annotation will be added or updated.
704
* For `annotations` the keys must all be strings, the values should be
705
* Boolean, Number or Strings. null and undefined are supported as falsy values.
706
*
707
* @return (Promise)
708
* A promise resolved once the update is complete.
709
* @rejects (Error)
710
* Rejects if the update was unsuccessful.
711
*
712
* @throws (Error)
713
* If `pageInfo` has an unexpected type.
714
* @throws (Error)
715
* If `pageInfo` has an invalid `url` or an invalid `guid`.
716
* @throws (Error)
717
* If `pageInfo` has neither `description` nor `previewImageURL`.
718
* @throws (Error)
719
* If the length of `pageInfo.previewImageURL` is greater than
720
* DB_URL_LENGTH_MAX defined in PlacesUtils.jsm.
721
*/
722
update(pageInfo) {
723
let info = PlacesUtils.validatePageInfo(pageInfo, false);
724
725
if (
726
info.description === undefined &&
727
info.previewImageURL === undefined &&
728
info.annotations === undefined
729
) {
730
throw new TypeError(
731
"pageInfo object must at least have either a description, previewImageURL or annotations property."
732
);
733
}
734
735
return PlacesUtils.withConnectionWrapper("History.jsm: update", db =>
736
update(db, info)
737
);
738
},
739
740
/**
741
* Possible values for the `transition` property of `VisitInfo`
742
* objects.
743
*/
744
745
TRANSITIONS: {
746
/**
747
* The user followed a link and got a new toplevel window.
748
*/
749
LINK: Ci.nsINavHistoryService.TRANSITION_LINK,
750
751
/**
752
* The user typed the page's URL in the URL bar or selected it from
753
* URL bar autocomplete results, clicked on it from a history query
754
* (from the History sidebar, History menu, or history query in the
755
* personal toolbar or Places organizer.
756
*/
757
TYPED: Ci.nsINavHistoryService.TRANSITION_TYPED,
758
759
/**
760
* The user followed a bookmark to get to the page.
761
*/
762
BOOKMARK: Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
763
764
/**
765
* Some inner content is loaded. This is true of all images on a
766
* page, and the contents of the iframe. It is also true of any
767
* content in a frame if the user did not explicitly follow a link
768
* to get there.
769
*/
770
EMBED: Ci.nsINavHistoryService.TRANSITION_EMBED,
771
772
/**
773
* Set when the transition was a permanent redirect.
774
*/
775
REDIRECT_PERMANENT: Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
776
777
/**
778
* Set when the transition was a temporary redirect.
779
*/
780
REDIRECT_TEMPORARY: Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY,
781
782
/**
783
* Set when the transition is a download.
784
*/
785
DOWNLOAD: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
786
787
/**
788
* The user followed a link and got a visit in a frame.
789
*/
790
FRAMED_LINK: Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK,
791
792
/**
793
* The user reloaded a page.
794
*/
795
RELOAD: Ci.nsINavHistoryService.TRANSITION_RELOAD,
796
},
797
});
798
799
/**
800
* Convert a PageInfo object into the format expected by updatePlaces.
801
*
802
* Note: this assumes that the PageInfo object has already been validated
803
* via PlacesUtils.validatePageInfo.
804
*
805
* @param pageInfo: (PageInfo)
806
* @return (info)
807
*/
808
function convertForUpdatePlaces(pageInfo) {
809
let info = {
810
guid: pageInfo.guid,
811
uri: PlacesUtils.toURI(pageInfo.url),
812
title: pageInfo.title,
813
visits: [],
814
};
815
816
for (let inVisit of pageInfo.visits) {
817
let visit = {
818
visitDate: PlacesUtils.toPRTime(inVisit.date),
819
transitionType: inVisit.transition,
820
referrerURI: inVisit.referrer
821
? PlacesUtils.toURI(inVisit.referrer)
822
: undefined,
823
};
824
info.visits.push(visit);
825
}
826
return info;
827
}
828
829
/**
830
* Generates a list of "?" SQL bindings based on input array length.
831
* @param {array} values an array of values.
832
* @param {string} [prefix] a string to prefix to the placeholder.
833
* @param {string} [suffix] a string to suffix to the placeholder.
834
* @returns {string} placeholders is a string made of question marks and commas,
835
* one per value.
836
*/
837
function sqlBindPlaceholders(values, prefix = "", suffix = "") {
838
return new Array(values.length).fill(prefix + "?" + suffix).join(",");
839
}
840
841
/**
842
* Invalidate and recompute the frecency of a list of pages,
843
* informing frecency observers.
844
*
845
* @param {OpenConnection} db an Sqlite connection
846
* @param {Array} idList The `moz_places` identifiers to invalidate.
847
* @returns {Promise} resolved when done
848
*/
849
var invalidateFrecencies = async function(db, idList) {
850
if (!idList.length) {
851
return;
852
}
853
for (let chunk of PlacesUtils.chunkArray(idList, db.variableLimit)) {
854
await db.execute(
855
`UPDATE moz_places
856
SET frecency = NOTIFY_FRECENCY(
857
CALCULATE_FRECENCY(id), url, guid, hidden, last_visit_date
858
) WHERE id in (${sqlBindPlaceholders(chunk)})`,
859
chunk
860
);
861
await db.execute(
862
`UPDATE moz_places
863
SET hidden = 0
864
WHERE id in (${sqlBindPlaceholders(chunk)})
865
AND frecency <> 0`,
866
chunk
867
);
868
}
869
// Trigger frecency updates for all affected origins.
870
await db.execute(`DELETE FROM moz_updateoriginsupdate_temp`);
871
};
872
873
// Inner implementation of History.clear().
874
var clear = async function(db) {
875
await db.executeTransaction(async function() {
876
// Remove all non-bookmarked places entries first, this will speed up the
877
// triggers work.
878
await db.execute(`DELETE FROM moz_places WHERE foreign_count = 0`);
879
await db.execute(`DELETE FROM moz_updateoriginsdelete_temp`);
880
881
// Expire orphan icons.
882
await db.executeCached(`DELETE FROM moz_pages_w_icons
883
WHERE page_url_hash NOT IN (SELECT url_hash FROM moz_places)`);
884
await removeOrphanIcons(db);
885
886
// Expire annotations.
887
await db.execute(`DELETE FROM moz_annos WHERE NOT EXISTS (
888
SELECT 1 FROM moz_places WHERE id = place_id
889
)`);
890
891
// Expire inputhistory.
892
await db.execute(`DELETE FROM moz_inputhistory WHERE place_id IN (
893
SELECT i.place_id FROM moz_inputhistory i
894
LEFT JOIN moz_places h ON h.id = i.place_id
895
WHERE h.id IS NULL)`);
896
897
// Remove all history.
898
await db.execute("DELETE FROM moz_historyvisits");
899
900
// Invalidate frecencies for the remaining places.
901
await db.execute(`UPDATE moz_places SET frecency =
902
(CASE
903
WHEN url_hash BETWEEN hash("place", "prefix_lo") AND
904
hash("place", "prefix_hi")
905
THEN 0
906
ELSE -1
907
END)
908
WHERE frecency > 0`);
909
});
910
911
let observers = PlacesUtils.history.getObservers();
912
notify(observers, "onClearHistory");
913
// Notify frecency change observers.
914
notify(observers, "onManyFrecenciesChanged");
915
916
// Trigger frecency updates for all affected origins.
917
await db.execute(`DELETE FROM moz_updateoriginsupdate_temp`);
918
};
919
920
/**
921
* Clean up pages whose history has been modified, by either
922
* removing them entirely (if they are marked for removal,
923
* typically because all visits have been removed and there
924
* are no more foreign keys such as bookmarks) or updating
925
* their frecency (otherwise).
926
*
927
* @param db: (Sqlite connection)
928
* The database.
929
* @param pages: (Array of objects)
930
* Pages that have been touched and that need cleaning up.
931
* Each object should have the following properties:
932
* - id: (number) The `moz_places` identifier for the place.
933
* - hasVisits: (boolean) If `true`, there remains at least one
934
* visit to this page, so the page should be kept and its
935
* frecency updated.
936
* - hasForeign: (boolean) If `true`, the page has at least
937
* one foreign reference (i.e. a bookmark), so the page should
938
* be kept and its frecency updated.
939
* @return (Promise)
940
*/
941
var cleanupPages = async function(db, pages) {
942
await invalidateFrecencies(
943
db,
944
pages.filter(p => p.hasForeign || p.hasVisits).map(p => p.id)
945
);
946
947
let pagesToRemove = pages.filter(p => !p.hasForeign && !p.hasVisits);
948
if (!pagesToRemove.length) {
949
return;
950
}
951
952
// Note, we are already in a transaction, since callers create it.
953
// Check relations regardless, to avoid creating orphans in case of
954
// async race conditions.
955
for (let chunk of PlacesUtils.chunkArray(pagesToRemove, db.variableLimit)) {
956
let idsToRemove = chunk.map(p => p.id);
957
await db.execute(
958
`DELETE FROM moz_places
959
WHERE id IN ( ${sqlBindPlaceholders(idsToRemove)} )
960
AND foreign_count = 0 AND last_visit_date ISNULL`,
961
idsToRemove
962
);
963
964
// Expire orphans.
965
let hashesToRemove = chunk.map(p => p.hash);
966
await db.executeCached(
967
`DELETE FROM moz_pages_w_icons
968
WHERE page_url_hash IN (${sqlBindPlaceholders(hashesToRemove)})`,
969
hashesToRemove
970
);
971
972
await db.execute(
973
`DELETE FROM moz_annos
974
WHERE place_id IN ( ${sqlBindPlaceholders(idsToRemove)} )`,
975
idsToRemove
976
);
977
await db.execute(
978
`DELETE FROM moz_inputhistory
979
WHERE place_id IN ( ${sqlBindPlaceholders(idsToRemove)} )`,
980
idsToRemove
981
);
982
}
983
// Hosts accumulated during the places delete are updated through a trigger
984
// (see nsPlacesTriggers.h).
985
await db.executeCached(`DELETE FROM moz_updateoriginsdelete_temp`);
986
987
await removeOrphanIcons(db);
988
};
989
990
/**
991
* Remove icons whose origin is not in moz_origins, unless referenced.
992
* @param db: (Sqlite connection)
993
* The database.
994
*/
995
function removeOrphanIcons(db) {
996
return db.executeCached(`
997
DELETE FROM moz_icons WHERE id IN (
998
SELECT id FROM moz_icons WHERE root = 0
999
UNION ALL
1000
SELECT id FROM moz_icons
1001
WHERE root = 1
1002
AND get_host_and_port(icon_url) NOT IN (SELECT host FROM moz_origins)
1003
AND fixup_url(get_host_and_port(icon_url)) NOT IN (SELECT host FROM moz_origins)
1004
EXCEPT
1005
SELECT icon_id FROM moz_icons_to_pages
1006
)`);
1007
}
1008
1009
/**
1010
* Notify observers that pages have been removed/updated.
1011
*
1012
* @param db: (Sqlite connection)
1013
* The database.
1014
* @param pages: (Array of objects)
1015
* Pages that have been touched and that need cleaning up.
1016
* Each object should have the following properties:
1017
* - id: (number) The `moz_places` identifier for the place.
1018
* - hasVisits: (boolean) If `true`, there remains at least one
1019
* visit to this page, so the page should be kept and its
1020
* frecency updated.
1021
* - hasForeign: (boolean) If `true`, the page has at least
1022
* one foreign reference (i.e. a bookmark), so the page should
1023
* be kept and its frecency updated.
1024
* @param transition: (Number)
1025
* Set to a valid TRANSITIONS value to indicate all transitions of a
1026
* certain type have been removed, otherwise defaults to -1 (unknown value).
1027
* @return (Promise)
1028
*/
1029
var notifyCleanup = async function(db, pages, transition = -1) {
1030
let notifiedCount = 0;
1031
let observers = PlacesUtils.history.getObservers();
1032
1033
let reason = Ci.nsINavHistoryObserver.REASON_DELETED;
1034
1035
for (let page of pages) {
1036
let uri = NetUtil.newURI(page.url.href);
1037
let guid = page.guid;
1038
if (page.hasVisits || page.hasForeign) {
1039
// We have removed all visits, but the page is still alive, e.g.
1040
// because of a bookmark.
1041
notify(observers, "onDeleteVisits", [
1042
uri,
1043
page.hasVisits > 0,
1044
guid,
1045
reason,
1046
transition,
1047
]);
1048
} else {
1049
// The page has been entirely removed.
1050
notify(observers, "onDeleteURI", [uri, guid, reason]);
1051
}
1052
if (++notifiedCount % NOTIFICATION_CHUNK_SIZE == 0) {
1053
// Every few notifications, yield time back to the main
1054
// thread to avoid jank.
1055
await Promise.resolve();
1056
}
1057
}
1058
};
1059
1060
/**
1061
* Notify an `onResult` callback of a set of operations
1062
* that just took place.
1063
*
1064
* @param data: (Array)
1065
* The data to send to the callback.
1066
* @param onResult: (function [optional])
1067
* If provided, call `onResult` with `data[0]`, `data[1]`, etc.
1068
* Otherwise, do nothing.
1069
*/
1070
var notifyOnResult = async function(data, onResult) {
1071
if (!onResult) {
1072
return;
1073
}
1074
let notifiedCount = 0;
1075
for (let info of data) {
1076
try {
1077
onResult(info);
1078
} catch (ex) {
1079
// Errors should be reported but should not stop the operation.
1080
Promise.reject(ex);
1081
}
1082
if (++notifiedCount % ONRESULT_CHUNK_SIZE == 0) {
1083
// Every few notifications, yield time back to the main
1084
// thread to avoid jank.
1085
await Promise.resolve();
1086
}
1087
}
1088
};
1089
1090
// Inner implementation of History.fetch.
1091
var fetch = async function(db, guidOrURL, options) {
1092
let whereClauseFragment = "";
1093
let params = {};
1094
if (guidOrURL instanceof URL) {
1095
whereClauseFragment = "WHERE h.url_hash = hash(:url) AND h.url = :url";
1096
params.url = guidOrURL.href;
1097
} else {
1098
whereClauseFragment = "WHERE h.guid = :guid";
1099
params.guid = guidOrURL;
1100
}
1101
1102
let visitSelectionFragment = "";
1103
let joinFragment = "";
1104
let visitOrderFragment = "";
1105
if (options.includeVisits) {
1106
visitSelectionFragment = ", v.visit_date, v.visit_type";
1107
joinFragment = "JOIN moz_historyvisits v ON h.id = v.place_id";
1108
visitOrderFragment = "ORDER BY v.visit_date DESC";
1109
}
1110
1111
let pageMetaSelectionFragment = "";
1112
if (options.includeMeta) {
1113
pageMetaSelectionFragment = ", description, preview_image_url";
1114
}
1115
1116
let query = `SELECT h.id, guid, url, title, frecency
1117
${pageMetaSelectionFragment} ${visitSelectionFragment}
1118
FROM moz_places h ${joinFragment}
1119
${whereClauseFragment}
1120
${visitOrderFragment}`;
1121
let pageInfo = null;
1122
let placeId = null;
1123
await db.executeCached(query, params, row => {
1124
if (pageInfo === null) {
1125
// This means we're on the first row, so we need to get all the page info.
1126
pageInfo = {
1127
guid: row.getResultByName("guid"),
1128
url: new URL(row.getResultByName("url")),
1129
frecency: row.getResultByName("frecency"),
1130
title: row.getResultByName("title") || "",
1131
};
1132
placeId = row.getResultByName("id");
1133
}
1134
if (options.includeMeta) {
1135
pageInfo.description = row.getResultByName("description") || "";
1136
let previewImageURL = row.getResultByName("preview_image_url");
1137
pageInfo.previewImageURL = previewImageURL
1138
? new URL(previewImageURL)
1139
: null;
1140
}
1141
if (options.includeVisits) {
1142
// On every row (not just the first), we need to collect visit data.
1143
if (!("visits" in pageInfo)) {
1144
pageInfo.visits = [];
1145
}
1146
let date = PlacesUtils.toDate(row.getResultByName("visit_date"));
1147
let transition = row.getResultByName("visit_type");
1148
1149
// TODO: Bug #1365913 add referrer URL to the `VisitInfo` data as well.
1150
pageInfo.visits.push({ date, transition });
1151
}
1152
});
1153
1154
// Only try to get annotations if requested, and if there's an actual page found.
1155
if (pageInfo && options.includeAnnotations) {
1156
let rows = await db.executeCached(
1157
`
1158
SELECT n.name, a.content FROM moz_anno_attributes n
1159
JOIN moz_annos a ON n.id = a.anno_attribute_id
1160
WHERE a.place_id = :placeId
1161
`,
1162
{ placeId }
1163
);
1164
1165
pageInfo.annotations = new Map(
1166
rows.map(row => [
1167
row.getResultByName("name"),
1168
row.getResultByName("content"),
1169
])
1170
);
1171
}
1172
return pageInfo;
1173
};
1174
1175
// Inner implementation of History.fetchAnnotatedPages.
1176
var fetchAnnotatedPages = async function(db, annotations) {
1177
let result = new Map();
1178
let rows = await db.execute(
1179
`
1180
SELECT n.name, h.url, a.content FROM moz_anno_attributes n
1181
JOIN moz_annos a ON n.id = a.anno_attribute_id
1182
JOIN moz_places h ON h.id = a.place_id
1183
WHERE n.name IN (${new Array(annotations.length).fill("?").join(",")})
1184
`,
1185
annotations
1186
);
1187
1188
for (let row of rows) {
1189
let uri;
1190
try {
1191
uri = new URL(row.getResultByName("url"));
1192
} catch (ex) {
1193
Cu.reportError("Invalid URL read from database in fetchAnnotatedPages");
1194
continue;
1195
}
1196
1197
let anno = {
1198
uri,
1199
content: row.getResultByName("content"),
1200
};
1201
let annoName = row.getResultByName("name");
1202
let pageAnnos = result.get(annoName);
1203
if (!pageAnnos) {
1204
pageAnnos = [];
1205
result.set(annoName, pageAnnos);
1206
}
1207
pageAnnos.push(anno);
1208
}
1209
1210
return result;
1211
};
1212
1213
// Inner implementation of History.removeVisitsByFilter.
1214
var removeVisitsByFilter = async function(db, filter, onResult = null) {
1215
// 1. Determine visits that took place during the interval. Note
1216
// that the database uses microseconds, while JS uses milliseconds,
1217
// so we need to *1000 one way and /1000 the other way.
1218
let conditions = [];
1219
let args = {};
1220
let transition = -1;
1221
if ("beginDate" in filter) {
1222
conditions.push("v.visit_date >= :begin * 1000");
1223
args.begin = Number(filter.beginDate);
1224
}
1225
if ("endDate" in filter) {
1226
conditions.push("v.visit_date <= :end * 1000");
1227
args.end = Number(filter.endDate);
1228
}
1229
if ("limit" in filter) {
1230
args.limit = Number(filter.limit);
1231
}
1232
if ("transition" in filter) {
1233
conditions.push("v.visit_type = :transition");
1234
args.transition = filter.transition;
1235
transition = filter.transition;
1236
}
1237
1238
let optionalJoin = "";
1239
if ("url" in filter) {
1240
let url = filter.url;
1241
if (url instanceof Ci.nsIURI) {
1242
url = filter.url.spec;
1243
} else {
1244
url = new URL(url).href;
1245
}
1246
optionalJoin = `JOIN moz_places h ON h.id = v.place_id`;
1247
conditions.push("h.url_hash = hash(:url)", "h.url = :url");
1248
args.url = url;
1249
}
1250
1251
let visitsToRemove = [];
1252
let pagesToInspect = new Set();
1253
let onResultData = onResult ? [] : null;
1254
1255
await db.executeCached(
1256
`SELECT v.id, place_id, visit_date / 1000 AS date, visit_type FROM moz_historyvisits v
1257
${optionalJoin}
1258
WHERE ${conditions.join(" AND ")}${
1259
args.limit ? " LIMIT :limit" : ""
1260
}`,
1261
args,
1262
row => {
1263
let id = row.getResultByName("id");
1264
let place_id = row.getResultByName("place_id");
1265
visitsToRemove.push(id);
1266
pagesToInspect.add(place_id);
1267
1268
if (onResult) {
1269
onResultData.push({
1270
date: new Date(row.getResultByName("date")),
1271
transition: row.getResultByName("visit_type"),
1272
});
1273
}
1274
}
1275
);
1276
1277
if (!visitsToRemove.length) {
1278
// Nothing to do
1279
return false;
1280
}
1281
1282
let pages = [];
1283
await db.executeTransaction(async function() {
1284
// 2. Remove all offending visits.
1285
for (let chunk of PlacesUtils.chunkArray(
1286
visitsToRemove,
1287
db.variableLimit
1288
)) {
1289
await db.execute(
1290
`DELETE FROM moz_historyvisits
1291
WHERE id IN (${sqlBindPlaceholders(chunk)})`,
1292
chunk
1293
);
1294
}
1295
1296
// 3. Find out which pages have been orphaned
1297
for (let chunk of PlacesUtils.chunkArray(
1298
[...pagesToInspect],
1299
db.variableLimit
1300
)) {
1301
await db.execute(
1302
`SELECT id, url, url_hash, guid,
1303
(foreign_count != 0) AS has_foreign,
1304
(last_visit_date NOTNULL) as has_visits
1305
FROM moz_places
1306
WHERE id IN (${sqlBindPlaceholders(chunk)})`,
1307
chunk,
1308
row => {
1309
let page = {
1310
id: row.getResultByName("id"),
1311
guid: row.getResultByName("guid"),
1312
hasForeign: row.getResultByName("has_foreign"),
1313
hasVisits: row.getResultByName("has_visits"),
1314
url: new URL(row.getResultByName("url")),
1315
hash: row.getResultByName("url_hash"),
1316
};
1317
pages.push(page);
1318
}
1319
);
1320
}
1321
1322
// 4. Clean up and notify
1323
await cleanupPages(db, pages);
1324
});
1325
1326
notifyCleanup(db, pages, transition);
1327
notifyOnResult(onResultData, onResult); // don't wait
1328
1329
return !!visitsToRemove.length;
1330
};
1331
1332
// Inner implementation of History.removeByFilter
1333
var removeByFilter = async function(db, filter, onResult = null) {
1334
// 1. Create fragment for date filtration
1335
let dateFilterSQLFragment = "";
1336
let conditions = [];
1337
let params = {};
1338
if ("beginDate" in filter) {
1339
conditions.push("v.visit_date >= :begin");
1340
params.begin = PlacesUtils.toPRTime(filter.beginDate);
1341
}
1342
if ("endDate" in filter) {
1343
conditions.push("v.visit_date <= :end");
1344
params.end = PlacesUtils.toPRTime(filter.endDate);
1345
}
1346
1347
if (conditions.length !== 0) {
1348
dateFilterSQLFragment = `EXISTS
1349
(SELECT id FROM moz_historyvisits v WHERE v.place_id = h.id AND
1350
${conditions.join(" AND ")}
1351
LIMIT 1)`;
1352
}
1353
1354
// 2. Create fragment for host and subhost filtering
1355
let hostFilterSQLFragment = "";
1356
if (filter.host) {
1357
// There are four cases that we need to consider:
1358
// mozilla.org, .mozilla.org, localhost, and local files
1359
let revHost = filter.host
1360
.split("")
1361
.reverse()
1362
.join("");
1363
if (filter.host == ".") {
1364
// Local files.
1365
hostFilterSQLFragment = `h.rev_host = :revHost`;
1366
} else if (filter.host.startsWith(".")) {
1367
// Remove the subhost wildcard.
1368
revHost = revHost.slice(0, -1);
1369
hostFilterSQLFragment = `h.rev_host between :revHost || "." and :revHost || "/"`;
1370
} else {
1371
// This covers non-wildcarded hosts (e.g.: mozilla.org, localhost)
1372
hostFilterSQLFragment = `h.rev_host = :revHost || "."`;
1373
}
1374
params.revHost = revHost;
1375
}
1376
1377
// 3. Find out what needs to be removed
1378
let fragmentArray = [hostFilterSQLFragment, dateFilterSQLFragment];
1379
let query = `SELECT h.id, url, url_hash, rev_host, guid, title, frecency, foreign_count
1380
FROM moz_places h WHERE
1381
(${fragmentArray.filter(f => f !== "").join(") AND (")})`;
1382
let onResultData = onResult ? [] : null;
1383
let pages = [];
1384
let hasPagesToRemove = false;
1385
1386
await db.executeCached(query, params, row => {
1387
let hasForeign = row.getResultByName("foreign_count") != 0;
1388
if (!hasForeign) {
1389
hasPagesToRemove = true;
1390
}
1391
let id = row.getResultByName("id");
1392
let guid = row.getResultByName("guid");
1393
let url = row.getResultByName("url");
1394
let page = {
1395
id,
1396
guid,
1397
hasForeign,
1398
hasVisits: false,
1399
url: new URL(url),
1400
hash: row.getResultByName("url_hash"),
1401
};
1402
pages.push(page);
1403
if (onResult) {
1404
onResultData.push({
1405
guid,
1406
title: row.getResultByName("title"),
1407
frecency: row.getResultByName("frecency"),
1408
url: new URL(url),
1409
});
1410
}
1411
});
1412
1413
if (pages.length === 0) {
1414
// Nothing to do
1415
return false;
1416
}
1417
1418
await db.executeTransaction(async function() {
1419
// 4. Actually remove visits
1420
let pageIds = pages.map(p => p.id);
1421
for (let chunk of PlacesUtils.chunkArray(pageIds, db.variableLimit)) {
1422
await db.execute(
1423
`DELETE FROM moz_historyvisits
1424
WHERE place_id IN(${sqlBindPlaceholders(chunk)})`,
1425
chunk
1426
);
1427
}
1428
// 5. Clean up and notify
1429
await cleanupPages(db, pages);
1430
});
1431
1432
notifyCleanup(db, pages);
1433
notifyOnResult(onResultData, onResult);
1434
1435
return hasPagesToRemove;
1436
};
1437
1438
// Inner implementation of History.remove.
1439
var remove = async function(db, { guids, urls }, onResult = null) {
1440
// 1. Find out what needs to be removed
1441
let onResultData = onResult ? [] : null;
1442
let pages = [];
1443
let hasPagesToRemove = false;
1444
function onRow(row) {
1445
let hasForeign = row.getResultByName("foreign_count") != 0;
1446
if (!hasForeign) {
1447
hasPagesToRemove = true;
1448
}
1449
let id = row.getResultByName("id");
1450
let guid = row.getResultByName("guid");
1451
let url = row.getResultByName("url");
1452
let page = {
1453
id,
1454
guid,
1455
hasForeign,
1456
hasVisits: false,
1457
url: new URL(url),
1458
hash: row.getResultByName("url_hash"),
1459
};
1460
pages.push(page);
1461
if (onResult) {
1462
onResultData.push({
1463
guid,
1464
title: row.getResultByName("title"),
1465
frecency: row.getResultByName("frecency"),
1466
url: new URL(url),
1467
});
1468
}
1469
}
1470
for (let chunk of PlacesUtils.chunkArray(guids, db.variableLimit)) {
1471
let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency
1472
FROM moz_places
1473
WHERE guid IN (${sqlBindPlaceholders(guids)})
1474
`;
1475
await db.execute(query, chunk, onRow);
1476
}
1477
for (let chunk of PlacesUtils.chunkArray(urls, db.variableLimit)) {
1478
// Make an array of variables like `["?1", "?2", ...]`, up to the length of
1479
// the chunk. This lets us bind each URL once, reusing the binding for the
1480
// `url_hash IN (...)` and `url IN (...)` clauses. We add 1 because indexed
1481
// parameters start at 1, not 0.
1482
let variables = Array.from(
1483
{ length: chunk.length },
1484
(_, i) => "?" + (i + 1)
1485
);
1486
let query = `SELECT id, url, url_hash, guid, foreign_count, title, frecency
1487
FROM moz_places
1488
WHERE url_hash IN (${variables.map(v => `hash(${v})`).join(",")}) AND
1489
url IN (${variables.join(",")})
1490
`;
1491
await db.execute(query, chunk, onRow);
1492
}
1493
1494
if (!pages.length) {
1495
// Nothing to do
1496
return false;
1497
}
1498
1499
await db.executeTransaction(async function() {
1500
// 2. Remove all visits to these pages.
1501
let pageIds = pages.map(p => p.id);
1502
for (let chunk of PlacesUtils.chunkArray(pageIds, db.variableLimit)) {
1503
await db.execute(
1504
`DELETE FROM moz_historyvisits
1505
WHERE place_id IN (${sqlBindPlaceholders(chunk)})`,
1506
chunk
1507
);
1508
}
1509
1510
// 3. Clean up and notify
1511
await cleanupPages(db, pages);
1512
});
1513
1514
notifyCleanup(db, pages);
1515
notifyOnResult(onResultData, onResult); // don't wait
1516
1517
return hasPagesToRemove;
1518
};
1519
1520
/**
1521
* Merges an updateInfo object, as returned by asyncHistory.updatePlaces
1522
* into a PageInfo object as defined in this file.
1523
*
1524
* @param updateInfo: (Object)
1525
* An object that represents a page that is generated by
1526
* asyncHistory.updatePlaces.
1527
* @param pageInfo: (PageInfo)
1528
* An PageInfo object into which to merge the data from updateInfo.
1529
* Defaults to an empty object so that this method can be used
1530
* to simply convert an updateInfo object into a PageInfo object.
1531
*
1532
* @return (PageInfo)
1533
* A PageInfo object populated with data from updateInfo.
1534
*/
1535
function mergeUpdateInfoIntoPageInfo(updateInfo, pageInfo = {}) {
1536
pageInfo.guid = updateInfo.guid;
1537
pageInfo.title = updateInfo.title;
1538
if (!pageInfo.url) {
1539
pageInfo.url = new URL(updateInfo.uri.spec);
1540
pageInfo.title = updateInfo.title;
1541
pageInfo.visits = updateInfo.visits.map(visit => {
1542
return {
1543
date: PlacesUtils.toDate(visit.visitDate),
1544
transition: visit.transitionType,
1545
referrer: visit.referrerURI ? new URL(visit.referrerURI.spec) : null,
1546
};
1547
});
1548
}
1549
return pageInfo;
1550
}
1551
1552
// Inner implementation of History.insert.
1553
var insert = function(db, pageInfo) {
1554
let info = convertForUpdatePlaces(pageInfo);
1555
1556
return new Promise((resolve, reject) => {
1557
asyncHistory.updatePlaces(info, {
1558
handleError: error => {
1559
reject(error);
1560
},
1561
handleResult: result => {
1562
pageInfo = mergeUpdateInfoIntoPageInfo(result, pageInfo);
1563
},
1564
handleCompletion: () => {
1565
resolve(pageInfo);
1566
},
1567
});
1568
});
1569
};
1570
1571
// Inner implementation of History.insertMany.
1572
var insertMany = function(db, pageInfos, onResult, onError) {
1573
let infos = [];
1574
let onResultData = [];
1575
let onErrorData = [];
1576
1577
for (let pageInfo of pageInfos) {
1578
let info = convertForUpdatePlaces(pageInfo);
1579
infos.push(info);
1580
}
1581
1582
return new Promise((resolve, reject) => {
1583
asyncHistory.updatePlaces(
1584
infos,
1585
{
1586
handleError: (resultCode, result) => {
1587
let pageInfo = mergeUpdateInfoIntoPageInfo(result);
1588
onErrorData.push(pageInfo);
1589
},
1590
handleResult: result => {
1591
let pageInfo = mergeUpdateInfoIntoPageInfo(result);
1592
onResultData.push(pageInfo);
1593
},
1594
ignoreErrors: !onError,
1595
ignoreResults: !onResult,
1596
handleCompletion: updatedCount => {
1597
notifyOnResult(onResultData, onResult);
1598
notifyOnResult(onErrorData, onError);
1599
if (updatedCount > 0) {
1600
resolve();
1601
} else {
1602
reject({ message: "No items were added to history." });
1603
}
1604
},
1605
},
1606
true
1607
);
1608
});
1609
};
1610
1611
// Inner implementation of History.update.
1612
var update = async function(db, pageInfo) {
1613
// Check for page existence first; we can skip most of the work if it doesn't
1614
// exist and anyway we'll need the place id multiple times later.
1615
// Prefer GUID over url if it's present.
1616
let id;
1617
if (typeof pageInfo.guid === "string") {
1618
let rows = await db.executeCached(
1619
"SELECT id FROM moz_places WHERE guid = :guid",
1620
{ guid: pageInfo.guid }
1621
);
1622
id = rows.length ? rows[0].getResultByName("id") : null;
1623
} else {
1624
let rows = await db.executeCached(
1625
"SELECT id FROM moz_places WHERE url_hash = hash(:url) AND url = :url",
1626
{ url: pageInfo.url.href }
1627
);
1628
id = rows.length ? rows[0].getResultByName("id") : null;
1629
}
1630
if (!id) {
1631
return;
1632
}
1633
1634
let updateFragments = [];
1635
let params = {};
1636
if ("description" in pageInfo) {
1637
updateFragments.push("description");
1638
params.description = pageInfo.description;
1639
}
1640
if ("previewImageURL" in pageInfo) {
1641
updateFragments.push("preview_image_url");
1642
params.preview_image_url = pageInfo.previewImageURL
1643
? pageInfo.previewImageURL.href
1644
: null;
1645
}
1646
if (updateFragments.length) {
1647
// Since this data may be written at every visit and is textual, avoid
1648
// overwriting the existing record if it didn't change.
1649
await db.execute(
1650
`
1651
UPDATE moz_places
1652
SET ${updateFragments.map(v => `${v} = :${v}`).join(", ")}
1653
WHERE id = :id
1654
AND (${updateFragments
1655
.map(v => `IFNULL(${v}, '') <> IFNULL(:${v}, '')`)
1656
.join(" OR ")})
1657
`,
1658
{ id, ...params }
1659
);
1660
}
1661
1662
if (pageInfo.annotations) {
1663
let annosToRemove = [];
1664
let annosToUpdate = [];
1665
1666
for (let anno of pageInfo.annotations) {
1667
anno[1] ? annosToUpdate.push(anno[0]) : annosToRemove.push(anno[0]);
1668
}
1669
1670
await db.executeTransaction(async function() {
1671
if (annosToUpdate.length) {
1672
await db.execute(
1673
`
1674
INSERT OR IGNORE INTO moz_anno_attributes (name)
1675
VALUES ${Array.from(annosToUpdate.keys())
1676
.map(k => `(:${k})`)
1677
.join(", ")}
1678
`,
1679
Object.assign({}, annosToUpdate)
1680
);
1681
1682
for (let anno of annosToUpdate) {
1683
let content = pageInfo.annotations.get(anno);
1684
// TODO: We only really need to save the type whilst we still support
1685
// accessing page annotations via the annotation service.
1686
let type =
1687
typeof content == "string"
1688
? Ci.nsIAnnotationService.TYPE_STRING
1689
: Ci.nsIAnnotationService.TYPE_INT64;
1690
let date = PlacesUtils.toPRTime(new Date());
1691
1692
// This will replace the id every time an annotation is updated. This is
1693
// not currently an issue as we're not joining on the id field.
1694
await db.execute(
1695
`
1696
INSERT OR REPLACE INTO moz_annos
1697
(place_id, anno_attribute_id, content, flags,
1698
expiration, type, dateAdded, lastModified)
1699
VALUES (:id,
1700
(SELECT id FROM moz_anno_attributes WHERE name = :anno_name),
1701
:content, 0, :expiration, :type, :date_added,
1702
:last_modified)
1703
`,
1704
{
1705
id,
1706
anno_name: anno,
1707
content,
1708
expiration: PlacesUtils.annotations.EXPIRE_NEVER,
1709
type,
1710
// The date fields are unused, so we just set them both to the latest.
1711
date_added: date,
1712
last_modified: date,
1713
}
1714
);
1715
}
1716
}
1717
1718
for (let anno of annosToRemove) {
1719
// We don't remove anything from the moz_anno_attributes table. If we
1720
// delete the last item of a given name, that item really should go away.
1721
// It will be cleaned up by expiration.
1722
await db.execute(
1723
`
1724
DELETE FROM moz_annos
1725
WHERE place_id = :id
1726
AND anno_attribute_id =
1727
(SELECT id FROM moz_anno_attributes WHERE name = :anno_name)
1728
`,
1729
{ id, anno_name: anno }
1730
);
1731
}
1732
});
1733
}
1734
};