1/* vim: sw=2 ts=2 sts=2 expandtab filetype=javascript
2 * This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6"use strict";
7
8var EXPORTED_SYMBOLS = ["DownloadUtils"];
9
10/**
11 * This module provides the DownloadUtils object which contains useful methods
12 * for downloads such as displaying file sizes, transfer times, and download
13 * locations.
14 *
15 * List of methods:
16 *
17 * [string status, double newLast]
18 * getDownloadStatus(int aCurrBytes, [optional] int aMaxBytes,
19 *                   [optional] double aSpeed, [optional] double aLastSec)
20 *
21 * string progress
22 * getTransferTotal(int aCurrBytes, [optional] int aMaxBytes)
23 *
24 * [string timeLeft, double newLast]
25 * getTimeLeft(double aSeconds, [optional] double aLastSec)
26 *
27 * [string dateCompact, string dateComplete]
28 * getReadableDates(Date aDate, [optional] Date aNow)
29 *
30 * [string displayHost, string fullHost]
31 * getURIHost(string aURIString)
32 *
33 * [string convertedBytes, string units]
34 * convertByteUnits(int aBytes)
35 *
36 * [int time, string units, int subTime, string subUnits]
37 * convertTimeUnits(double aSecs)
38 */
39
40const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
41
42ChromeUtils.defineModuleGetter(
43  this,
44  "PluralForm",
45  "resource://gre/modules/PluralForm.jsm"
46);
47
48const MS_PER_DAY = 24 * 60 * 60 * 1000;
49
50var localeNumberFormatCache = new Map();
51function getLocaleNumberFormat(fractionDigits) {
52  if (!localeNumberFormatCache.has(fractionDigits)) {
53    localeNumberFormatCache.set(
54      fractionDigits,
55      new Services.intl.NumberFormat(undefined, {
56        maximumFractionDigits: fractionDigits,
57        minimumFractionDigits: fractionDigits,
58      })
59    );
60  }
61  return localeNumberFormatCache.get(fractionDigits);
62}
63
64const kDownloadProperties =
65  "chrome://mozapps/locale/downloads/downloads.properties";
66
67var gStr = {
68  statusFormat: "statusFormat3",
69  statusFormatInfiniteRate: "statusFormatInfiniteRate",
70  statusFormatNoRate: "statusFormatNoRate",
71  transferSameUnits: "transferSameUnits2",
72  transferDiffUnits: "transferDiffUnits2",
73  transferNoTotal: "transferNoTotal2",
74  timePair: "timePair3",
75  timeLeftSingle: "timeLeftSingle3",
76  timeLeftDouble: "timeLeftDouble3",
77  timeFewSeconds: "timeFewSeconds2",
78  timeUnknown: "timeUnknown2",
79  yesterday: "yesterday",
80  doneScheme: "doneScheme2",
81  doneFileScheme: "doneFileScheme",
82  units: ["bytes", "kilobyte", "megabyte", "gigabyte"],
83  // Update timeSize in convertTimeUnits if changing the length of this array
84  timeUnits: ["shortSeconds", "shortMinutes", "shortHours", "shortDays"],
85  infiniteRate: "infiniteRate",
86};
87
88// This lazily initializes the string bundle upon first use.
89Object.defineProperty(this, "gBundle", {
90  configurable: true,
91  enumerable: true,
92  get() {
93    delete this.gBundle;
94    return (this.gBundle = Services.strings.createBundle(kDownloadProperties));
95  },
96});
97
98// Keep track of at most this many second/lastSec pairs so that multiple calls
99// to getTimeLeft produce the same time left
100const kCachedLastMaxSize = 10;
101var gCachedLast = [];
102
103var DownloadUtils = {
104  /**
105   * Generate a full status string for a download given its current progress,
106   * total size, speed, last time remaining
107   *
108   * @param aCurrBytes
109   *        Number of bytes transferred so far
110   * @param [optional] aMaxBytes
111   *        Total number of bytes or -1 for unknown
112   * @param [optional] aSpeed
113   *        Current transfer rate in bytes/sec or -1 for unknown
114   * @param [optional] aLastSec
115   *        Last time remaining in seconds or Infinity for unknown
116   * @return A pair: [download status text, new value of "last seconds"]
117   */
118  getDownloadStatus: function DU_getDownloadStatus(
119    aCurrBytes,
120    aMaxBytes,
121    aSpeed,
122    aLastSec
123  ) {
124    let [
125      transfer,
126      timeLeft,
127      newLast,
128      normalizedSpeed,
129    ] = this._deriveTransferRate(aCurrBytes, aMaxBytes, aSpeed, aLastSec);
130
131    let [rate, unit] = DownloadUtils.convertByteUnits(normalizedSpeed);
132
133    let status;
134    if (rate === "Infinity") {
135      // Infinity download speed doesn't make sense. Show a localized phrase instead.
136      let params = [
137        transfer,
138        gBundle.GetStringFromName(gStr.infiniteRate),
139        timeLeft,
140      ];
141      status = gBundle.formatStringFromName(
142        gStr.statusFormatInfiniteRate,
143        params
144      );
145    } else {
146      let params = [transfer, rate, unit, timeLeft];
147      status = gBundle.formatStringFromName(gStr.statusFormat, params);
148    }
149    return [status, newLast];
150  },
151
152  /**
153   * Generate a status string for a download given its current progress,
154   * total size, speed, last time remaining. The status string contains the
155   * time remaining, as well as the total bytes downloaded. Unlike
156   * getDownloadStatus, it does not include the rate of download.
157   *
158   * @param aCurrBytes
159   *        Number of bytes transferred so far
160   * @param [optional] aMaxBytes
161   *        Total number of bytes or -1 for unknown
162   * @param [optional] aSpeed
163   *        Current transfer rate in bytes/sec or -1 for unknown
164   * @param [optional] aLastSec
165   *        Last time remaining in seconds or Infinity for unknown
166   * @return A pair: [download status text, new value of "last seconds"]
167   */
168  getDownloadStatusNoRate: function DU_getDownloadStatusNoRate(
169    aCurrBytes,
170    aMaxBytes,
171    aSpeed,
172    aLastSec
173  ) {
174    let [transfer, timeLeft, newLast] = this._deriveTransferRate(
175      aCurrBytes,
176      aMaxBytes,
177      aSpeed,
178      aLastSec
179    );
180
181    let params = [transfer, timeLeft];
182    let status = gBundle.formatStringFromName(gStr.statusFormatNoRate, params);
183    return [status, newLast];
184  },
185
186  /**
187   * Helper function that returns a transfer string, a time remaining string,
188   * and a new value of "last seconds".
189   * @param aCurrBytes
190   *        Number of bytes transferred so far
191   * @param [optional] aMaxBytes
192   *        Total number of bytes or -1 for unknown
193   * @param [optional] aSpeed
194   *        Current transfer rate in bytes/sec or -1 for unknown
195   * @param [optional] aLastSec
196   *        Last time remaining in seconds or Infinity for unknown
197   * @return A triple: [amount transferred string, time remaining string,
198   *                    new value of "last seconds"]
199   */
200  _deriveTransferRate: function DU__deriveTransferRate(
201    aCurrBytes,
202    aMaxBytes,
203    aSpeed,
204    aLastSec
205  ) {
206    if (aMaxBytes == null) {
207      aMaxBytes = -1;
208    }
209    if (aSpeed == null) {
210      aSpeed = -1;
211    }
212    if (aLastSec == null) {
213      aLastSec = Infinity;
214    }
215
216    // Calculate the time remaining if we have valid values
217    let seconds =
218      aSpeed > 0 && aMaxBytes > 0 ? (aMaxBytes - aCurrBytes) / aSpeed : -1;
219
220    let transfer = DownloadUtils.getTransferTotal(aCurrBytes, aMaxBytes);
221    let [timeLeft, newLast] = DownloadUtils.getTimeLeft(seconds, aLastSec);
222    return [transfer, timeLeft, newLast, aSpeed];
223  },
224
225  /**
226   * Generate the transfer progress string to show the current and total byte
227   * size. Byte units will be as large as possible and the same units for
228   * current and max will be suppressed for the former.
229   *
230   * @param aCurrBytes
231   *        Number of bytes transferred so far
232   * @param [optional] aMaxBytes
233   *        Total number of bytes or -1 for unknown
234   * @return The transfer progress text
235   */
236  getTransferTotal: function DU_getTransferTotal(aCurrBytes, aMaxBytes) {
237    if (aMaxBytes == null) {
238      aMaxBytes = -1;
239    }
240
241    let [progress, progressUnits] = DownloadUtils.convertByteUnits(aCurrBytes);
242    let [total, totalUnits] = DownloadUtils.convertByteUnits(aMaxBytes);
243
244    // Figure out which byte progress string to display
245    let name, values;
246    if (aMaxBytes < 0) {
247      name = gStr.transferNoTotal;
248      values = [progress, progressUnits];
249    } else if (progressUnits == totalUnits) {
250      name = gStr.transferSameUnits;
251      values = [progress, total, totalUnits];
252    } else {
253      name = gStr.transferDiffUnits;
254      values = [progress, progressUnits, total, totalUnits];
255    }
256
257    return gBundle.formatStringFromName(name, values);
258  },
259
260  /**
261   * Generate a "time left" string given an estimate on the time left and the
262   * last time. The extra time is used to give a better estimate on the time to
263   * show. Both the time values are doubles instead of integers to help get
264   * sub-second accuracy for current and future estimates.
265   *
266   * @param aSeconds
267   *        Current estimate on number of seconds left for the download
268   * @param [optional] aLastSec
269   *        Last time remaining in seconds or Infinity for unknown
270   * @return A pair: [time left text, new value of "last seconds"]
271   */
272  getTimeLeft: function DU_getTimeLeft(aSeconds, aLastSec) {
273    let nf = new Services.intl.NumberFormat();
274    if (aLastSec == null) {
275      aLastSec = Infinity;
276    }
277
278    if (aSeconds < 0) {
279      return [gBundle.GetStringFromName(gStr.timeUnknown), aLastSec];
280    }
281
282    // Try to find a cached lastSec for the given second
283    aLastSec = gCachedLast.reduce(
284      (aResult, aItem) => (aItem[0] == aSeconds ? aItem[1] : aResult),
285      aLastSec
286    );
287
288    // Add the current second/lastSec pair unless we have too many
289    gCachedLast.push([aSeconds, aLastSec]);
290    if (gCachedLast.length > kCachedLastMaxSize) {
291      gCachedLast.shift();
292    }
293
294    // Apply smoothing only if the new time isn't a huge change -- e.g., if the
295    // new time is more than half the previous time; this is useful for
296    // downloads that start/resume slowly
297    if (aSeconds > aLastSec / 2) {
298      // Apply hysteresis to favor downward over upward swings
299      // 30% of down and 10% of up (exponential smoothing)
300      let diff = aSeconds - aLastSec;
301      aSeconds = aLastSec + (diff < 0 ? 0.3 : 0.1) * diff;
302
303      // If the new time is similar, reuse something close to the last seconds,
304      // but subtract a little to provide forward progress
305      let diffPct = (diff / aLastSec) * 100;
306      if (Math.abs(diff) < 5 || Math.abs(diffPct) < 5) {
307        aSeconds = aLastSec - (diff < 0 ? 0.4 : 0.2);
308      }
309    }
310
311    // Decide what text to show for the time
312    let timeLeft;
313    if (aSeconds < 4) {
314      // Be friendly in the last few seconds
315      timeLeft = gBundle.GetStringFromName(gStr.timeFewSeconds);
316    } else {
317      // Convert the seconds into its two largest units to display
318      let [time1, unit1, time2, unit2] = DownloadUtils.convertTimeUnits(
319        aSeconds
320      );
321
322      let pair1 = gBundle.formatStringFromName(gStr.timePair, [
323        nf.format(time1),
324        unit1,
325      ]);
326      let pair2 = gBundle.formatStringFromName(gStr.timePair, [
327        nf.format(time2),
328        unit2,
329      ]);
330
331      // Only show minutes for under 1 hour unless there's a few minutes left;
332      // or the second pair is 0.
333      if ((aSeconds < 3600 && time1 >= 4) || time2 == 0) {
334        timeLeft = gBundle.formatStringFromName(gStr.timeLeftSingle, [pair1]);
335      } else {
336        // We've got 2 pairs of times to display
337        timeLeft = gBundle.formatStringFromName(gStr.timeLeftDouble, [
338          pair1,
339          pair2,
340        ]);
341      }
342    }
343
344    return [timeLeft, aSeconds];
345  },
346
347  /**
348   * Converts a Date object to two readable formats, one compact, one complete.
349   * The compact format is relative to the current date, and is not an accurate
350   * representation. For example, only the time is displayed for today. The
351   * complete format always includes both the date and the time, excluding the
352   * seconds, and is often shown when hovering the cursor over the compact
353   * representation.
354   *
355   * @param aDate
356   *        Date object representing the date and time to format. It is assumed
357   *        that this value represents a past date.
358   * @param [optional] aNow
359   *        Date object representing the current date and time. The real date
360   *        and time of invocation is used if this parameter is omitted.
361   * @return A pair: [compact text, complete text]
362   */
363  getReadableDates: function DU_getReadableDates(aDate, aNow) {
364    if (!aNow) {
365      aNow = new Date();
366    }
367
368    // Figure out when today begins
369    let today = new Date(aNow.getFullYear(), aNow.getMonth(), aNow.getDate());
370
371    let dateTimeCompact;
372    let dateTimeFull;
373
374    // Figure out if the time is from today, yesterday, this week, etc.
375    if (aDate >= today) {
376      let dts = new Services.intl.DateTimeFormat(undefined, {
377        timeStyle: "short",
378      });
379      dateTimeCompact = dts.format(aDate);
380    } else if (today - aDate < MS_PER_DAY) {
381      // After yesterday started, show yesterday
382      dateTimeCompact = gBundle.GetStringFromName(gStr.yesterday);
383    } else if (today - aDate < 6 * MS_PER_DAY) {
384      // After last week started, show day of week
385      dateTimeCompact = aDate.toLocaleDateString(undefined, {
386        weekday: "long",
387      });
388    } else {
389      // Show month/day
390      dateTimeCompact = aDate.toLocaleString(undefined, {
391        month: "long",
392        day: "numeric",
393      });
394    }
395
396    const dtOptions = { dateStyle: "long", timeStyle: "short" };
397    dateTimeFull = new Services.intl.DateTimeFormat(
398      undefined,
399      dtOptions
400    ).format(aDate);
401
402    return [dateTimeCompact, dateTimeFull];
403  },
404
405  /**
406   * Get the appropriate display host string for a URI string depending on if
407   * the URI has an eTLD + 1, is an IP address, a local file, or other protocol
408   *
409   * @param aURIString
410   *        The URI string to try getting an eTLD + 1, etc.
411   * @return A pair: [display host for the URI string, full host name]
412   */
413  getURIHost: function DU_getURIHost(aURIString) {
414    let idnService = Cc["@mozilla.org/network/idn-service;1"].getService(
415      Ci.nsIIDNService
416    );
417
418    // Get a URI that knows about its components
419    let uri;
420    try {
421      uri = Services.io.newURI(aURIString);
422    } catch (ex) {
423      return ["", ""];
424    }
425
426    // Get the inner-most uri for schemes like jar:
427    if (uri instanceof Ci.nsINestedURI) {
428      uri = uri.innermostURI;
429    }
430
431    let fullHost;
432    try {
433      // Get the full host name; some special URIs fail (data: jar:)
434      fullHost = uri.host;
435    } catch (e) {
436      fullHost = "";
437    }
438
439    let displayHost;
440    try {
441      // This might fail if it's an IP address or doesn't have more than 1 part
442      let baseDomain = Services.eTLD.getBaseDomain(uri);
443
444      // Convert base domain for display; ignore the isAscii out param
445      displayHost = idnService.convertToDisplayIDN(baseDomain, {});
446    } catch (e) {
447      // Default to the host name
448      displayHost = fullHost;
449    }
450
451    // Check if we need to show something else for the host
452    if (uri.scheme == "file") {
453      // Display special text for file protocol
454      displayHost = gBundle.GetStringFromName(gStr.doneFileScheme);
455      fullHost = displayHost;
456    } else if (!displayHost.length) {
457      // Got nothing; show the scheme (data: about: moz-icon:)
458      displayHost = gBundle.formatStringFromName(gStr.doneScheme, [uri.scheme]);
459      fullHost = displayHost;
460    } else if (uri.port != -1) {
461      // Tack on the port if it's not the default port
462      let port = ":" + uri.port;
463      displayHost += port;
464      fullHost += port;
465    }
466
467    return [displayHost, fullHost];
468  },
469
470  /**
471   * Converts a number of bytes to the appropriate unit that results in an
472   * internationalized number that needs fewer than 4 digits.
473   *
474   * @param aBytes
475   *        Number of bytes to convert
476   * @return A pair: [new value with 3 sig. figs., its unit]
477   */
478  convertByteUnits: function DU_convertByteUnits(aBytes) {
479    let unitIndex = 0;
480
481    // Convert to next unit if it needs 4 digits (after rounding), but only if
482    // we know the name of the next unit
483    while (aBytes >= 999.5 && unitIndex < gStr.units.length - 1) {
484      aBytes /= 1024;
485      unitIndex++;
486    }
487
488    // Get rid of insignificant bits by truncating to 1 or 0 decimal points
489    // 0 -> 0; 1.2 -> 1.2; 12.3 -> 12.3; 123.4 -> 123; 234.5 -> 235
490    // added in bug 462064: (unitIndex != 0) makes sure that no decimal digit for bytes appears when aBytes < 100
491    let fractionDigits = aBytes > 0 && aBytes < 100 && unitIndex != 0 ? 1 : 0;
492
493    // Don't try to format Infinity values using NumberFormat.
494    if (aBytes === Infinity) {
495      aBytes = "Infinity";
496    } else {
497      aBytes = getLocaleNumberFormat(fractionDigits).format(aBytes);
498    }
499
500    return [aBytes, gBundle.GetStringFromName(gStr.units[unitIndex])];
501  },
502
503  /**
504   * Converts a number of seconds to the two largest units. Time values are
505   * whole numbers, and units have the correct plural/singular form.
506   *
507   * @param aSecs
508   *        Seconds to convert into the appropriate 2 units
509   * @return 4-item array [first value, its unit, second value, its unit]
510   */
511  convertTimeUnits: function DU_convertTimeUnits(aSecs) {
512    // These are the maximum values for seconds, minutes, hours corresponding
513    // with gStr.timeUnits without the last item
514    let timeSize = [60, 60, 24];
515
516    let time = aSecs;
517    let scale = 1;
518    let unitIndex = 0;
519
520    // Keep converting to the next unit while we have units left and the
521    // current one isn't the largest unit possible
522    while (unitIndex < timeSize.length && time >= timeSize[unitIndex]) {
523      time /= timeSize[unitIndex];
524      scale *= timeSize[unitIndex];
525      unitIndex++;
526    }
527
528    let value = convertTimeUnitsValue(time);
529    let units = convertTimeUnitsUnits(value, unitIndex);
530
531    let extra = aSecs - value * scale;
532    let nextIndex = unitIndex - 1;
533
534    // Convert the extra time to the next largest unit
535    for (let index = 0; index < nextIndex; index++) {
536      extra /= timeSize[index];
537    }
538
539    let value2 = convertTimeUnitsValue(extra);
540    let units2 = convertTimeUnitsUnits(value2, nextIndex);
541
542    return [value, units, value2, units2];
543  },
544
545  /**
546   * Converts a number of seconds to "downloading file opens in X" status.
547   * @param aSeconds
548   *        Seconds to convert into the time format.
549   * @return status object, example:
550   *  status = {
551   *      l10n: {
552   *        id: "downloading-file-opens-in-minutes-and-seconds",
553   *        args: { minutes: 2, seconds: 30 },
554   *      },
555   *   };
556   */
557  getFormattedTimeStatus: function DU_getFormattedTimeStatus(aSeconds) {
558    aSeconds = Math.floor(aSeconds);
559    let l10n;
560    if (!isFinite(aSeconds)) {
561      l10n = {
562        id: "downloading-file-opens-in-some-time",
563      };
564    } else if (aSeconds < 60) {
565      l10n = {
566        id: "downloading-file-opens-in-seconds",
567        args: { seconds: aSeconds },
568      };
569    } else if (aSeconds < 3600) {
570      let minutes = Math.floor(aSeconds / 60);
571      let seconds = aSeconds % 60;
572      l10n = seconds
573        ? {
574            args: { seconds, minutes },
575            id: "downloading-file-opens-in-minutes-and-seconds",
576          }
577        : { args: { minutes }, id: "downloading-file-opens-in-minutes" };
578    } else {
579      let hours = Math.floor(aSeconds / 3600);
580      let minutes = Math.floor((aSeconds % 3600) / 60);
581      l10n = {
582        args: { hours, minutes },
583        id: "downloading-file-opens-in-hours-and-minutes",
584      };
585    }
586    return { l10n };
587  },
588};
589
590/**
591 * Private helper for convertTimeUnits that gets the display value of a time
592 *
593 * @param aTime
594 *        Time value for display
595 * @return An integer value for the time rounded down
596 */
597function convertTimeUnitsValue(aTime) {
598  return Math.floor(aTime);
599}
600
601/**
602 * Private helper for convertTimeUnits that gets the display units of a time
603 *
604 * @param aTime
605 *        Time value for display
606 * @param aIndex
607 *        Index into gStr.timeUnits for the appropriate unit
608 * @return The appropriate plural form of the unit for the time
609 */
610function convertTimeUnitsUnits(aTime, aIndex) {
611  // Negative index would be an invalid unit, so just give empty
612  if (aIndex < 0) {
613    return "";
614  }
615
616  return PluralForm.get(
617    aTime,
618    gBundle.GetStringFromName(gStr.timeUnits[aIndex])
619  );
620}
621
622/**
623 * Private helper function to log errors to the error console and command line
624 *
625 * @param aMsg
626 *        Error message to log or an array of strings to concat
627 */
628// function log(aMsg) {
629//   let msg = "DownloadUtils.jsm: " + (aMsg.join ? aMsg.join("") : aMsg);
630//   Services.console.logStringMessage(msg);
631//   dump(msg + "\n");
632// }
633