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