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
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5/**
6 * Provides access to downloads from previous sessions on platforms that store
7 * them in a different location than session downloads.
8 *
9 * This module works with objects that are compatible with Download, while using
10 * the Places interfaces internally. Some of the Places objects may also be
11 * exposed to allow the consumers to integrate with history view commands.
12 */
13
14"use strict";
15
16var EXPORTED_SYMBOLS = ["DownloadHistory"];
17
18const { DownloadList } = ChromeUtils.import(
19  "resource://gre/modules/DownloadList.jsm"
20);
21const { XPCOMUtils } = ChromeUtils.import(
22  "resource://gre/modules/XPCOMUtils.jsm"
23);
24
25XPCOMUtils.defineLazyModuleGetters(this, {
26  Downloads: "resource://gre/modules/Downloads.jsm",
27  FileUtils: "resource://gre/modules/FileUtils.jsm",
28  OS: "resource://gre/modules/osfile.jsm",
29  PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
30  Services: "resource://gre/modules/Services.jsm",
31});
32
33// Places query used to retrieve all history downloads for the related list.
34const HISTORY_PLACES_QUERY =
35  "place:transition=" +
36  Ci.nsINavHistoryService.TRANSITION_DOWNLOAD +
37  "&sort=" +
38  Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING;
39
40const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI";
41const METADATA_ANNO = "downloads/metaData";
42
43const METADATA_STATE_FINISHED = 1;
44const METADATA_STATE_FAILED = 2;
45const METADATA_STATE_CANCELED = 3;
46const METADATA_STATE_PAUSED = 4;
47const METADATA_STATE_BLOCKED_PARENTAL = 6;
48const METADATA_STATE_DIRTY = 8;
49
50/**
51 * Provides methods to retrieve downloads from previous sessions and store
52 * downloads for future sessions.
53 */
54var DownloadHistory = {
55  /**
56   * Retrieves the main DownloadHistoryList object which provides a unified view
57   * on downloads from both previous browsing sessions and this session.
58   *
59   * @param type
60   *        Determines which type of downloads from this session should be
61   *        included in the list. This is Downloads.PUBLIC by default, but can
62   *        also be Downloads.PRIVATE or Downloads.ALL.
63   * @param maxHistoryResults
64   *        Optional number that limits the amount of results the history query
65   *        may return.
66   *
67   * @return {Promise}
68   * @resolves The requested DownloadHistoryList object.
69   * @rejects JavaScript exception.
70   */
71  async getList({ type = Downloads.PUBLIC, maxHistoryResults } = {}) {
72    await DownloadCache.ensureInitialized();
73
74    let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`;
75    if (!this._listPromises[key]) {
76      this._listPromises[key] = Downloads.getList(type).then(list => {
77        // When the amount of history downloads is capped, we request the list in
78        // descending order, to make sure that the list can apply the limit.
79        let query =
80          HISTORY_PLACES_QUERY +
81          (maxHistoryResults ? "&maxResults=" + maxHistoryResults : "");
82        return new DownloadHistoryList(list, query);
83      });
84    }
85
86    return this._listPromises[key];
87  },
88
89  /**
90   * This object is populated with one key for each type of download list that
91   * can be returned by the getList method. The values are promises that resolve
92   * to DownloadHistoryList objects.
93   */
94  _listPromises: {},
95
96  async addDownloadToHistory(download) {
97    if (
98      download.source.isPrivate ||
99      !PlacesUtils.history.canAddURI(PlacesUtils.toURI(download.source.url))
100    ) {
101      return;
102    }
103
104    await DownloadCache.addDownload(download);
105
106    await this._updateHistoryListData(download.source.url);
107  },
108
109  /**
110   * Stores new detailed metadata for the given download in history. This is
111   * normally called after a download finishes, fails, or is canceled.
112   *
113   * Failed or canceled downloads with partial data are not stored as paused,
114   * because the information from the session download is required for resuming.
115   *
116   * @param download
117   *        Download object whose metadata should be updated. If the object
118   *        represents a private download, the call has no effect.
119   */
120  async updateMetaData(download) {
121    if (download.source.isPrivate || !download.stopped) {
122      return;
123    }
124
125    let state = METADATA_STATE_CANCELED;
126    if (download.succeeded) {
127      state = METADATA_STATE_FINISHED;
128    } else if (download.error) {
129      if (download.error.becauseBlockedByParentalControls) {
130        state = METADATA_STATE_BLOCKED_PARENTAL;
131      } else if (download.error.becauseBlockedByReputationCheck) {
132        state = METADATA_STATE_DIRTY;
133      } else {
134        state = METADATA_STATE_FAILED;
135      }
136    }
137
138    let metaData = { state, endTime: download.endTime };
139    if (download.succeeded) {
140      metaData.fileSize = download.target.size;
141    }
142
143    // The verdict may still be present even if the download succeeded.
144    if (download.error && download.error.reputationCheckVerdict) {
145      metaData.reputationCheckVerdict = download.error.reputationCheckVerdict;
146    }
147
148    // This should be executed before any async parts, to ensure the cache is
149    // updated before any notifications are activated.
150    await DownloadCache.setMetadata(download.source.url, metaData);
151
152    await this._updateHistoryListData(download.source.url);
153  },
154
155  async _updateHistoryListData(sourceUrl) {
156    for (let key of Object.getOwnPropertyNames(this._listPromises)) {
157      let downloadHistoryList = await this._listPromises[key];
158      downloadHistoryList.updateForMetaDataChange(
159        sourceUrl,
160        DownloadCache.get(sourceUrl)
161      );
162    }
163  },
164};
165
166/**
167 * This cache exists:
168 * - in order to optimize the load of DownloadsHistoryList, when Places
169 *   annotations for history downloads must be read. In fact, annotations are
170 *   stored in a single table, and reading all of them at once is much more
171 *   efficient than an individual query.
172 * - to avoid needing to do asynchronous reading of the database during download
173 *   list updates, which are designed to be synchronous (to improve UI
174 *   responsiveness).
175 *
176 * The cache is initialized the first time DownloadHistory.getList is called, or
177 * when data is added.
178 */
179var DownloadCache = {
180  _data: new Map(),
181  _initializePromise: null,
182
183  /**
184   * Initializes the cache, loading the data from the places database.
185   *
186   * @return {Promise} Returns a promise that is resolved once the
187   *                   initialization is complete.
188   */
189  ensureInitialized() {
190    if (this._initializePromise) {
191      return this._initializePromise;
192    }
193    this._initializePromise = (async () => {
194      PlacesUtils.history.addObserver(this, true);
195
196      let pageAnnos = await PlacesUtils.history.fetchAnnotatedPages([
197        METADATA_ANNO,
198        DESTINATIONFILEURI_ANNO,
199      ]);
200
201      let metaDataPages = pageAnnos.get(METADATA_ANNO);
202      if (metaDataPages) {
203        for (let { uri, content } of metaDataPages) {
204          try {
205            this._data.set(uri.href, JSON.parse(content));
206          } catch (ex) {
207            // Do nothing - JSON.parse could throw.
208          }
209        }
210      }
211
212      let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO);
213      if (destinationFilePages) {
214        for (let { uri, content } of destinationFilePages) {
215          let newData = this.get(uri.href);
216          newData.targetFileSpec = content;
217          this._data.set(uri.href, newData);
218        }
219      }
220    })();
221
222    return this._initializePromise;
223  },
224
225  /**
226   * This returns an object containing the meta data for the supplied URL.
227   *
228   * @param {String} url The url to get the meta data for.
229   * @return {Object|null} Returns an empty object if there is no meta data found, or
230   *                       an object containing the meta data. The meta data
231   *                       will look like:
232   *
233   * { targetFileSpec, state, endTime, fileSize, ... }
234   *
235   * The targetFileSpec property is the value of "downloads/destinationFileURI",
236   * while the other properties are taken from "downloads/metaData". Any of the
237   * properties may be missing from the object.
238   */
239  get(url) {
240    return this._data.get(url) || {};
241  },
242
243  /**
244   * Adds a download to the cache and the places database.
245   *
246   * @param {Download} download The download to add to the database and cache.
247   */
248  async addDownload(download) {
249    await this.ensureInitialized();
250
251    let targetFile = new FileUtils.File(download.target.path);
252    let targetUri = Services.io.newFileURI(targetFile);
253
254    // This should be executed before any async parts, to ensure the cache is
255    // updated before any notifications are activated.
256    // Note: this intentionally overwrites any metadata as this is
257    // the start of a new download.
258    this._data.set(download.source.url, { targetFileSpec: targetUri.spec });
259
260    let originalPageInfo = await PlacesUtils.history.fetch(download.source.url);
261
262    let pageInfo = await PlacesUtils.history.insert({
263      url: download.source.url,
264      // In case we are downloading a file that does not correspond to a web
265      // page for which the title is present, we populate the otherwise empty
266      // history title with the name of the destination file, to allow it to be
267      // visible and searchable in history results.
268      title:
269        (originalPageInfo && originalPageInfo.title) || targetFile.leafName,
270      visits: [
271        {
272          // The start time is always available when we reach this point.
273          date: download.startTime,
274          transition: PlacesUtils.history.TRANSITIONS.DOWNLOAD,
275          referrer: download.source.referrerInfo
276            ? download.source.referrerInfo.originalReferrer
277            : null,
278        },
279      ],
280    });
281
282    await PlacesUtils.history.update({
283      annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]),
284      // XXX Bug 1479445: We shouldn't have to supply both guid and url here,
285      // but currently we do.
286      guid: pageInfo.guid,
287      url: pageInfo.url,
288    });
289  },
290
291  /**
292   * Sets the metadata for a given url. If the cache already contains meta data
293   * for the given url, it will be overwritten (note: the targetFileSpec will be
294   * maintained).
295   *
296   * @param {String} url The url to set the meta data for.
297   * @param {Object} metadata The new metaData to save in the cache.
298   */
299  async setMetadata(url, metadata) {
300    await this.ensureInitialized();
301
302    // This should be executed before any async parts, to ensure the cache is
303    // updated before any notifications are activated.
304    let existingData = this.get(url);
305    let newData = { ...metadata };
306    if ("targetFileSpec" in existingData) {
307      newData.targetFileSpec = existingData.targetFileSpec;
308    }
309    this._data.set(url, newData);
310
311    try {
312      await PlacesUtils.history.update({
313        annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]),
314        url,
315      });
316    } catch (ex) {
317      Cu.reportError(ex);
318    }
319  },
320
321  QueryInterface: ChromeUtils.generateQI([
322    Ci.nsINavHistoryObserver,
323    Ci.nsISupportsWeakReference,
324  ]),
325
326  // nsINavHistoryObserver
327  onDeleteURI(uri) {
328    this._data.delete(uri.spec);
329  },
330  onClearHistory() {
331    this._data.clear();
332  },
333  onBeginUpdateBatch() {},
334  onEndUpdateBatch() {},
335  onTitleChanged() {},
336  onFrecencyChanged() {},
337  onManyFrecenciesChanged() {},
338  onPageChanged() {},
339  onDeleteVisits() {},
340};
341
342/**
343 * Represents a download from the browser history. This object implements part
344 * of the interface of the Download object.
345 *
346 * While Download objects are shared between the public DownloadList and all the
347 * DownloadHistoryList instances, multiple HistoryDownload objects referring to
348 * the same item can be created for different DownloadHistoryList instances.
349 *
350 * @param placesNode
351 *        The Places node from which the history download should be initialized.
352 */
353function HistoryDownload(placesNode) {
354  this.placesNode = placesNode;
355
356  // History downloads should get the referrer from Places (bug 829201).
357  this.source = {
358    url: placesNode.uri,
359    isPrivate: false,
360  };
361  this.target = {
362    path: undefined,
363    exists: false,
364    size: undefined,
365  };
366
367  // In case this download cannot obtain its end time from the Places metadata,
368  // use the time from the Places node, that is the start time of the download.
369  this.endTime = placesNode.time / 1000;
370}
371
372HistoryDownload.prototype = {
373  /**
374   * DownloadSlot containing this history download.
375   */
376  slot: null,
377
378  /**
379   * Pushes information from Places metadata into this object.
380   */
381  updateFromMetaData(metaData) {
382    try {
383      this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
384        .getService(Ci.nsIFileProtocolHandler)
385        .getFileFromURLSpec(metaData.targetFileSpec).path;
386    } catch (ex) {
387      this.target.path = undefined;
388    }
389
390    if ("state" in metaData) {
391      this.succeeded = metaData.state == METADATA_STATE_FINISHED;
392      this.canceled =
393        metaData.state == METADATA_STATE_CANCELED ||
394        metaData.state == METADATA_STATE_PAUSED;
395      this.endTime = metaData.endTime;
396
397      // Recreate partial error information from the state saved in history.
398      if (metaData.state == METADATA_STATE_FAILED) {
399        this.error = { message: "History download failed." };
400      } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) {
401        this.error = { becauseBlockedByParentalControls: true };
402      } else if (metaData.state == METADATA_STATE_DIRTY) {
403        this.error = {
404          becauseBlockedByReputationCheck: true,
405          reputationCheckVerdict: metaData.reputationCheckVerdict || "",
406        };
407      } else {
408        this.error = null;
409      }
410
411      // Normal history downloads are assumed to exist until the user interface
412      // is refreshed, at which point these values may be updated.
413      this.target.exists = true;
414      this.target.size = metaData.fileSize;
415    } else {
416      // Metadata might be missing from a download that has started but hasn't
417      // stopped already. Normally, this state is overridden with the one from
418      // the corresponding in-progress session download. But if the browser is
419      // terminated abruptly and additionally the file with information about
420      // in-progress downloads is lost, we may end up using this state. We use
421      // the failed state to allow the download to be restarted.
422      //
423      // On the other hand, if the download is missing the target file
424      // annotation as well, it is just a very old one, and we can assume it
425      // succeeded.
426      this.succeeded = !this.target.path;
427      this.error = this.target.path ? { message: "Unstarted download." } : null;
428      this.canceled = false;
429
430      // These properties may be updated if the user interface is refreshed.
431      this.target.exists = false;
432      this.target.size = undefined;
433    }
434  },
435
436  /**
437   * History downloads are never in progress.
438   */
439  stopped: true,
440
441  /**
442   * No percentage indication is shown for history downloads.
443   */
444  hasProgress: false,
445
446  /**
447   * History downloads cannot be restarted using their partial data, even if
448   * they are indicated as paused in their Places metadata. The only way is to
449   * use the information from a persisted session download, that will be shown
450   * instead of the history download. In case this session download is not
451   * available, we show the history download as canceled, not paused.
452   */
453  hasPartialData: false,
454
455  /**
456   * This method may be called when deleting a history download.
457   */
458  async finalize() {},
459
460  /**
461   * This method mimicks the "refresh" method of session downloads.
462   */
463  async refresh() {
464    try {
465      this.target.size = (await OS.File.stat(this.target.path)).size;
466      this.target.exists = true;
467    } catch (ex) {
468      // We keep the known file size from the metadata, if any.
469      this.target.exists = false;
470    }
471
472    this.slot.list._notifyAllViews("onDownloadChanged", this);
473  },
474};
475
476/**
477 * Represents one item in the list of public session and history downloads.
478 *
479 * The object may contain a session download, a history download, or both. When
480 * both a history and a session download are present, the session download gets
481 * priority and its information is accessed.
482 *
483 * @param list
484 *        The DownloadHistoryList that owns this DownloadSlot object.
485 */
486function DownloadSlot(list) {
487  this.list = list;
488}
489
490DownloadSlot.prototype = {
491  list: null,
492
493  /**
494   * Download object representing the session download contained in this slot.
495   */
496  sessionDownload: null,
497
498  /**
499   * HistoryDownload object contained in this slot.
500   */
501  get historyDownload() {
502    return this._historyDownload;
503  },
504  set historyDownload(historyDownload) {
505    this._historyDownload = historyDownload;
506    if (historyDownload) {
507      historyDownload.slot = this;
508    }
509  },
510  _historyDownload: null,
511
512  /**
513   * Returns the Download or HistoryDownload object for displaying information
514   * and executing commands in the user interface.
515   */
516  get download() {
517    return this.sessionDownload || this.historyDownload;
518  },
519};
520
521/**
522 * Represents an ordered collection of DownloadSlot objects containing a merged
523 * view on session downloads and history downloads. Views on this list will
524 * receive notifications for changes to both types of downloads.
525 *
526 * Downloads in this list are sorted from oldest to newest, with all session
527 * downloads after all the history downloads. When a new history download is
528 * added and the list also contains session downloads, the insertBefore option
529 * of the onDownloadAdded notification refers to the first session download.
530 *
531 * The list of downloads cannot be modified using the DownloadList methods.
532 *
533 * @param publicList
534 *        Underlying DownloadList containing public downloads.
535 * @param place
536 *        Places query used to retrieve history downloads.
537 */
538var DownloadHistoryList = function(publicList, place) {
539  DownloadList.call(this);
540
541  // While "this._slots" contains all the data in order, the other properties
542  // provide fast access for the most common operations.
543  this._slots = [];
544  this._slotsForUrl = new Map();
545  this._slotForDownload = new WeakMap();
546
547  // Start the asynchronous queries to retrieve history and session downloads.
548  publicList.addView(this).catch(Cu.reportError);
549  let query = {},
550    options = {};
551  PlacesUtils.history.queryStringToQuery(place, query, options);
552
553  // NB: The addObserver call sets our nsINavHistoryResultObserver.result.
554  let result = PlacesUtils.history.executeQuery(query.value, options.value);
555  result.addObserver(this);
556
557  // Our history result observer is long lived for fast shared views, so free
558  // the reference on shutdown to prevent leaks.
559  Services.obs.addObserver(() => {
560    this.result = null;
561  }, "quit-application-granted");
562};
563
564DownloadHistoryList.prototype = {
565  __proto__: DownloadList.prototype,
566
567  /**
568   * This is set when executing the Places query.
569   */
570  get result() {
571    return this._result;
572  },
573  set result(result) {
574    if (this._result == result) {
575      return;
576    }
577
578    if (this._result) {
579      this._result.removeObserver(this);
580      this._result.root.containerOpen = false;
581    }
582
583    this._result = result;
584
585    if (this._result) {
586      this._result.root.containerOpen = true;
587    }
588  },
589  _result: null,
590
591  /**
592   * Updates the download history item when the meta data or destination file
593   * changes.
594   *
595   * @param {String} sourceUrl The sourceUrl which was updated.
596   * @param {Object} metaData The new meta data for the sourceUrl.
597   */
598  updateForMetaDataChange(sourceUrl, metaData) {
599    let slotsForUrl = this._slotsForUrl.get(sourceUrl);
600    if (!slotsForUrl) {
601      return;
602    }
603
604    for (let slot of slotsForUrl) {
605      if (slot.sessionDownload) {
606        // The visible data doesn't change, so we don't have to notify views.
607        return;
608      }
609      slot.historyDownload.updateFromMetaData(metaData);
610      this._notifyAllViews("onDownloadChanged", slot.download);
611    }
612  },
613
614  /**
615   * Index of the first slot that contains a session download. This is equal to
616   * the length of the list when there are no session downloads.
617   */
618  _firstSessionSlotIndex: 0,
619
620  _insertSlot({ slot, index, slotsForUrl }) {
621    // Add the slot to the ordered array.
622    this._slots.splice(index, 0, slot);
623    this._downloads.splice(index, 0, slot.download);
624    if (!slot.sessionDownload) {
625      this._firstSessionSlotIndex++;
626    }
627
628    // Add the slot to the fast access maps.
629    slotsForUrl.add(slot);
630    this._slotsForUrl.set(slot.download.source.url, slotsForUrl);
631
632    // Add the associated view items.
633    this._notifyAllViews("onDownloadAdded", slot.download, {
634      insertBefore: this._downloads[index + 1],
635    });
636  },
637
638  _removeSlot({ slot, slotsForUrl }) {
639    // Remove the slot from the ordered array.
640    let index = this._slots.indexOf(slot);
641    this._slots.splice(index, 1);
642    this._downloads.splice(index, 1);
643    if (this._firstSessionSlotIndex > index) {
644      this._firstSessionSlotIndex--;
645    }
646
647    // Remove the slot from the fast access maps.
648    slotsForUrl.delete(slot);
649    if (slotsForUrl.size == 0) {
650      this._slotsForUrl.delete(slot.download.source.url);
651    }
652
653    // Remove the associated view items.
654    this._notifyAllViews("onDownloadRemoved", slot.download);
655  },
656
657  /**
658   * Ensures that the information about a history download is stored in at least
659   * one slot, adding a new one at the end of the list if necessary.
660   *
661   * A reference to the same Places node will be stored in the HistoryDownload
662   * object for all the DownloadSlot objects associated with the source URL.
663   *
664   * @param placesNode
665   *        The Places node that represents the history download.
666   */
667  _insertPlacesNode(placesNode) {
668    let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set();
669
670    // If there are existing slots associated with this URL, we only have to
671    // ensure that the Places node reference is kept updated in case the more
672    // recent Places notification contained a different node object.
673    if (slotsForUrl.size > 0) {
674      for (let slot of slotsForUrl) {
675        if (!slot.historyDownload) {
676          slot.historyDownload = new HistoryDownload(placesNode);
677        } else {
678          slot.historyDownload.placesNode = placesNode;
679        }
680      }
681      return;
682    }
683
684    // If there are no existing slots for this URL, we have to create a new one.
685    // Since the history download is visible in the slot, we also have to update
686    // the object using the Places metadata.
687    let historyDownload = new HistoryDownload(placesNode);
688    historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri));
689    let slot = new DownloadSlot(this);
690    slot.historyDownload = historyDownload;
691    this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex });
692  },
693
694  // nsINavHistoryResultObserver
695  containerStateChanged(node, oldState, newState) {
696    this.invalidateContainer(node);
697  },
698
699  // nsINavHistoryResultObserver
700  invalidateContainer(container) {
701    this._notifyAllViews("onDownloadBatchStarting");
702
703    // Remove all the current slots containing only history downloads.
704    for (let index = this._slots.length - 1; index >= 0; index--) {
705      let slot = this._slots[index];
706      if (slot.sessionDownload) {
707        // The visible data doesn't change, so we don't have to notify views.
708        slot.historyDownload = null;
709      } else {
710        let slotsForUrl = this._slotsForUrl.get(slot.download.source.url);
711        this._removeSlot({ slot, slotsForUrl });
712      }
713    }
714
715    // Add new slots or reuse existing ones for history downloads.
716    for (let index = container.childCount - 1; index >= 0; --index) {
717      try {
718        this._insertPlacesNode(container.getChild(index));
719      } catch (ex) {
720        Cu.reportError(ex);
721      }
722    }
723
724    this._notifyAllViews("onDownloadBatchEnded");
725  },
726
727  // nsINavHistoryResultObserver
728  nodeInserted(parent, placesNode) {
729    this._insertPlacesNode(placesNode);
730  },
731
732  // nsINavHistoryResultObserver
733  nodeRemoved(parent, placesNode, aOldIndex) {
734    let slotsForUrl = this._slotsForUrl.get(placesNode.uri);
735    for (let slot of slotsForUrl) {
736      if (slot.sessionDownload) {
737        // The visible data doesn't change, so we don't have to notify views.
738        slot.historyDownload = null;
739      } else {
740        this._removeSlot({ slot, slotsForUrl });
741      }
742    }
743  },
744
745  // nsINavHistoryResultObserver
746  nodeIconChanged() {},
747  nodeTitleChanged() {},
748  nodeKeywordChanged() {},
749  nodeDateAddedChanged() {},
750  nodeLastModifiedChanged() {},
751  nodeHistoryDetailsChanged() {},
752  nodeTagsChanged() {},
753  sortingChanged() {},
754  nodeMoved() {},
755  nodeURIChanged() {},
756  batching() {},
757
758  // DownloadList callback
759  onDownloadAdded(download) {
760    let url = download.source.url;
761    let slotsForUrl = this._slotsForUrl.get(url) || new Set();
762
763    // For every source URL, there can be at most one slot containing a history
764    // download without an associated session download. If we find one, then we
765    // can reuse it for the current session download, although we have to move
766    // it together with the other session downloads.
767    let slot = [...slotsForUrl][0];
768    if (slot && !slot.sessionDownload) {
769      // Remove the slot because we have to change its position.
770      this._removeSlot({ slot, slotsForUrl });
771    } else {
772      slot = new DownloadSlot(this);
773    }
774    slot.sessionDownload = download;
775    this._insertSlot({ slot, slotsForUrl, index: this._slots.length });
776    this._slotForDownload.set(download, slot);
777  },
778
779  // DownloadList callback
780  onDownloadChanged(download) {
781    let slot = this._slotForDownload.get(download);
782    this._notifyAllViews("onDownloadChanged", slot.download);
783  },
784
785  // DownloadList callback
786  onDownloadRemoved(download) {
787    let url = download.source.url;
788    let slotsForUrl = this._slotsForUrl.get(url);
789    let slot = this._slotForDownload.get(download);
790    this._removeSlot({ slot, slotsForUrl });
791
792    this._slotForDownload.delete(download);
793
794    // If there was only one slot for this source URL and it also contained a
795    // history download, we should resurrect it in the correct area of the list.
796    if (slotsForUrl.size == 0 && slot.historyDownload) {
797      // We have one download slot containing both a session download and a
798      // history download, and we are now removing the session download.
799      // Previously, we did not use the Places metadata because it was obscured
800      // by the session download. Since this is no longer the case, we have to
801      // read the latest metadata before resurrecting the history download.
802      slot.historyDownload.updateFromMetaData(DownloadCache.get(url));
803      slot.sessionDownload = null;
804      // Place the resurrected history slot after all the session slots.
805      this._insertSlot({
806        slot,
807        slotsForUrl,
808        index: this._firstSessionSlotIndex,
809      });
810    }
811  },
812
813  // DownloadList
814  add() {
815    throw new Error("Not implemented.");
816  },
817
818  // DownloadList
819  remove() {
820    throw new Error("Not implemented.");
821  },
822
823  // DownloadList
824  removeFinished() {
825    throw new Error("Not implemented.");
826  },
827};
828