Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import {
UrlbarProvider,
UrlbarUtils,
} from "resource:///modules/UrlbarUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs",
UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"ClipboardHelper",
"@mozilla.org/widget/clipboardhelper;1",
"nsIClipboardHelper"
);
// This pref is relative to the `browser.urlbar` branch.
const ENABLED_PREF = "suggest.calculator";
const DYNAMIC_RESULT_TYPE = "calculator";
const VIEW_TEMPLATE = {
attributes: {
selectable: true,
},
children: [
{
name: "content",
tag: "span",
attributes: { class: "urlbarView-no-wrap" },
children: [
{
name: "icon",
tag: "img",
attributes: { class: "urlbarView-favicon" },
},
{
name: "input",
tag: "strong",
},
{
name: "action",
tag: "span",
},
],
},
],
};
// Minimum number of parts of the expression before we show a result.
const MIN_EXPRESSION_LENGTH = 3;
/**
* A provider that returns a suggested url to the user based on what
* they have currently typed so they can navigate directly.
*/
class ProviderCalculator extends UrlbarProvider {
constructor() {
super();
lazy.UrlbarResult.addDynamicResultType(DYNAMIC_RESULT_TYPE);
lazy.UrlbarView.addDynamicViewTemplate(DYNAMIC_RESULT_TYPE, VIEW_TEMPLATE);
}
/**
* Returns the name of this provider.
*
* @returns {string} the name of this provider.
*/
get name() {
return DYNAMIC_RESULT_TYPE;
}
/**
* The type of the provider.
*
* @returns {UrlbarUtils.PROVIDER_TYPE}
*/
get type() {
return UrlbarUtils.PROVIDER_TYPE.PROFILE;
}
/**
* Whether this provider should be invoked for the given context.
* If this method returns false, the providers manager won't start a query
* with this provider, to save on resources.
*
* @param {UrlbarQueryContext} queryContext The query context object
* @returns {boolean} Whether this provider should be invoked for the search.
*/
isActive(queryContext) {
return (
queryContext.trimmedSearchString &&
!queryContext.searchMode &&
lazy.UrlbarPrefs.get(ENABLED_PREF)
);
}
/**
* Starts querying. Extended classes should return a Promise resolved when the
* provider is done searching AND returning results.
*
* @param {UrlbarQueryContext} queryContext The query context object
* @param {Function} addCallback Callback invoked by the provider to add a new
* result. A UrlbarResult should be passed to it.
*/
async startQuery(queryContext, addCallback) {
try {
// Calculator will throw when given an invalid expression, therefore
// addCallback will never be called.
let postfix = Calculator.infix2postfix(queryContext.searchString);
if (postfix.length < MIN_EXPRESSION_LENGTH) {
return;
}
let value = Calculator.evaluatePostfix(postfix);
const result = new lazy.UrlbarResult(
UrlbarUtils.RESULT_TYPE.DYNAMIC,
UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
{
value,
input: queryContext.searchString,
dynamicType: DYNAMIC_RESULT_TYPE,
}
);
result.suggestedIndex = 1;
addCallback(this, result);
} catch (e) {}
}
getViewUpdate(result) {
const viewUpdate = {
icon: {
attributes: {
src: "chrome://global/skin/icons/edit-copy.svg",
},
},
input: {
l10n: {
id: "urlbar-result-action-calculator-result",
args: { result: result.payload.value },
},
},
action: {
l10n: { id: "urlbar-result-action-copy-to-clipboard" },
},
};
return viewUpdate;
}
onEngagement(queryContext, controller, details) {
let { result } = details;
lazy.ClipboardHelper.copyString(result.payload.value);
}
}
/**
* Base implementation of a basic calculator.
*/
class BaseCalculator {
// Holds the current symbols for calculation
stack = [];
numberSystems = [];
addNumberSystem(system) {
this.numberSystems.push(system);
}
isNumeric(value) {
return value - 0 == value && value.length;
}
isOperator(value) {
return this.numberSystems.some(sys => sys.isOperator(value));
}
isNumericToken(char) {
return this.numberSystems.some(sys => sys.isNumericToken(char));
}
parsel10nFloat(num) {
for (const system of this.numberSystems) {
num = system.transformNumber(num);
}
return parseFloat(num, 10);
}
precedence(val) {
if (["-", "+"].includes(val)) {
return 2;
}
if (["*", "/"].includes(val)) {
return 3;
}
return null;
}
// This is a basic implementation of the shunting yard algorithm
// Currently functions are unimplemented and only operators with
// left association are used
infix2postfix(infix) {
let parser = new Parser(infix, this);
let tokens = parser.parse(infix);
let output = [];
let stack = [];
tokens.forEach(token => {
if (token.number) {
output.push(this.parsel10nFloat(token.value, 10));
}
if (this.isOperator(token.value)) {
let i = this.precedence;
while (
stack.length &&
this.isOperator(stack[stack.length - 1]) &&
i(token.value) <= i(stack[stack.length - 1])
) {
output.push(stack.pop());
}
stack.push(token.value);
}
if (token.value === "(") {
stack.push(token.value);
}
if (token.value === ")") {
while (stack.length && stack[stack.length - 1] !== "(") {
output.push(stack.pop());
}
// This is the (
stack.pop();
}
});
while (stack.length) {
output.push(stack.pop());
}
return output;
}
evaluate = {
"*": (a, b) => a * b,
"+": (a, b) => a + b,
"-": (a, b) => a - b,
"/": (a, b) => a / b,
};
evaluatePostfix(postfix) {
let stack = [];
postfix.forEach(token => {
if (!this.isOperator(token)) {
stack.push(token);
} else {
let op2 = stack.pop();
let op1 = stack.pop();
let result = this.evaluate[token](op1, op2);
if (isNaN(result)) {
throw new Error("Value is " + result);
}
stack.push(result);
}
});
let finalResult = stack.pop();
if (isNaN(finalResult)) {
throw new Error("Value is " + finalResult);
}
return finalResult;
}
}
function Parser(input, calculator) {
this.calculator = calculator;
this.init(input);
}
Parser.prototype = {
init(input) {
// No spaces.
input = input.replace(/[ \t\v\n]/g, "");
// String to array:
this._chars = [];
for (let i = 0; i < input.length; ++i) {
this._chars.push(input[i]);
}
this._tokens = [];
},
// This method returns an array of objects with these properties:
// - number: true/false
// - value: the token value
parse() {
// The input must be a "block" without any digit left.
if (!this._tokenizeBlock() || this._chars.length) {
throw new Error("Wrong input");
}
return this._tokens;
},
_tokenizeBlock() {
if (!this._chars.length) {
return false;
}
// "(" + something + ")"
if (this._chars[0] == "(") {
this._tokens.push({ number: false, value: this._chars[0] });
this._chars.shift();
if (!this._tokenizeBlock()) {
return false;
}
if (!this._chars.length || this._chars[0] != ")") {
return false;
}
this._chars.shift();
this._tokens.push({ number: false, value: ")" });
} else if (!this._tokenizeNumber()) {
// number + ...
return false;
}
if (!this._chars.length || this._chars[0] == ")") {
return true;
}
while (this._chars.length && this._chars[0] != ")") {
if (!this._tokenizeOther()) {
return false;
}
if (!this._tokenizeBlock()) {
return false;
}
}
return true;
},
// This is a simple float parser.
_tokenizeNumber() {
if (!this._chars.length) {
return false;
}
// {+,-}something
let number = [];
if (/[+-]/.test(this._chars[0])) {
number.push(this._chars.shift());
}
let tokenizeNumberInternal = () => {
if (
!this._chars.length ||
!this.calculator.isNumericToken(this._chars[0])
) {
return false;
}
while (
this._chars.length &&
this.calculator.isNumericToken(this._chars[0])
) {
number.push(this._chars.shift());
}
return true;
};
if (!tokenizeNumberInternal()) {
return false;
}
// 123{e...}
if (!this._chars.length || this._chars[0] != "e") {
this._tokens.push({ number: true, value: number.join("") });
return true;
}
number.push(this._chars.shift());
// 123e{+,-}
if (/[+-]/.test(this._chars[0])) {
number.push(this._chars.shift());
}
if (!this._chars.length) {
return false;
}
// the number
if (!tokenizeNumberInternal()) {
return false;
}
this._tokens.push({ number: true, value: number.join("") });
return true;
},
_tokenizeOther() {
if (!this._chars.length) {
return false;
}
if (this.calculator.isOperator(this._chars[0])) {
this._tokens.push({ number: false, value: this._chars.shift() });
return true;
}
return false;
},
};
export let Calculator = new BaseCalculator();
Calculator.addNumberSystem({
isOperator: char => ["÷", "×", "-", "+", "*", "/"].includes(char),
isNumericToken: char => /^[0-9\.,]/.test(char),
// parseFloat will only handle numbers that use periods as decimal
// seperators, various countries use commas. This function attempts
// to fixup the number so parseFloat will accept it.
transformNumber: num => {
let firstComma = num.indexOf(",");
let firstPeriod = num.indexOf(".");
if (firstPeriod != -1 && firstComma != -1 && firstPeriod < firstComma) {
// Contains both a period and a comma and the period came first
// so using comma as decimal seperator, strip . and replace , with .
// (ie 1.999,5).
num = num.replace(/\./g, "");
num = num.replace(/,/g, ".");
} else if (firstPeriod != -1 && firstComma != -1) {
// Contains both a period and a comma and the comma came first
// so strip the comma (ie 1,999.5).
num = num.replace(/,/g, "");
} else if (firstComma != -1) {
// Has commas but no periods so treat comma as decimal seperator
num = num.replace(/,/g, ".");
}
return num;
},
});
export var UrlbarProviderCalculator = new ProviderCalculator();