tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

UrlbarProviderCalculator.sys.mjs (13687B)


      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/. */
      4 
      5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      6 
      7 import {
      8  UrlbarProvider,
      9  UrlbarUtils,
     10 } from "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs";
     11 
     12 const lazy = {};
     13 
     14 ChromeUtils.defineESModuleGetters(lazy, {
     15  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     16  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
     17  UrlbarView: "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs",
     18 });
     19 
     20 ChromeUtils.defineLazyGetter(lazy, "l10n", () => {
     21  return new Localization(["browser/browser.ftl"], true);
     22 });
     23 
     24 XPCOMUtils.defineLazyServiceGetter(
     25  lazy,
     26  "ClipboardHelper",
     27  "@mozilla.org/widget/clipboardhelper;1",
     28  Ci.nsIClipboardHelper
     29 );
     30 
     31 // This pref is relative to the `browser.urlbar` branch.
     32 const ENABLED_PREF = "suggest.calculator";
     33 
     34 const DYNAMIC_RESULT_TYPE = "calculator";
     35 
     36 const VIEW_TEMPLATE = {
     37  attributes: {
     38    selectable: true,
     39  },
     40  children: [
     41    {
     42      name: "content",
     43      tag: "span",
     44      attributes: { class: "urlbarView-no-wrap" },
     45      children: [
     46        {
     47          name: "icon",
     48          tag: "img",
     49          attributes: { class: "urlbarView-favicon" },
     50        },
     51        {
     52          name: "input",
     53          tag: "strong",
     54        },
     55        {
     56          name: "action",
     57          tag: "span",
     58        },
     59      ],
     60    },
     61  ],
     62 };
     63 
     64 // Minimum number of parts of the expression before we show a result.
     65 const MIN_EXPRESSION_LENGTH = 3;
     66 const UNDEFINED_VALUE = "undefined";
     67 // Minimum and maximum value of result before it switches to scientific
     68 // notation. Displaying numbers longer than 10 digits long or a decimal
     69 // containing 5 or more leading zeroes in scientific notation improves
     70 // readability.
     71 const FULL_NUMBER_MAX_THRESHOLD = 1 * 10 ** 10;
     72 const FULL_NUMBER_MIN_THRESHOLD = 10 ** -5;
     73 
     74 /**
     75 * A provider that returns a suggested url to the user based on what
     76 * they have currently typed so they can navigate directly.
     77 */
     78 export class UrlbarProviderCalculator extends UrlbarProvider {
     79  constructor() {
     80    super();
     81    lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE);
     82    lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE);
     83  }
     84 
     85  /**
     86   * @returns {Values<typeof UrlbarUtils.PROVIDER_TYPE>}
     87   */
     88  get type() {
     89    return UrlbarUtils.PROVIDER_TYPE.PROFILE;
     90  }
     91 
     92  /**
     93   * Whether this provider should be invoked for the given context.
     94   * If this method returns false, the providers manager won't start a query
     95   * with this provider, to save on resources.
     96   *
     97   * @param {UrlbarQueryContext} queryContext The query context object
     98   */
     99  async isActive(queryContext) {
    100    return (
    101      queryContext.trimmedSearchString &&
    102      !queryContext.searchMode &&
    103      lazy.UrlbarPrefs.get(ENABLED_PREF)
    104    );
    105  }
    106 
    107  /**
    108   * Starts querying.
    109   *
    110   * @param {UrlbarQueryContext} queryContext
    111   * @param {(provider: UrlbarProvider, result: UrlbarResult) => void} addCallback
    112   *   Callback invoked by the provider to add a new result.
    113   */
    114  async startQuery(queryContext, addCallback) {
    115    try {
    116      // Calculator will throw when given an invalid expression, therefore
    117      // addCallback will never be called.
    118      let postfix = Calculator.infix2postfix(queryContext.searchString);
    119      if (postfix.length < MIN_EXPRESSION_LENGTH) {
    120        return;
    121      }
    122      let value = Calculator.evaluatePostfix(postfix);
    123      const result = new lazy.UrlbarResult({
    124        type: UrlbarUtils.RESULT_TYPE.DYNAMIC,
    125        source: UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
    126        suggestedIndex: 1,
    127        payload: {
    128          value,
    129          input: queryContext.searchString,
    130          dynamicType: DYNAMIC_RESULT_TYPE,
    131        },
    132      });
    133      addCallback(this, result);
    134    } catch (e) {}
    135  }
    136 
    137  getViewUpdate(result) {
    138    const { value } = result.payload;
    139 
    140    return {
    141      icon: {
    142        attributes: {
    143          src: "chrome://global/skin/icons/edit-copy.svg",
    144        },
    145      },
    146      input:
    147        value == UNDEFINED_VALUE
    148          ? {
    149              l10n: { id: "urlbar-result-action-undefined-calculator-result" },
    150            }
    151          : {
    152              textContent: `= ${value}`,
    153              attributes: { dir: "ltr" },
    154            },
    155      action: {
    156        l10n: { id: "urlbar-result-action-copy-to-clipboard" },
    157      },
    158    };
    159  }
    160 
    161  onEngagement(queryContext, controller, details) {
    162    const { result } = details;
    163    const input = this.getViewUpdate(result).input;
    164    let localizedResult;
    165    if ("l10n" in input) {
    166      const args = input.l10n.args || {};
    167      localizedResult = lazy.l10n.formatValueSync(input.l10n.id, args);
    168    } else {
    169      localizedResult = input.textContent.replace(/^=\s*/, "");
    170    }
    171 
    172    lazy.ClipboardHelper.copyString(localizedResult);
    173  }
    174 }
    175 
    176 /**
    177 * Base implementation of a basic calculator.
    178 */
    179 class BaseCalculator {
    180  // Holds the current symbols for calculation
    181  stack = [];
    182  numberSystems = [];
    183 
    184  addNumberSystem(system) {
    185    this.numberSystems.push(system);
    186  }
    187 
    188  isNumeric(value) {
    189    return value - 0 == value && value.length;
    190  }
    191 
    192  isOperator(value) {
    193    return this.numberSystems.some(sys => sys.isOperator(value));
    194  }
    195 
    196  isNumericToken(char) {
    197    return this.numberSystems.some(sys => sys.isNumericToken(char));
    198  }
    199 
    200  /**
    201   * Parses a string into a float accounting for different localisations.
    202   *
    203   * @param {string} num
    204   */
    205  parsel10nFloat(num) {
    206    for (const system of this.numberSystems) {
    207      num = system.transformNumber(num);
    208    }
    209    return parseFloat(num);
    210  }
    211 
    212  precedence(val) {
    213    if (["-", "+"].includes(val)) {
    214      return 2;
    215    }
    216    if (["*", "/"].includes(val)) {
    217      return 3;
    218    }
    219    if ("^" === val) {
    220      return 4;
    221    }
    222 
    223    return null;
    224  }
    225 
    226  isLeftAssociative(val) {
    227    if (["-", "+", "*", "/"].includes(val)) {
    228      return true;
    229    }
    230    if ("^" === val) {
    231      return false;
    232    }
    233 
    234    return null;
    235  }
    236 
    237  // This is a basic implementation of the shunting yard algorithm
    238  // described http://en.wikipedia.org/wiki/Shunting-yard_algorithm
    239  // Currently functions are unimplemented
    240  infix2postfix(infix) {
    241    let parser = new Parser(infix, this);
    242    let tokens = parser.parse();
    243    let output = [];
    244    let stack = [];
    245 
    246    tokens.forEach(token => {
    247      if (token.number) {
    248        output.push(this.parsel10nFloat(token.value));
    249      }
    250 
    251      if (this.isOperator(token.value)) {
    252        let i = this.precedence;
    253        while (
    254          stack.length &&
    255          this.isOperator(stack[stack.length - 1]) &&
    256          (i(token.value) < i(stack[stack.length - 1]) ||
    257            (i(token.value) == i(stack[stack.length - 1]) &&
    258              this.isLeftAssociative(token.value)))
    259        ) {
    260          output.push(stack.pop());
    261        }
    262        stack.push(token.value);
    263      }
    264 
    265      if (token.value === "(") {
    266        stack.push(token.value);
    267      }
    268 
    269      if (token.value === ")") {
    270        while (stack.length && stack[stack.length - 1] !== "(") {
    271          output.push(stack.pop());
    272        }
    273        // This is the (
    274        stack.pop();
    275      }
    276    });
    277 
    278    while (stack.length) {
    279      output.push(stack.pop());
    280    }
    281    return output;
    282  }
    283 
    284  evaluate = {
    285    "*": (a, b) => a * b,
    286    "+": (a, b) => a + b,
    287    "-": (a, b) => a - b,
    288    "/": (a, b) => a / b,
    289    "^": (a, b) => a ** b,
    290  };
    291 
    292  evaluatePostfix(postfix) {
    293    let stack = [];
    294 
    295    for (const token of postfix) {
    296      if (!this.isOperator(token)) {
    297        stack.push(token);
    298      } else {
    299        let op2 = stack.pop();
    300        let op1 = stack.pop();
    301        let result = this.evaluate[token](op1, op2);
    302        if (token == "/" && op2 == 0) {
    303          return UNDEFINED_VALUE;
    304        }
    305        if (isNaN(result) || !isFinite(result)) {
    306          throw new Error("Value is " + result);
    307        }
    308        stack.push(result);
    309      }
    310    }
    311    let finalResult = stack.pop();
    312    if (isNaN(finalResult) || !isFinite(finalResult)) {
    313      throw new Error("Value is " + finalResult);
    314    }
    315 
    316    let locale = Services.locale.appLocaleAsBCP47;
    317 
    318    if (
    319      Math.abs(finalResult) >= FULL_NUMBER_MAX_THRESHOLD ||
    320      (Math.abs(finalResult) <= FULL_NUMBER_MIN_THRESHOLD && finalResult != 0)
    321    ) {
    322      return new Intl.NumberFormat(locale, {
    323        style: "decimal",
    324        notation: "scientific",
    325        minimumFractionDigits: 1,
    326        maximumFractionDigits: 8,
    327        numberingSystem: "latn",
    328      })
    329        .format(finalResult)
    330        .toLowerCase();
    331    } else if (Math.abs(finalResult) < 1) {
    332      return new Intl.NumberFormat(locale, {
    333        style: "decimal",
    334        maximumSignificantDigits: 9,
    335        numberingSystem: "latn",
    336      }).format(finalResult);
    337    }
    338    return new Intl.NumberFormat(locale, {
    339      style: "decimal",
    340      useGrouping: false,
    341      maximumFractionDigits: 8,
    342      numberingSystem: "latn",
    343    }).format(finalResult);
    344  }
    345 }
    346 
    347 function Parser(input, calculator) {
    348  this.calculator = calculator;
    349  this.init(input);
    350 }
    351 
    352 Parser.prototype = {
    353  init(input) {
    354    // No spaces.
    355    input = input.replace(/[ \t\v\n]/g, "");
    356 
    357    // String to array:
    358    this._chars = [];
    359    for (let i = 0; i < input.length; ++i) {
    360      this._chars.push(input[i]);
    361    }
    362 
    363    this._tokens = [];
    364  },
    365 
    366  // This method returns an array of objects with these properties:
    367  // - number: true/false
    368  // - value:  the token value
    369  parse() {
    370    // The input must be a "block" without any digit left.
    371    if (!this._tokenizeBlock() || this._chars.length) {
    372      throw new Error("Wrong input");
    373    }
    374 
    375    return this._tokens;
    376  },
    377 
    378  _tokenizeBlock() {
    379    if (!this._chars.length) {
    380      return false;
    381    }
    382 
    383    // "(" + something + ")"
    384    if (this._chars[0] == "(") {
    385      this._tokens.push({ number: false, value: this._chars[0] });
    386      this._chars.shift();
    387 
    388      if (!this._tokenizeBlock()) {
    389        return false;
    390      }
    391 
    392      if (!this._chars.length || this._chars[0] != ")") {
    393        return false;
    394      }
    395 
    396      this._chars.shift();
    397 
    398      this._tokens.push({ number: false, value: ")" });
    399    } else if (!this._tokenizeNumber()) {
    400      // number + ...
    401      return false;
    402    }
    403 
    404    if (!this._chars.length || this._chars[0] == ")") {
    405      return true;
    406    }
    407 
    408    while (this._chars.length && this._chars[0] != ")") {
    409      if (!this._tokenizeOther()) {
    410        return false;
    411      }
    412 
    413      if (!this._tokenizeBlock()) {
    414        return false;
    415      }
    416    }
    417 
    418    return true;
    419  },
    420 
    421  // This is a simple float parser.
    422  _tokenizeNumber() {
    423    if (!this._chars.length) {
    424      return false;
    425    }
    426 
    427    // {+,-}something
    428    let number = [];
    429    if (/[+-]/.test(this._chars[0])) {
    430      number.push(this._chars.shift());
    431    }
    432 
    433    let tokenizeNumberInternal = () => {
    434      if (
    435        !this._chars.length ||
    436        !this.calculator.isNumericToken(this._chars[0])
    437      ) {
    438        return false;
    439      }
    440 
    441      while (
    442        this._chars.length &&
    443        this.calculator.isNumericToken(this._chars[0])
    444      ) {
    445        number.push(this._chars.shift());
    446      }
    447 
    448      return true;
    449    };
    450 
    451    if (!tokenizeNumberInternal()) {
    452      return false;
    453    }
    454 
    455    // 123{e...}
    456    if (!this._chars.length || this._chars[0] != "e") {
    457      this._tokens.push({ number: true, value: number.join("") });
    458      return true;
    459    }
    460 
    461    number.push(this._chars.shift());
    462 
    463    // 123e{+,-}
    464    if (/[+-]/.test(this._chars[0])) {
    465      number.push(this._chars.shift());
    466    }
    467 
    468    if (!this._chars.length) {
    469      return false;
    470    }
    471 
    472    // the number
    473    if (!tokenizeNumberInternal()) {
    474      return false;
    475    }
    476 
    477    this._tokens.push({ number: true, value: number.join("") });
    478    return true;
    479  },
    480 
    481  _tokenizeOther() {
    482    if (!this._chars.length) {
    483      return false;
    484    }
    485 
    486    if (this.calculator.isOperator(this._chars[0])) {
    487      this._tokens.push({ number: false, value: this._chars.shift() });
    488      return true;
    489    }
    490 
    491    return false;
    492  },
    493 };
    494 
    495 export let Calculator = new BaseCalculator();
    496 
    497 Calculator.addNumberSystem({
    498  isOperator: char => ["÷", "×", "-", "+", "*", "/", "^"].includes(char),
    499  isNumericToken: char => /^[0-9\.,]/.test(char),
    500  /**
    501   * parseFloat will only handle numbers that use periods as decimal
    502   * separators, various countries use commas. This function attempts
    503   * to fixup the number so parseFloat will accept it.
    504   *
    505   * @param {string} num
    506   */
    507  transformNumber: num => {
    508    let firstComma = num.indexOf(",");
    509    let firstPeriod = num.indexOf(".");
    510 
    511    if (firstPeriod != -1 && firstComma != -1 && firstPeriod < firstComma) {
    512      // Contains both a period and a comma and the period came first
    513      // so using comma as decimal seperator, strip . and replace , with .
    514      // (ie 1.999,5).
    515      num = num.replace(/\./g, "");
    516      num = num.replace(/,/g, ".");
    517    } else if (firstPeriod != -1 && firstComma != -1) {
    518      // Contains both a period and a comma and the comma came first
    519      // so strip the comma (ie 1,999.5).
    520      num = num.replace(/,/g, "");
    521    } else if (firstComma != -1 && num.includes(",", firstComma + 1)) {
    522      // Contains multiple commas and no periods, strip commas
    523      num = num.replace(/,/g, "");
    524    } else if (firstPeriod != -1 && num.includes(".", firstPeriod + 1)) {
    525      // Contains multiple periods and no commas, strip periods
    526      num = num.replace(/\./g, "");
    527    } else if (firstComma != -1) {
    528      // Has a single comma and no periods, treat comma as decimal seperator
    529      num = num.replace(/,/g, ".");
    530    }
    531    return num;
    532  },
    533 });