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