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