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