From d311d5949fe47786531c1184defc45b576c27fc9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Sat, 2 May 2020 16:40:52 +0200
Subject: [PATCH] forms: detect and suggest fixes for typos in email domains
 (#42396)

---
 wcs/qommon/form.py                   |   3 +
 wcs/qommon/static/css/dc2/admin.css  |  36 ++++++++
 wcs/qommon/static/js/qommon.forms.js | 119 +++++++++++++++++++++++++++
 wcs/root.py                          |   2 +
 4 files changed, 160 insertions(+)

diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 7a9745fe7..2ee4f18ea 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -882,6 +882,9 @@ class EmailWidget(StringWidget):
         if not 'size' in kwargs:
             self.attrs['size'] = '35'
 
+    def add_media(self):
+        get_response().add_javascript(['jquery.js', '../../i18n.js', 'qommon.forms.js'])
+
     def _parse(self, request):
         StringWidget._parse(self, request)
         if self.value is not None:
diff --git a/wcs/qommon/static/css/dc2/admin.css b/wcs/qommon/static/css/dc2/admin.css
index 5434ec86a..d2f3ca18c 100644
--- a/wcs/qommon/static/css/dc2/admin.css
+++ b/wcs/qommon/static/css/dc2/admin.css
@@ -1916,3 +1916,39 @@ div.mail-body {
 #sidebar-custom-views .active {
 	font-weight: bold;
 }
+
+.field-live-hint {
+	position: absolute;
+	background: #ffffee;
+	color: #333;
+	z-index: 1000000;
+	padding: 1em 1em;
+	box-shadow: 0px 2px 5px 0px rgba(0, 0, 0, 0.16);
+}
+
+.field-live-hint span::before {
+	font-family: FontAwesome;
+	content: "\f071";  /* exclamation triangle */
+	padding-right: 0.5em;
+}
+
+.field-live-hint button.action,
+.field-live-hint button.close {
+	margin: 0 1em;
+	padding: 0;
+	color: blue !important;
+	border: none;
+	text-decoration: underline !important;
+	background: transparent !important;
+	box-shadow: none !important;
+}
+
+.field-live-hint button.close {
+	color: #333 !important;
+	margin: 0;
+	text-decoration: none !important;
+}
+
+.field-live-hint button.close::after {
+	content: "×";
+}
diff --git a/wcs/qommon/static/js/qommon.forms.js b/wcs/qommon/static/js/qommon.forms.js
index 4549107af..85b9468ab 100644
--- a/wcs/qommon/static/js/qommon.forms.js
+++ b/wcs/qommon/static/js/qommon.forms.js
@@ -1,3 +1,80 @@
+String.prototype.similarity = function(string) {
+  // adapted from https://github.com/jordanthomas/jaro-winkler (licensed as MIT)
+  var s1 = this, s2 = string;
+  var m = 0;
+  var i;
+  var j;
+
+  // Exit early if either are empty.
+  if (s1.length === 0 || s2.length === 0) {
+    return 0;
+  }
+
+  // Convert to upper
+  s1 = s1.toUpperCase();
+  s2 = s2.toUpperCase();
+
+  // Exit early if they're an exact match.
+  if (s1 === s2) {
+    return 1;
+  }
+
+  var range = (Math.floor(Math.max(s1.length, s2.length) / 2)) - 1;
+  var s1Matches = new Array(s1.length);
+  var s2Matches = new Array(s2.length);
+
+  for (i = 0; i < s1.length; i++) {
+    var low  = (i >= range) ? i - range : 0;
+    var high = (i + range <= (s2.length - 1)) ? (i + range) : (s2.length - 1);
+
+    for (j = low; j <= high; j++) {
+      if (s1Matches[i] !== true && s2Matches[j] !== true && s1[i] === s2[j]) {
+        ++m;
+        s1Matches[i] = s2Matches[j] = true;
+        break;
+      }
+    }
+  }
+
+  // Exit early if no matches were found.
+  if (m === 0) {
+    return 0;
+  }
+
+  // Count the transpositions.
+  var k = 0;
+  var numTrans = 0;
+
+  for (i = 0; i < s1.length; i++) {
+    if (s1Matches[i] === true) {
+      for (j = k; j < s2.length; j++) {
+        if (s2Matches[j] === true) {
+          k = j + 1;
+          break;
+        }
+      }
+
+      if (s1[i] !== s2[j]) {
+        ++numTrans;
+      }
+    }
+  }
+
+  var weight = (m / s1.length + m / s2.length + (m - (numTrans / 2)) / m) / 3;
+  var l = 0;
+  var p = 0.1;
+
+  if (weight > 0.7) {
+    while (s1[l] === s2[l] && l < 4) {
+      ++l;
+    }
+
+    weight = weight + l * p * (1 - weight);
+  }
+
+  return weight;
+}
+
 $(function() {
   var autosave_timeout_id = null;
   if ($('form[data-has-draft]').length == 1) {
@@ -30,6 +107,48 @@ $(function() {
       last_auto_save = $('form[data-has-draft]').serialize();
     });
   }
+  var well_known_domains = ['gmail.com', 'msn.com', 'hotmail.com', 'hotmail.fr', 'wanadoo.fr',
+                            'free.fr', 'yahoo.fr', 'numericable.fr', "laposte.fr"];
+  $('input[type=email]').on('change wcs:change', function() {
+    var $email_input = $(this);
+    var val = $email_input.val();
+    var val_domain = val.split('@')[1];
+    var $domain_hint_div = this.domain_hint_div;
+    var highest_ratio = 0;
+    var suggestion = null;
+    for (var i=0; i < well_known_domains.length; i++) {
+      var domain = well_known_domains[i];
+      var ratio = val_domain.similarity(domain);
+      if (ratio > highest_ratio) {
+        highest_ratio = ratio;
+        suggestion = domain;
+      }
+    }
+    if (highest_ratio > 0.80 && highest_ratio < 1) {
+      if ($domain_hint_div === undefined) {
+        $domain_hint_div = $('<div class="field-live-hint"><span class="message"></span><button class="action"></button><button class="close"><span class="sr-only"></span></button></div>');
+        this.domain_hint_div = $domain_hint_div;
+        $(this).after($domain_hint_div);
+        $domain_hint_div.find('button.action').on('click', function() {
+          $email_input.val($email_input.val().replace(/@.*/, '@' + $(this).data('suggestion')));
+          $email_input.trigger('wcs:change');
+          $domain_hint_div.hide();
+          return false;
+        });
+        $domain_hint_div.find('button.close').on('click', function() {
+          $domain_hint_div.hide();
+          return false;
+        });
+      }
+      $domain_hint_div.find('span').text(WCS_I18N.email_domain_suggest + ' @' + suggestion + ' ?');
+      $domain_hint_div.find('button.action').text(WCS_I18N.email_domain_fix);
+      $domain_hint_div.find('button.action').data('suggestion', suggestion);
+      $domain_hint_div.find('button.close span.sr-only').text(WCS_I18N.close);
+      $domain_hint_div.show();
+    } else if ($domain_hint_div) {
+      $domain_hint_div.hide();
+    }
+  });
   $('.date-pick').each(function() {
     if (this.type == "date" || this.type == "time") {
       return;  // prefer native date/time widgets
diff --git a/wcs/root.py b/wcs/root.py
index 129a83520..d37fc53b2 100644
--- a/wcs/root.py
+++ b/wcs/root.py
@@ -411,6 +411,8 @@ class RootDirectory(Directory):
             's2_loadmore': _('Loading more results...'),
             's2_searching': _('Searching...'),
             'close': _('Close'),
+            'email_domain_suggest': _('Did you want to write'),
+            'email_domain_fix': _('Apply fix'),
         }
         return 'WCS_I18N = %s;\n' % json.dumps(strings)
 
-- 
2.27.0.rc2

