VulpesOS/gaia/shared/js/contacts/contacts_matcher.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

717 lines
23 KiB
JavaScript
Executable file

/* global SimplePhoneMatcher */
/* global Normalizer */
/* exported Matcher */
(function(exports) {
'use strict';
function MatcherObj(pdataProvider) {
var blankRegExp = /\s+/g;
var FB_CATEGORY = 'facebook';
var FB_LINKED = 'fb_linked';
var dataProvider = pdataProvider;
// Multiple matcher Object. It tries to find a set of Contacts that match at
// least one of the targets passed as parameters
// ptargets: They are the targets (telephone numbers, emails) we want to
// find in the Contacts DB
// mmatchingOptions: are the matching options to be passed to the
// mozContacts API. Particularly filterBy and filterOp
// MultipleMatcher.onmatch is called when there is at least one matching
// MultipleMatcher.onmismatch is called when there are no matches
function MultipleMatcher(ptargets, pmatchingOptions) {
var next = 0;
var self = this;
var targets = ptargets;
var matchingOptions = pmatchingOptions;
var finalMatchings = {};
function doMatchBy(target, callbacks) {
var options = {
filterValue: target,
filterBy: matchingOptions.filterBy,
filterOp: matchingOptions.filterOp
};
var req = dataProvider.find(options);
req.onsuccess = function() {
var matchings = req.result.filter(function(aResult) {
return filterFacebook(aResult, matchingOptions.linkParams);
});
var filterBy = options.filterBy;
var sanitizedTarget = SimplePhoneMatcher.sanitizedNumber(target);
var targetVariants = SimplePhoneMatcher.generateVariants(target);
matchings.forEach(function(aMatching) {
if (matchingOptions.selfContactId === aMatching.id) {
return;
}
var field = options.filterBy[0];
var values = aMatching[field];
values.forEach(function(aValue) {
var value = aValue.value;
var sanitizedValue = SimplePhoneMatcher.sanitizedNumber(value);
var valueMatched = false;
if (targetVariants.length > 1) {
if (targetVariants.indexOf(sanitizedValue) !== -1) {
valueMatched = true;
}
} else if (SimplePhoneMatcher.generateVariants(sanitizedValue).
indexOf(sanitizedTarget) !== -1) {
valueMatched = true;
}
if (valueMatched) {
var matchings, matchingObj;
if (!finalMatchings[aMatching.id]) {
matchingObj = {
matchings: {},
matchingContact: aMatching
};
finalMatchings[aMatching.id] = matchingObj;
matchingObj.matchings[filterBy[0]] = [];
}
else {
matchingObj = finalMatchings[aMatching.id];
}
matchings = matchingObj.matchings[filterBy[0]];
// Avoinding to report multiple matchings due to variants
var sameValueMatchings = matchings.filter(function(matching) {
return (matching.matchedValue === value);
});
if (sameValueMatchings.length === 0) {
matchings.push({
'target': target,
'matchedValue': value
});
}
}
});
}); // matchings.forEach
carryOn();
}; // onsuccess
req.onerror = function(e) {
window.console.error('Error while trying to do the matching',
e.target.error.name);
notifyMismatch(self);
};
}
function carryOn() {
next++;
if (next < targets.length) {
doMatchBy(targets[next], callbacks);
}
else if (Object.keys(finalMatchings).length > 0) {
notifyMatch(self, finalMatchings);
}
else {
notifyMismatch(self);
}
}
function matched(contacts) {
carryOn();
}
var callbacks = {
onmatch: matched,
onmismatch: carryOn
};
this.start = function() {
doMatchBy(targets[0], callbacks);
};
}
function notifyMatch(obj, matchings) {
typeof obj.onmatch === 'function' && obj.onmatch(matchings);
}
function notifyMismatch(obj) {
typeof obj.onmismatch === 'function' && obj.onmismatch();
}
// Match a Contact by the field and filter specified as parameters
// Callbacks is an object that declares onmatch and onmismatch callbacks
function matchBy(aContact, field, filterOper, callbacks, poptions) {
var options = poptions || {};
var values = [];
if (Array.isArray(aContact[field])) {
aContact[field].forEach(function(aField) {
if (field === 'tel') {
// Bug 924378 Contacts API does not properly match phone numbers
// and their variants
var variants = SimplePhoneMatcher.generateVariants(aField.value);
variants.forEach(function(aVariant) {
values.push(aVariant);
});
}
else if (typeof aField.value === 'string') {
values.push(aField.value.trim());
}
});
}
if (values.length > 0) {
var matcher = new MultipleMatcher(values, {
filterBy: [field],
filterOp: filterOper,
selfContactId: aContact.id,
linkParams: options
});
matcher.onmatch = callbacks.onmatch;
matcher.onmismatch = callbacks.onmismatch;
matcher.start();
}
else {
notifyMismatch(callbacks);
}
}
function matchByTel(aContact, callbacks, options) {
matchBy(aContact, 'tel', 'match', callbacks, options);
}
function matchByEmail(aContact, callbacks, options) {
matchBy(aContact, 'email', 'startsWith', callbacks, options);
}
// Performs a matching for an incoming contact 'aContact' and the mode
// ['active', passive'] specified as parameter. Callbacks is an object
// with the onmatch and onmismatch functions that will be called after
// the matching process has finished
function doMatch(aContact, mode, callbacks) {
if (mode === 'passive') {
doMatchPassive(aContact, callbacks);
}
else if (mode === 'active') {
doMatchActive(aContact, callbacks);
}
}
// Implements the active mode 'matching'
function doMatchTelAndEmail(aContact, callbacks, options) {
var localCbs = {
onmatch: function(telMatches) {
var matchCbs = {
onmatch: function(mailMatches) {
// Have a unique set of matches
var allMatches = telMatches;
Object.keys(mailMatches).forEach(function(aMatch) {
if (!allMatches[aMatch]) {
allMatches[aMatch] = mailMatches[aMatch];
}
else {
allMatches[aMatch].matchings.email =
mailMatches[aMatch].matchings.email;
}
});
notifyMatch(callbacks, allMatches);
},
onmismatch: function() {
notifyMatch(callbacks, telMatches);
}
};
matchByEmail(aContact, matchCbs, options);
},
onmismatch: function() {
matchByEmail(aContact, callbacks, options);
}
};
matchByTel(aContact, localCbs, options);
}
function getSourceSimUrl(aContact) {
var out;
if (Array.isArray(aContact.category) &&
aContact.category.indexOf('sim') !== -1 &&
Array.isArray(aContact.url)) {
for (var j = 0; j < aContact.url.length; j++) {
var aUrl = aContact.url[j];
if (aUrl.type.indexOf('source') !== -1 &&
aUrl.type.indexOf('sim') !== -1) {
out = aUrl.value;
}
}
}
return out;
}
// Implements the silent mode matching
function doMatchPassive(aContact, callbacks) {
if (!hasName(aContact)) {
notifyMismatch(callbacks);
return;
}
// SIM Contacts are detected and matched accordingly
// We try to match by name and then try to see if they correspond to
// the same SIM contact. In that case matching is reported
var simUrl = getSourceSimUrl(aContact);
if (simUrl) {
performPassiveMatchForSimContact(simUrl, aContact, callbacks);
}
else {
performPassiveMatch(aContact, callbacks);
}
}
function performPassiveMatchForSimContact(simUrl, aContact, callbacks) {
var localCbs = {
onmatch: function(results) {
var matchingFound = false;
Object.keys(results).forEach(function(aResultId) {
var matchingContact = results[aResultId].matchingContact;
var matchingUrl = getSourceSimUrl(matchingContact);
if (matchingUrl && matchingUrl === simUrl) {
matchingFound = true;
var matchings = {};
matchings[aResultId] = {
matchingContact: matchingContact
};
callbacks.onmatch(matchings);
return;
}
});
if (!matchingFound) {
performPassiveMatch(aContact, callbacks);
}
},
onmismatch: function() {
performPassiveMatch(aContact, callbacks);
}
};
// Matching by name and then see if they have the same SIM contact source
matchByName(aContact, localCbs);
}
// Implements the silent mode matching
function performPassiveMatch(aContact, callbacks) {
var matchingsFound = {};
var localCbs = {
onmatch: function(results) {
// Results will contain contacts that match by tel or email
// Normalizing the strings
var names = [];
Object.keys(results).forEach(function(aResultId) {
var mContact = results[aResultId].matchingContact;
if (!hasName(mContact)) {
return;
}
// As the number of candidates here will be short a normal search
// will be conducted
var targetFN = null;
if (!isEmptyStr(aContact.familyName)) {
targetFN = Normalizer.toAscii(
aContact.familyName[0].trim().toLowerCase()).
replace(blankRegExp, '');
}
var targetGN = null;
if (!isEmptyStr(aContact.givenName)) {
targetGN = Normalizer.toAscii(
aContact.givenName[0].trim().toLowerCase()).
replace(blankRegExp, '');
}
// To support seamless matching of SIM contacts
var targetName = (targetGN || '') + (targetFN || '');
var mFamilyName = null;
var mGivenName = null;
if (!isEmptyStr(mContact.familyName)) {
mFamilyName = Normalizer.toAscii(
mContact.familyName[0].trim().toLowerCase()
).replace(blankRegExp, '');
}
if (!isEmptyStr(mContact.givenName)) {
mGivenName = Normalizer.toAscii(
mContact.givenName[0].trim().toLowerCase()
).replace(blankRegExp, '');
}
// To support seamless matching of SIM contacts
var mName = (mGivenName || '') + (mFamilyName || '');
names.push({
contact: mContact,
familyName: mFamilyName,
givenName: mGivenName,
name: mName
});
var matchingList = names.filter(function(obj) {
return ((obj.familyName === targetFN &&
obj.givenName === targetGN) ||
(obj.name && obj.name === targetName) && (
!Array.isArray(obj.contact.category) ||
obj.contact.category.indexOf(FB_CATEGORY) === -1));
});
matchingList.forEach(function(aMatching) {
var contact = aMatching.contact;
matchingsFound[contact.id] = {
matchingContact: contact
};
});
});
reconcileResults(aContact, matchingsFound, results, callbacks);
},
onmismatch: function() {
notifyMismatch(callbacks);
}
};
// Matching by email and phone number, then match by names
doMatchTelAndEmail(aContact, localCbs);
}
function doMatchActive(aContact, callbacks) {
var options = {
linkedMatched: {},
linkedTo: getLinkedTo(aContact)
};
var localCbs = {
onmatch: function(results) {
var cbsName = {
onmatch: function(nameResults) {
Object.keys(nameResults).forEach(function(aId) {
if (!results[aId]) {
results[aId] = nameResults[aId];
}
else {
results[aId].matchings.name =
nameResults[aId].matchings.name;
}
});
notifyMatch(callbacks, results);
},
onmismatch: function() {
notifyMatch(callbacks, results);
}
};
matchByName(aContact, cbsName, options);
},
onmismatch: function() {
matchByName(aContact, callbacks, options);
}
};
// Matching by email and phone number, then match by names
doMatchTelAndEmail(aContact, localCbs, options);
}
function notifyFindNameReady() {
document.dispatchEvent(new CustomEvent('by_name_ready'));
}
// Used for the active mode. Performs matching by familyName and givenName
function matchByName(aContact, callbacks, options) {
// First we try to find by familyName
// Afterwards we search by givenName
var isSimContact = (Array.isArray(aContact.category) &&
aContact.category.indexOf('sim') !== -1);
if ((isEmptyStr(aContact.familyName) || isEmptyStr(aContact.givenName)) &&
!isSimContact) {
notifyMismatch(callbacks);
return;
}
var finalResult = {};
var resultsByName = [];
if (!isEmptyStr(aContact.name)) {
var targetName = aContact.name[0].trim();
// Filter by familyName using startsWith. Gecko 'startsWith' operation
// acts as 'equal' but does not match case.
var reqName = dataProvider.find({
filterValue: targetName,
filterBy: ['name'],
filterOp: 'startsWith'
});
reqName.onsuccess = function() {
resultsByName = reqName.result.filter(function(aResult) {
return filterFacebook(aResult, options);
});
notifyFindNameReady();
};
reqName.onerror = function(e) {
window.console.warn('Error while trying to find by name: ',
e.target.error.name);
notifyFindNameReady();
};
}
else {
notifyFindNameReady();
}
if (isEmptyStr(aContact.familyName)) {
endOfMatchByName(finalResult, aContact, resultsByName, callbacks);
}
else {
var targetFamilyName = aContact.familyName[0].trim();
// Filter by familyName using startsWith. Gecko 'startsWith' operation
// acts as 'equal' but does not match case.
var reqFamilyName = dataProvider.find({
filterValue: targetFamilyName,
filterBy: ['familyName'],
filterOp: 'startsWith'
});
reqFamilyName.onsuccess = function() {
var results = reqFamilyName.result;
var givenNames = [];
var targetGN = null;
if (!isEmptyStr(aContact.givenName)) {
targetGN = Normalizer.toAscii(
aContact.givenName[0].trim().toLowerCase()).
replace(blankRegExp, '');
}
results.forEach(function(mContact) {
if (mContact.id === aContact.id || isEmptyStr(mContact.givenName)) {
return;
}
givenNames.push({
contact: mContact,
givenName: Normalizer.toAscii(
mContact.givenName[0].trim().toLowerCase()).
replace(blankRegExp, '')
});
});
var finalMatchings = givenNames.filter(function(obj) {
var gn = obj.givenName;
return ((gn === targetGN || targetGN.startsWith(gn) ||
gn.startsWith(targetGN)) &&
filterFacebook(obj.contact, options));
});
finalMatchings.forEach(function(aMatching) {
finalResult[aMatching.contact.id] = {
matchings: {
'name': [{
target: targetFamilyName,
matchedValue: Array.isArray(aMatching.contact.name) ?
aMatching.contact.name[0] :
getCompleteName(aMatching.contact)
}]
},
matchingContact: aMatching.contact
};
});
if (resultsByName) {
endOfMatchByName(finalResult, aContact, resultsByName, callbacks);
}
else {
document.addEventListener('by_name_ready', function nameReady() {
document.removeEventListener('by_name_ready', nameReady);
endOfMatchByName(finalResult, aContact, resultsByName, callbacks);
});
}
};
reqFamilyName.onerror = function(e) {
window.console.error('Error while trying to find by familyName: ',
e.target.error.name);
notifyMismatch(callbacks);
};
}
}
function getLinkedTo(contact) {
var out = null;
if (Array.isArray(contact.category)) {
var idx = contact.category.indexOf(FB_LINKED);
if (idx !== -1) {
out = contact.category[idx + 1];
}
}
return out;
}
function isFbLinked(contact) {
return (Array.isArray(contact.category) &&
contact.category.indexOf(FB_LINKED) !== -1);
}
function isFbContact(contact) {
return (Array.isArray(contact.category) &&
contact.category.indexOf(FB_CATEGORY) !== -1);
}
function filterFacebook(contact, linkParams) {
var out = false;
if (!isFbContact(contact)) {
out = true;
}
else if (isFbLinked(contact)) {
var linkedTo = getLinkedTo(contact);
var targetUid = linkParams.linkedTo || '';
var linkedMatched = linkParams.linkedMatched || {};
if (targetUid === linkedTo) {
out = true;
}
// It is only allowed to match one linked contact or various but all of
// them linked to the same FB friend
else if ((Object.keys(linkedMatched).length === 0 ||
linkedMatched[linkedTo]) && !targetUid) {
linkedMatched[linkedTo] = linkedTo;
out = true;
}
}
return out;
}
function endOfMatchByName(finalResult, aContact, resultsByName, callbacks) {
resultsByName.forEach(function(aResult) {
if (aResult.id === aContact.id) {
return;
}
var matchingObj = {
matchings: {
'name': [{
target: (Array.isArray(aContact.name) &&
(typeof aContact.name[0] === 'string')) ?
aContact.name[0].trim() : getCompleteName(aContact),
matchedValue: Array.isArray(aResult.name) ?
aResult.name[0] :
getCompleteName(aResult)
}]
},
matchingContact: aResult
};
if (!finalResult[aResult.id]) {
finalResult[aResult.id] = matchingObj;
}
else {
finalResult[aResult.id].matchings.name =
matchingObj.matchings.name;
}
});
if (Object.keys(finalResult).length > 0) {
notifyMatch(callbacks, finalResult);
}
else {
notifyMismatch(callbacks);
}
}
function getCompleteName(contact) {
var givenName = Array.isArray(contact.givenName) ?
contact.givenName[0] : '';
var familyName = Array.isArray(contact.familyName) ?
contact.familyName[0] : '';
var completeName = givenName && familyName ?
givenName + ' ' + familyName :
givenName || familyName;
return completeName;
}
function isEmpty(collection) {
return ((!Array.isArray(collection) ||
!collection[0]) ||
(collection[0] &&
collection[0].value &&
!collection[0].value.trim()));
}
function isEmptyStr(collection) {
return (!Array.isArray(collection) ||
(typeof collection[0] !== 'string') || !(collection[0].trim()));
}
function hasName(aContact) {
return (!isEmptyStr(aContact.givenName) ||
!isEmptyStr(aContact.familyName));
}
function reconcileResults(incomingContact, nameMatches, phoneMailMatches,
callbacks) {
var finalMatchings = {};
// Name matches drive all the process
Object.keys(nameMatches).forEach(function(aNameMatching) {
var matchingContact = nameMatches[aNameMatching].matchingContact;
var isPhoneMatching = Array.isArray(phoneMailMatches[aNameMatching].
matchings.tel);
var isMailMatching = Array.isArray(phoneMailMatches[aNameMatching].
matchings.email);
// Three cases under which a matching is considered
if (isPhoneMatching && isMailMatching) {
finalMatchings[aNameMatching] = phoneMailMatches[aNameMatching];
}
else if (isPhoneMatching && (isEmpty(incomingContact.email) ||
isEmpty(matchingContact.email))) {
finalMatchings[aNameMatching] = phoneMailMatches[aNameMatching];
}
else if (isMailMatching && (isEmpty(incomingContact.tel) ||
isEmpty(matchingContact.tel))) {
finalMatchings[aNameMatching] = phoneMailMatches[aNameMatching];
}
});
if (Object.keys(finalMatchings).length > 0) {
notifyMatch(callbacks, finalMatchings);
}
else {
notifyMismatch(callbacks);
}
}
// Exported method
this.match = doMatch;
Object.defineProperty(this, 'dataProvider', {
set: function(theProvider) {
dataProvider = theProvider;
}
});
}
exports.Matcher = new MatcherObj(navigator.mozContacts);
}(window));