summaryrefslogtreecommitdiffstats
path: root/toolkit/components/downloads/DownloadHistory.sys.mjs
blob: 0077601d8405a1cd7d40443d06db9fdaf5e13018 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
/* 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/. */

/**
 * Provides access to downloads from previous sessions on platforms that store
 * them in a different location than session downloads.
 *
 * This module works with objects that are compatible with Download, while using
 * the Places interfaces internally. Some of the Places objects may also be
 * exposed to allow the consumers to integrate with history view commands.
 */

import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  Downloads: "resource://gre/modules/Downloads.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
  PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
});

// Places query used to retrieve all history downloads for the related list.
const HISTORY_PLACES_QUERY = `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`;
const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI";
const METADATA_ANNO = "downloads/metaData";

const METADATA_STATE_FINISHED = 1;
const METADATA_STATE_FAILED = 2;
const METADATA_STATE_CANCELED = 3;
const METADATA_STATE_PAUSED = 4;
const METADATA_STATE_BLOCKED_PARENTAL = 6;
const METADATA_STATE_DIRTY = 8;

/**
 * Provides methods to retrieve downloads from previous sessions and store
 * downloads for future sessions.
 */
export let DownloadHistory = {
  /**
   * Retrieves the main DownloadHistoryList object which provides a unified view
   * on downloads from both previous browsing sessions and this session.
   *
   * @param type
   *        Determines which type of downloads from this session should be
   *        included in the list. This is Downloads.PUBLIC by default, but can
   *        also be Downloads.PRIVATE or Downloads.ALL.
   * @param maxHistoryResults
   *        Optional number that limits the amount of results the history query
   *        may return.
   *
   * @return {Promise}
   * @resolves The requested DownloadHistoryList object.
   * @rejects JavaScript exception.
   */
  async getList({ type = lazy.Downloads.PUBLIC, maxHistoryResults } = {}) {
    await DownloadCache.ensureInitialized();

    let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`;
    if (!this._listPromises[key]) {
      this._listPromises[key] = lazy.Downloads.getList(type).then(list => {
        // When the amount of history downloads is capped, we request the list in
        // descending order, to make sure that the list can apply the limit.
        let query =
          HISTORY_PLACES_QUERY +
          (maxHistoryResults ? `&maxResults=${maxHistoryResults}` : "");

        return new DownloadHistoryList(list, query);
      });
    }

    return this._listPromises[key];
  },

  /**
   * This object is populated with one key for each type of download list that
   * can be returned by the getList method. The values are promises that resolve
   * to DownloadHistoryList objects.
   */
  _listPromises: {},

  async addDownloadToHistory(download) {
    if (
      download.source.isPrivate ||
      !lazy.PlacesUtils.history.canAddURI(
        lazy.PlacesUtils.toURI(download.source.url)
      )
    ) {
      return;
    }

    await DownloadCache.addDownload(download);

    await this._updateHistoryListData(download.source.url);
  },

  /**
   * Stores new detailed metadata for the given download in history. This is
   * normally called after a download finishes, fails, or is canceled.
   *
   * Failed or canceled downloads with partial data are not stored as paused,
   * because the information from the session download is required for resuming.
   *
   * @param download
   *        Download object whose metadata should be updated. If the object
   *        represents a private download, the call has no effect.
   */
  async updateMetaData(download) {
    if (
      download.source.isPrivate ||
      !download.stopped ||
      !lazy.PlacesUtils.history.canAddURI(
        lazy.PlacesUtils.toURI(download.source.url)
      )
    ) {
      return;
    }

    let state = METADATA_STATE_CANCELED;
    if (download.succeeded) {
      state = METADATA_STATE_FINISHED;
    } else if (download.error) {
      if (download.error.becauseBlockedByParentalControls) {
        state = METADATA_STATE_BLOCKED_PARENTAL;
      } else if (download.error.becauseBlockedByReputationCheck) {
        state = METADATA_STATE_DIRTY;
      } else {
        state = METADATA_STATE_FAILED;
      }
    }

    let metaData = {
      state,
      deleted: download.deleted,
      endTime: download.endTime,
    };
    if (download.succeeded) {
      metaData.fileSize = download.target.size;
    }

    // The verdict may still be present even if the download succeeded.
    if (download.error && download.error.reputationCheckVerdict) {
      metaData.reputationCheckVerdict = download.error.reputationCheckVerdict;
    }

    // This should be executed before any async parts, to ensure the cache is
    // updated before any notifications are activated.
    await DownloadCache.setMetadata(download.source.url, metaData);

    await this._updateHistoryListData(download.source.url);
  },

  async _updateHistoryListData(sourceUrl) {
    for (let key of Object.getOwnPropertyNames(this._listPromises)) {
      let downloadHistoryList = await this._listPromises[key];
      downloadHistoryList.updateForMetaDataChange(
        sourceUrl,
        DownloadCache.get(sourceUrl)
      );
    }
  },
};

/**
 * This cache exists:
 * - in order to optimize the load of DownloadsHistoryList, when Places
 *   annotations for history downloads must be read. In fact, annotations are
 *   stored in a single table, and reading all of them at once is much more
 *   efficient than an individual query.
 * - to avoid needing to do asynchronous reading of the database during download
 *   list updates, which are designed to be synchronous (to improve UI
 *   responsiveness).
 *
 * The cache is initialized the first time DownloadHistory.getList is called, or
 * when data is added.
 */
let DownloadCache = {
  _data: new Map(),
  _initializePromise: null,

  /**
   * Initializes the cache, loading the data from the places database.
   *
   * @return {Promise} Returns a promise that is resolved once the
   *                   initialization is complete.
   */
  ensureInitialized() {
    if (this._initializePromise) {
      return this._initializePromise;
    }
    this._initializePromise = (async () => {
      const placesObserver = new PlacesWeakCallbackWrapper(
        this.handlePlacesEvents.bind(this)
      );
      PlacesObservers.addListener(
        ["history-cleared", "page-removed"],
        placesObserver
      );

      let pageAnnos = await lazy.PlacesUtils.history.fetchAnnotatedPages([
        METADATA_ANNO,
        DESTINATIONFILEURI_ANNO,
      ]);

      let metaDataPages = pageAnnos.get(METADATA_ANNO);
      if (metaDataPages) {
        for (let { uri, content } of metaDataPages) {
          try {
            this._data.set(uri.href, JSON.parse(content));
          } catch (ex) {
            // Do nothing - JSON.parse could throw.
          }
        }
      }

      let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO);
      if (destinationFilePages) {
        for (let { uri, content } of destinationFilePages) {
          let newData = this.get(uri.href);
          newData.targetFileSpec = content;
          this._data.set(uri.href, newData);
        }
      }
    })();

    return this._initializePromise;
  },

  /**
   * This returns an object containing the meta data for the supplied URL.
   *
   * @param {String} url The url to get the meta data for.
   * @return {Object|null} Returns an empty object if there is no meta data found, or
   *                       an object containing the meta data. The meta data
   *                       will look like:
   *
   * { targetFileSpec, state, deleted, endTime, fileSize, ... }
   *
   * The targetFileSpec property is the value of "downloads/destinationFileURI",
   * while the other properties are taken from "downloads/metaData". Any of the
   * properties may be missing from the object.
   */
  get(url) {
    return this._data.get(url) || {};
  },

  /**
   * Adds a download to the cache and the places database.
   *
   * @param {Download} download The download to add to the database and cache.
   */
  async addDownload(download) {
    await this.ensureInitialized();

    let targetFile = new lazy.FileUtils.File(download.target.path);
    let targetUri = Services.io.newFileURI(targetFile);

    // This should be executed before any async parts, to ensure the cache is
    // updated before any notifications are activated.
    // Note: this intentionally overwrites any metadata as this is
    // the start of a new download.
    this._data.set(download.source.url, { targetFileSpec: targetUri.spec });

    let originalPageInfo = await lazy.PlacesUtils.history.fetch(
      download.source.url
    );

    let pageInfo = await lazy.PlacesUtils.history.insert({
      url: download.source.url,
      // In case we are downloading a file that does not correspond to a web
      // page for which the title is present, we populate the otherwise empty
      // history title with the name of the destination file, to allow it to be
      // visible and searchable in history results.
      title:
        (originalPageInfo && originalPageInfo.title) || targetFile.leafName,
      visits: [
        {
          // The start time is always available when we reach this point.
          date: download.startTime,
          transition: lazy.PlacesUtils.history.TRANSITIONS.DOWNLOAD,
          referrer: download.source.referrerInfo
            ? download.source.referrerInfo.originalReferrer
            : null,
        },
      ],
    });

    await lazy.PlacesUtils.history.update({
      annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]),
      // XXX Bug 1479445: We shouldn't have to supply both guid and url here,
      // but currently we do.
      guid: pageInfo.guid,
      url: pageInfo.url,
    });
  },

  /**
   * Sets the metadata for a given url. If the cache already contains meta data
   * for the given url, it will be overwritten (note: the targetFileSpec will be
   * maintained).
   *
   * @param {String} url The url to set the meta data for.
   * @param {Object} metadata The new metaData to save in the cache.
   */
  async setMetadata(url, metadata) {
    await this.ensureInitialized();

    // This should be executed before any async parts, to ensure the cache is
    // updated before any notifications are activated.
    let existingData = this.get(url);
    let newData = { ...metadata };
    if ("targetFileSpec" in existingData) {
      newData.targetFileSpec = existingData.targetFileSpec;
    }
    this._data.set(url, newData);

    try {
      await lazy.PlacesUtils.history.update({
        annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]),
        url,
      });
    } catch (ex) {
      console.error(ex);
    }
  },

  QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),

  handlePlacesEvents(events) {
    for (const event of events) {
      switch (event.type) {
        case "history-cleared": {
          this._data.clear();
          break;
        }
        case "page-removed": {
          if (event.isRemovedFromStore) {
            this._data.delete(event.url);
          }
          break;
        }
      }
    }
  },
};

/**
 * Represents a download from the browser history. This object implements part
 * of the interface of the Download object.
 *
 * While Download objects are shared between the public DownloadList and all the
 * DownloadHistoryList instances, multiple HistoryDownload objects referring to
 * the same item can be created for different DownloadHistoryList instances.
 *
 * @param placesNode
 *        The Places node from which the history download should be initialized.
 */
class HistoryDownload {
  constructor(placesNode) {
    this.placesNode = placesNode;

    // History downloads should get the referrer from Places (bug 829201).
    this.source = {
      url: placesNode.uri,
      isPrivate: false,
    };
    this.target = {
      path: undefined,
      exists: false,
      size: undefined,
    };

    // In case this download cannot obtain its end time from the Places metadata,
    // use the time from the Places node, that is the start time of the download.
    this.endTime = placesNode.time / 1000;
  }

  /**
   * DownloadSlot containing this history download.
   *
   * @type {DownloadSlot}
   */
  slot = null;

  /**
   * History downloads are never in progress.
   *
   * @type {Boolean}
   */
  stopped = true;

  /**
   * No percentage indication is shown for history downloads.
   *
   * @type {Boolean}
   */
  hasProgress = false;

  /**
   * History downloads cannot be restarted using their partial data, even if
   * they are indicated as paused in their Places metadata. The only way is to
   * use the information from a persisted session download, that will be shown
   * instead of the history download. In case this session download is not
   * available, we show the history download as canceled, not paused.
   *
   * @type {Boolean}
   */
  hasPartialData = false;

  /**
   * Pushes information from Places metadata into this object.
   */
  updateFromMetaData(metaData) {
    try {
      this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
        .getService(Ci.nsIFileProtocolHandler)
        .getFileFromURLSpec(metaData.targetFileSpec).path;
    } catch (ex) {
      this.target.path = undefined;
    }

    if ("state" in metaData) {
      this.succeeded = metaData.state == METADATA_STATE_FINISHED;
      this.canceled =
        metaData.state == METADATA_STATE_CANCELED ||
        metaData.state == METADATA_STATE_PAUSED;
      this.endTime = metaData.endTime;
      this.deleted = metaData.deleted;

      // Recreate partial error information from the state saved in history.
      if (metaData.state == METADATA_STATE_FAILED) {
        this.error = { message: "History download failed." };
      } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) {
        this.error = { becauseBlockedByParentalControls: true };
      } else if (metaData.state == METADATA_STATE_DIRTY) {
        this.error = {
          becauseBlockedByReputationCheck: true,
          reputationCheckVerdict: metaData.reputationCheckVerdict || "",
        };
      } else {
        this.error = null;
      }

      // Normal history downloads are assumed to exist until the user interface
      // is refreshed, at which point these values may be updated.
      this.target.exists = true;
      this.target.size = metaData.fileSize;
    } else {
      // Metadata might be missing from a download that has started but hasn't
      // stopped already. Normally, this state is overridden with the one from
      // the corresponding in-progress session download. But if the browser is
      // terminated abruptly and additionally the file with information about
      // in-progress downloads is lost, we may end up using this state. We use
      // the failed state to allow the download to be restarted.
      //
      // On the other hand, if the download is missing the target file
      // annotation as well, it is just a very old one, and we can assume it
      // succeeded.
      this.succeeded = !this.target.path;
      this.error = this.target.path ? { message: "Unstarted download." } : null;
      this.canceled = false;
      this.deleted = false;

      // These properties may be updated if the user interface is refreshed.
      this.target.exists = false;
      this.target.size = undefined;
    }
  }

  /**
   * This method may be called when deleting a history download.
   */
  async finalize() {}

  /**
   * This method mimicks the "refresh" method of session downloads.
   */
  async refresh() {
    try {
      this.target.size = (await IOUtils.stat(this.target.path)).size;
      this.target.exists = true;
    } catch (ex) {
      // We keep the known file size from the metadata, if any.
      this.target.exists = false;
    }

    this.slot.list._notifyAllViews("onDownloadChanged", this);
  }

  /**
   * This method mimicks the "manuallyRemoveData" method of session downloads.
   */
  async manuallyRemoveData() {
    let { path } = this.target;
    if (this.target.path && this.succeeded) {
      // Temp files are made "read-only" by DownloadIntegration.downloadDone, so
      // reset the permission bits to read/write. This won't be necessary after
      // bug 1733587 since Downloads won't ever be temporary.
      await IOUtils.setPermissions(path, 0o660);
      await IOUtils.remove(path, { ignoreAbsent: true });
    }
    this.deleted = true;
    await this.refresh();
  }
}

/**
 * Represents one item in the list of public session and history downloads.
 *
 * The object may contain a session download, a history download, or both. When
 * both a history and a session download are present, the session download gets
 * priority and its information is accessed.
 *
 * @param list
 *        The DownloadHistoryList that owns this DownloadSlot object.
 */
class DownloadSlot {
  constructor(list) {
    this.list = list;
  }

  /**
   * Download object representing the session download contained in this slot.
   */
  sessionDownload = null;
  _historyDownload = null;

  /**
   * HistoryDownload object contained in this slot.
   */
  get historyDownload() {
    return this._historyDownload;
  }

  set historyDownload(historyDownload) {
    this._historyDownload = historyDownload;
    if (historyDownload) {
      historyDownload.slot = this;
    }
  }

  /**
   * Returns the Download or HistoryDownload object for displaying information
   * and executing commands in the user interface.
   */
  get download() {
    return this.sessionDownload || this.historyDownload;
  }
}

/**
 * Represents an ordered collection of DownloadSlot objects containing a merged
 * view on session downloads and history downloads. Views on this list will
 * receive notifications for changes to both types of downloads.
 *
 * Downloads in this list are sorted from oldest to newest, with all session
 * downloads after all the history downloads. When a new history download is
 * added and the list also contains session downloads, the insertBefore option
 * of the onDownloadAdded notification refers to the first session download.
 *
 * The list of downloads cannot be modified using the DownloadList methods.
 *
 * @param publicList
 *        Underlying DownloadList containing public downloads.
 * @param place
 *        Places query used to retrieve history downloads.
 */
class DownloadHistoryList extends DownloadList {
  constructor(publicList, place) {
    super();

    // While "this._slots" contains all the data in order, the other properties
    // provide fast access for the most common operations.
    this._slots = [];
    this._slotsForUrl = new Map();
    this._slotForDownload = new WeakMap();

    // Start the asynchronous queries to retrieve history and session downloads.
    publicList.addView(this).catch(console.error);
    let query = {},
      options = {};
    lazy.PlacesUtils.history.queryStringToQuery(place, query, options);

    // NB: The addObserver call sets our nsINavHistoryResultObserver.result.
    let result = lazy.PlacesUtils.history.executeQuery(
      query.value,
      options.value
    );
    result.addObserver(this);

    // Our history result observer is long lived for fast shared views, so free
    // the reference on shutdown to prevent leaks.
    Services.obs.addObserver(() => {
      this.result = null;
    }, "quit-application-granted");
  }

  /**
   * This is set when executing the Places query.
   */
  _result = null;

  /**
   * Index of the first slot that contains a session download. This is equal to
   * the length of the list when there are no session downloads.
   *
   * @type {Number}
   */
  _firstSessionSlotIndex = 0;

  get result() {
    return this._result;
  }

  set result(result) {
    if (this._result == result) {
      return;
    }

    if (this._result) {
      this._result.removeObserver(this);
      this._result.root.containerOpen = false;
    }

    this._result = result;

    if (this._result) {
      this._result.root.containerOpen = true;
    }
  }

  /**
   * Updates the download history item when the meta data or destination file
   * changes.
   *
   * @param {String} sourceUrl The sourceUrl which was updated.
   * @param {Object} metaData The new meta data for the sourceUrl.
   */
  updateForMetaDataChange(sourceUrl, metaData) {
    let slotsForUrl = this._slotsForUrl.get(sourceUrl);
    if (!slotsForUrl) {
      return;
    }

    for (let slot of slotsForUrl) {
      if (slot.sessionDownload) {
        // The visible data doesn't change, so we don't have to notify views.
        return;
      }
      slot.historyDownload.updateFromMetaData(metaData);
      this._notifyAllViews("onDownloadChanged", slot.download);
    }
  }

  _insertSlot({ slot, index, slotsForUrl }) {
    // Add the slot to the ordered array.
    this._slots.splice(index, 0, slot);
    this._downloads.splice(index, 0, slot.download);
    if (!slot.sessionDownload) {
      this._firstSessionSlotIndex++;
    }

    // Add the slot to the fast access maps.
    slotsForUrl.add(slot);
    this._slotsForUrl.set(slot.download.source.url, slotsForUrl);

    // Add the associated view items.
    this._notifyAllViews("onDownloadAdded", slot.download, {
      insertBefore: this._downloads[index + 1],
    });
  }

  _removeSlot({ slot, slotsForUrl }) {
    // Remove the slot from the ordered array.
    let index = this._slots.indexOf(slot);
    this._slots.splice(index, 1);
    this._downloads.splice(index, 1);
    if (this._firstSessionSlotIndex > index) {
      this._firstSessionSlotIndex--;
    }

    // Remove the slot from the fast access maps.
    slotsForUrl.delete(slot);
    if (slotsForUrl.size == 0) {
      this._slotsForUrl.delete(slot.download.source.url);
    }

    // Remove the associated view items.
    this._notifyAllViews("onDownloadRemoved", slot.download);
  }

  /**
   * Ensures that the information about a history download is stored in at least
   * one slot, adding a new one at the end of the list if necessary.
   *
   * A reference to the same Places node will be stored in the HistoryDownload
   * object for all the DownloadSlot objects associated with the source URL.
   *
   * @param placesNode
   *        The Places node that represents the history download.
   */
  _insertPlacesNode(placesNode) {
    let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set();

    // If there are existing slots associated with this URL, we only have to
    // ensure that the Places node reference is kept updated in case the more
    // recent Places notification contained a different node object.
    if (slotsForUrl.size > 0) {
      for (let slot of slotsForUrl) {
        if (!slot.historyDownload) {
          slot.historyDownload = new HistoryDownload(placesNode);
        } else {
          slot.historyDownload.placesNode = placesNode;
        }
      }
      return;
    }

    // If there are no existing slots for this URL, we have to create a new one.
    // Since the history download is visible in the slot, we also have to update
    // the object using the Places metadata.
    let historyDownload = new HistoryDownload(placesNode);
    historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri));
    let slot = new DownloadSlot(this);
    slot.historyDownload = historyDownload;
    this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex });
  }

  // nsINavHistoryResultObserver
  containerStateChanged(node, oldState, newState) {
    this.invalidateContainer(node);
  }

  // nsINavHistoryResultObserver
  invalidateContainer(container) {
    this._notifyAllViews("onDownloadBatchStarting");

    // Remove all the current slots containing only history downloads.
    for (let index = this._slots.length - 1; index >= 0; index--) {
      let slot = this._slots[index];
      if (slot.sessionDownload) {
        // The visible data doesn't change, so we don't have to notify views.
        slot.historyDownload = null;
      } else {
        let slotsForUrl = this._slotsForUrl.get(slot.download.source.url);
        this._removeSlot({ slot, slotsForUrl });
      }
    }

    // Add new slots or reuse existing ones for history downloads.
    for (let index = container.childCount - 1; index >= 0; --index) {
      try {
        this._insertPlacesNode(container.getChild(index));
      } catch (ex) {
        console.error(ex);
      }
    }

    this._notifyAllViews("onDownloadBatchEnded");
  }

  // nsINavHistoryResultObserver
  nodeInserted(parent, placesNode) {
    this._insertPlacesNode(placesNode);
  }

  // nsINavHistoryResultObserver
  nodeRemoved(parent, placesNode, aOldIndex) {
    let slotsForUrl = this._slotsForUrl.get(placesNode.uri);
    for (let slot of slotsForUrl) {
      if (slot.sessionDownload) {
        // The visible data doesn't change, so we don't have to notify views.
        slot.historyDownload = null;
      } else {
        this._removeSlot({ slot, slotsForUrl });
      }
    }
  }

  // nsINavHistoryResultObserver
  nodeIconChanged() {}
  nodeTitleChanged() {}
  nodeKeywordChanged() {}
  nodeDateAddedChanged() {}
  nodeLastModifiedChanged() {}
  nodeHistoryDetailsChanged() {}
  nodeTagsChanged() {}
  sortingChanged() {}
  nodeMoved() {}
  nodeURIChanged() {}
  batching() {}

  // DownloadList callback
  onDownloadAdded(download) {
    let url = download.source.url;
    let slotsForUrl = this._slotsForUrl.get(url) || new Set();

    // For every source URL, there can be at most one slot containing a history
    // download without an associated session download. If we find one, then we
    // can reuse it for the current session download, although we have to move
    // it together with the other session downloads.
    let slot = [...slotsForUrl][0];
    if (slot && !slot.sessionDownload) {
      // Remove the slot because we have to change its position.
      this._removeSlot({ slot, slotsForUrl });
    } else {
      slot = new DownloadSlot(this);
    }
    slot.sessionDownload = download;
    this._insertSlot({ slot, slotsForUrl, index: this._slots.length });
    this._slotForDownload.set(download, slot);
  }

  // DownloadList callback
  onDownloadChanged(download) {
    let slot = this._slotForDownload.get(download);
    this._notifyAllViews("onDownloadChanged", slot.download);
  }

  // DownloadList callback
  onDownloadRemoved(download) {
    let url = download.source.url;
    let slotsForUrl = this._slotsForUrl.get(url);
    let slot = this._slotForDownload.get(download);
    this._removeSlot({ slot, slotsForUrl });

    this._slotForDownload.delete(download);

    // If there was only one slot for this source URL and it also contained a
    // history download, we should resurrect it in the correct area of the list.
    if (slotsForUrl.size == 0 && slot.historyDownload) {
      // We have one download slot containing both a session download and a
      // history download, and we are now removing the session download.
      // Previously, we did not use the Places metadata because it was obscured
      // by the session download. Since this is no longer the case, we have to
      // read the latest metadata before resurrecting the history download.
      slot.historyDownload.updateFromMetaData(DownloadCache.get(url));
      slot.sessionDownload = null;
      // Place the resurrected history slot after all the session slots.
      this._insertSlot({
        slot,
        slotsForUrl,
        index: this._firstSessionSlotIndex,
      });
    }
  }

  // DownloadList
  add() {
    throw new Error("Not implemented.");
  }

  // DownloadList
  remove() {
    throw new Error("Not implemented.");
  }

  // DownloadList
  removeFinished() {
    throw new Error("Not implemented.");
  }
}