Bug 1370429 - Part 2: Implement address-lines parser and refactor getInfo function. r=MattN draft
authorSean Lee <selee@mozilla.com>
Wed, 19 Jul 2017 10:15:34 +0800
changeset 613051 ea87939163d8311371d5d2aafdd5c6f1d3924abe
parent 613050 fc9d5ddc616fa4b3c24bb3f7a20dcb76dcfb1af7
child 613052 6eeda223a01a1d903143bdb2c3ea643d8eadff2d
push id69715
push userbmo:selee@mozilla.com
push dateFri, 21 Jul 2017 14:12:03 +0000
reviewersMattN
bugs1370429
milestone56.0a1
Bug 1370429 - Part 2: Implement address-lines parser and refactor getInfo function. r=MattN MozReview-Commit-ID: 5gseB36n1M0
browser/extensions/formautofill/FormAutofillHeuristics.jsm
browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
browser/extensions/formautofill/test/unit/test_getInfo.js
--- a/browser/extensions/formautofill/FormAutofillHeuristics.jsm
+++ b/browser/extensions/formautofill/FormAutofillHeuristics.jsm
@@ -18,36 +18,92 @@ Cu.import("resource://formautofill/FormA
 
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]);
 
 const PREF_HEURISTICS_ENABLED = "extensions.formautofill.heuristics.enabled";
 
 /**
  * A scanner for traversing all elements in a form and retrieving the field
- * detail with FormAutofillHeuristics.getInfo function.
+ * detail with FormAutofillHeuristics.getInfo function. It also provides a
+ * cursor (indexParsing) to indicate which element is waiting for parsing.
  */
 class FieldScanner {
   /**
    * Create a FieldScanner based on form elements with the existing
    * fieldDetails.
    *
    * @param {Array.DOMElement} elements
    *        The elements from a form for each parser.
    */
   constructor(elements) {
     this._elementsWeakRef = Cu.getWeakReference(elements);
     this.fieldDetails = [];
+    this._indexParsing = 0;
   }
 
   get _elements() {
     return this._elementsWeakRef.get();
   }
 
   /**
+   * This cursor means the index of the element which is waiting for parsing.
+   *
+   * @returns {number}
+   *          The index of the element which is waiting for parsing.
+   */
+  get indexParsing() {
+    return this._indexParsing;
+  }
+
+  /**
+   * Move the indexParsing to the next elements. Any elements behind this index
+   * means the parsing tasks are finished.
+   *
+   * @param {number} index
+   *        The latest index of elements waiting for parsing.
+   */
+  set indexParsing(index) {
+    if (index > this.fieldDetails.length) {
+      log.warn("The parsing index is out of range.");
+      index = this.fieldDetails.length;
+    }
+    this._indexParsing = index;
+  }
+
+  /**
+   * Retrieve the field detail by the index. If the field detail is not ready,
+   * the elements will be traversed until matching the index.
+   *
+   * @param {number} index
+   *        The index of the element that you want to retrieve.
+   * @returns {Object}
+   *          The field detail at the specific index.
+   */
+  getFieldDetailByIndex(index) {
+    if (index >= this._elements.length) {
+      return null;
+    }
+
+    if (this.fieldDetails.length > index) {
+      return this.fieldDetails[index];
+    }
+
+    for (let i = this.fieldDetails.length; i < (index + 1); i++) {
+      this.pushDetail();
+    }
+
+    return this.fieldDetails[index];
+  }
+
+  get parsingFinished() {
+    return this.indexParsing >= this._elements.length;
+  }
+
+  /**
    * This function will prepare an autocomplete info object with getInfo
    * function and push the detail to fieldDetails property. Any duplicated
    * detail will be marked as _duplicated = true for the parser.
    *
    * Any element without the related detail will be used for adding the detail
    * to the end of field details.
    */
   pushDetail() {
@@ -74,16 +130,39 @@ class FieldScanner {
       // A field with the same identifier already exists.
       log.debug("Not collecting a field matching another with the same info:", info);
       fieldInfo._duplicated = true;
     }
 
     this.fieldDetails.push(fieldInfo);
   }
 
+  /**
+   * When a field detail should be changed its fieldName after parsing, use
+   * this function to update the fieldName which is at a specific index.
+   *
+   * @param {number} index
+   *        The index indicates a field detail to be updated.
+   * @param {string} fieldName
+   *        The new fieldName
+   */
+  updateFieldName(index, fieldName) {
+    if (index >= this.fieldDetails.length) {
+      log.warn("Try to update an inexistent field detail.");
+      return;
+    }
+    this.fieldDetails[index].fieldName = fieldName;
+
+    delete this.fieldDetails[index]._duplicated;
+    let indexSame = this.findSameField(this.fieldDetails[index]);
+    if (indexSame != index && indexSame != -1) {
+      this.fieldDetails[index]._duplicated = true;
+    }
+  }
+
   findSameField(info) {
     return this.fieldDetails.findIndex(f => f.section == info.section &&
                                        f.addressType == info.addressType &&
                                        f.contactType == info.contactType &&
                                        f.fieldName == info.fieldName);
   }
 
   /**
@@ -121,81 +200,102 @@ this.FormAutofillHeuristics = {
       "country",
     ],
     TEL: ["tel"],
     EMAIL: ["email"],
   },
 
   RULES: null,
 
+  /**
+   * This function tries to find the correct address-line[1-3] sequence and
+   * correct their field names.
+   *
+   * @param {Object} fieldScanner
+   *        The current parsing status for all elements
+   * @returns {boolean}
+   *          Return true if there is any field can be recognized in the parser,
+   *          otherwise false.
+   */
+  _parseAddressFields(fieldScanner) {
+    let parsedFields = false;
+    let addressLines = ["address-line1", "address-line2", "address-line3"];
+    for (let i = 0; !fieldScanner.parsingFinished && i < addressLines.length; i++) {
+      let detail = fieldScanner.getFieldDetailByIndex(fieldScanner.indexParsing);
+      if (!detail || !addressLines.includes(detail.fieldName)) {
+        // When the field is not related to any address-line[1-3] fields, it
+        // means the parsing process can be terminated.
+        break;
+      }
+      fieldScanner.updateFieldName(fieldScanner.indexParsing, addressLines[i]);
+      fieldScanner.indexParsing++;
+      parsedFields = true;
+    }
+
+    return parsedFields;
+  },
+
   getFormInfo(form) {
-    if (form.autocomplete == "off") {
+    if (form.autocomplete == "off" || form.elements.length <= 0) {
       return [];
     }
+
     let fieldScanner = new FieldScanner(form.elements);
-    for (let i = 0; i < fieldScanner.elements.length; i++) {
-      let element = fieldScanner.elements[i];
-      let info = this.getInfo(element, fieldScanner.fieldDetails);
-      fieldScanner.pushDetail(i, info);
+    while (!fieldScanner.parsingFinished) {
+      let parsedAddressFields = this._parseAddressFields(fieldScanner);
+
+      // If there is no any field parsed, the parsing cursor can be moved
+      // forward to the next one.
+      if (!parsedAddressFields) {
+        fieldScanner.indexParsing++;
+      }
     }
     return fieldScanner.trimmedFieldDetail;
   },
 
   /**
    * Get the autocomplete info (e.g. fieldName) determined by the regexp
-   * (this.RULES) matching to a feature string. The result is based on the
-   * existing field names to prevent duplicating predictions
-   * (e.g. address-line[1-3).
+   * (this.RULES) matching to a feature string.
    *
    * @param {string} string a feature string to be determined.
-   * @param {Array<string>} existingFieldNames the array of exising field names
-   *                        in a form.
    * @returns {Object}
    *          Provide the predicting result including the field name.
    *
    */
-  _matchStringToFieldName(string, existingFieldNames) {
+  _matchStringToFieldName(string) {
     let result = {
       fieldName: "",
       section: "",
       addressType: "",
       contactType: "",
     };
     if (this.RULES.email.test(string)) {
       result.fieldName = "email";
       return result;
     }
     if (this.RULES.tel.test(string)) {
       result.fieldName = "tel";
       return result;
     }
     for (let fieldName of this.FIELD_GROUPS.ADDRESS) {
       if (this.RULES[fieldName].test(string)) {
-        // If "address-line1" or "address-line2" exist already, the string
-        // could be satisfied with "address-line2" or "address-line3".
-        if ((fieldName == "address-line1" &&
-            existingFieldNames.includes("address-line1")) ||
-            (fieldName == "address-line2" &&
-            existingFieldNames.includes("address-line2"))) {
-          continue;
-        }
         result.fieldName = fieldName;
         return result;
       }
     }
     for (let fieldName of this.FIELD_GROUPS.NAME) {
       if (this.RULES[fieldName].test(string)) {
         result.fieldName = fieldName;
         return result;
       }
     }
     return null;
   },
 
-  getInfo(element, fieldDetails) {
+  getInfo(element) {
     if (!FormAutofillUtils.isFieldEligibleForAutofill(element)) {
       return null;
     }
 
     let info = element.getAutocompleteInfo();
     // An input[autocomplete="on"] will not be early return here since it stll
     // needs to find the field name.
     if (info && info.fieldName && info.fieldName != "on") {
@@ -214,35 +314,31 @@ this.FormAutofillHeuristics = {
       return {
         fieldName: "email",
         section: "",
         addressType: "",
         contactType: "",
       };
     }
 
-    let existingFieldNames = fieldDetails ? fieldDetails.map(i => i.fieldName) : "";
-
     for (let elementString of [element.id, element.name]) {
-      let fieldNameResult = this._matchStringToFieldName(elementString,
-                                                         existingFieldNames);
+      let fieldNameResult = this._matchStringToFieldName(elementString);
       if (fieldNameResult) {
         return fieldNameResult;
       }
     }
     let labels = FormAutofillUtils.findLabelElements(element);
     if (!labels || labels.length == 0) {
       log.debug("No label found for", element);
       return null;
     }
     for (let label of labels) {
       let strings = FormAutofillUtils.extractLabelStrings(label);
       for (let string of strings) {
-        let fieldNameResult = this._matchStringToFieldName(string,
-                                                           existingFieldNames);
+        let fieldNameResult = this._matchStringToFieldName(string);
         if (fieldNameResult) {
           return fieldNameResult;
         }
       }
     }
 
     return null;
   },
--- a/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
+++ b/browser/extensions/formautofill/test/unit/test_getAdaptedProfiles.js
@@ -88,18 +88,18 @@ const TESTCASES = [
                <input id="line1" autocomplete="address-line1">
                <input id="line3" autocomplete="address-line3">
                </form>`,
     profileData: [Object.assign({}, DEFAULT_PROFILE)],
     expectedResult: [{
       "guid": "123",
       "street-address": "2 Harrison St line2 line3",
       "-moz-street-address-one-line": "2 Harrison St line2 line3",
-      "address-line1": "2 Harrison St line2",
-      "address-line2": "line2",
+      "address-line1": "2 Harrison St",
+      "address-line2": "line2 line3",
       "address-line3": "line3",
     }],
   },
 ];
 
 for (let testcase of TESTCASES) {
   add_task(async function() {
     do_print("Starting testcase: " + testcase.description);
--- a/browser/extensions/formautofill/test/unit/test_getInfo.js
+++ b/browser/extensions/formautofill/test/unit/test_getInfo.js
@@ -91,39 +91,22 @@ const TESTCASES = [
     expectedReturnValue: {
       fieldName: "address-level1",
       section: "",
       addressType: "",
       contactType: "",
     },
   },
   {
-    description: "2 address line inputs",
+    description: "address line input",
     document: `<label for="targetElement">street</label>
                <input id="targetElement" type="text">`,
     elementId: "targetElement",
-    addressFieldDetails: [{fieldName: "address-line1"}],
     expectedReturnValue: {
-      fieldName: "address-line2",
-      section: "",
-      addressType: "",
-      contactType: "",
-    },
-  },
-  {
-    description: "3 address line inputs",
-    document: `<label for="targetElement">street</label>
-               <input id="targetElement" type="text">`,
-    elementId: "targetElement",
-    addressFieldDetails: [
-      {fieldName: "address-line1"},
-      {fieldName: "address-line2"},
-    ],
-    expectedReturnValue: {
-      fieldName: "address-line3",
+      fieldName: "address-line1",
       section: "",
       addressType: "",
       contactType: "",
     },
   },
   {
     description: "CJK character - Traditional Chinese",
     document: `<label> 郵遞區號
@@ -213,13 +196,13 @@ const TESTCASES = [
 TESTCASES.forEach(testcase => {
   add_task(async function() {
     do_print("Starting testcase: " + testcase.description);
 
     let doc = MockDocument.createTestDocument(
       "http://localhost:8080/test/", testcase.document);
 
     let element = doc.getElementById(testcase.elementId);
-    let value = FormAutofillHeuristics.getInfo(element, testcase.addressFieldDetails);
+    let value = FormAutofillHeuristics.getInfo(element);
 
     Assert.deepEqual(value, testcase.expectedReturnValue);
   });
 });