1 /* ***** BEGIN LICENSE BLOCK *****
  2  * Version: MPL 1.1/GPL 2.0/LGPL 2.1
  3  *
  4  * The contents of this file are subject to the Mozilla Public License Version
  5  * 1.1 (the "License"); you may not use this file except in compliance with
  6  * the License. You may obtain a copy of the License at
  7  * http://www.mozilla.org/MPL/
  8  *
  9  * Software distributed under the License is distributed on an "AS IS" basis,
 10  * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 11  * for the specific language governing rights and limitations under the
 12  * License.
 13  *
 14  * The Original Code is gContactSync.
 15  *
 16  * The Initial Developer of the Original Code is
 17  * Josh Geenen <gcontactsync@pirules.org>.
 18  * Portions created by the Initial Developer are Copyright (C) 2010
 19  * the Initial Developer. All Rights Reserved.
 20  *
 21  * Contributor(s):
 22  *
 23  * Alternatively, the contents of this file may be used under the terms of
 24  * either the GNU General Public License Version 2 or later (the "GPL"), or
 25  * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 26  * in which case the provisions of the GPL or the LGPL are applicable instead
 27  * of those above. If you wish to allow use of your version of this file only
 28  * under the terms of either the GPL or the LGPL, and not to allow others to
 29  * use your version of this file under the terms of the MPL, indicate your
 30  * decision by deleting the provisions above and replace them with the notice
 31  * and other provisions required by the GPL or the LGPL. If you do not delete
 32  * the provisions above, a recipient may use your version of this file under
 33  * the terms of any one of the MPL, the GPL or the LGPL.
 34  *
 35  * ***** END LICENSE BLOCK ***** */
 36 
 37 if (!com) var com = {}; // A generic wrapper variable
 38 // A wrapper for all GCS functions and variables
 39 if (!com.gContactSync) com.gContactSync = {};
 40 
 41 /**
 42  * This class is used to import contacts using OAuth.
 43  * This requires some interaction with a remote website (pirules.org) for
 44  * authentication.
 45  * 
 46  * pirules.org stores the following information for each source
 47  *  - oauth_consumer_key
 48  *  - oauth_consumer_secret
 49  *  - base API URL
 50  *  - @me/@self URL
 51  *  - @me/@all or @me/@friends URL
 52  * etc.
 53  * It also reorganizes and signs the parameters.
 54  * 
 55  * TODO List:
 56  *  - For certain sources attempt to get more information
 57  *  - Add error detection and handling
 58  *  - Use HttpRequest
 59  * 
 60  * @class
 61  */
 62 com.gContactSync.Import = {
 63   /** The 'source' from which contacts are imported (Plaxo, Google, etc.) */
 64   mSource: "",
 65   /** This is used internally to track whether an import is in progress */
 66   mStarted: false,
 67   /** A reference to the window TODO remove */
 68   mWindow: {},
 69   /** Map for Plaxo only */
 70   mMapplaxo: {
 71     /** The user's ID */
 72     id:            "PlaxoID",
 73     /** An array of the user's photos */
 74     photos:        "PlaxoPhotos"
 75   },
 76   /** Map for MySpace only */
 77   mMapmyspace: {
 78     /** The user's MySpace ID */
 79     id:            "MySpaceID",
 80     /**
 81      * The 'nickname' (MySpace only).  This is mapped w/ DisplayName because it
 82      * is basically all that MySpace gives.
 83      */
 84     nickname:      "DisplayName",
 85     /** The user's thumbnail */
 86     thumbnailUrl:  "MySpaceThumbnail",
 87     /** The URL to the contact's profile */
 88     profileUrl:    "WebPage2"
 89   },
 90   /** Map for Facebook only */
 91   mMapfacebook: {
 92     /** Name is a simple attribute */
 93     name:          "DisplayName",
 94     /** ID is also a simple attribute */
 95     id:            "FacebookID",
 96     /** A link to the user's Facebook profile */
 97     link:          "WebPage1",
 98     /** A link to the user's website */
 99     website:       "WebPage2",
100     /** The user's 'About' text */
101     about:         "Notes",
102     /** The user's public profile photo */
103     //picture:       "FacebookProfilePhoto",
104     /** The user's hometown */
105     hometown: {
106       /** The name of the contact's hometown */
107       name: "Hometown"
108     },
109     /** The contact's current location */
110     location: {
111       /** The name of the contact's current location */
112       name: "Location"
113     },
114     /** An array of a user's job history */
115     work: {
116       /** The most recent job */
117       0: "",
118       /** The second most recent job */
119       1: "Second",
120       /** The third most recent job */
121       2: "Third",
122       /** Employer information (name and Facebook ID) */
123       employer: {
124         /** The name of the company */
125         name:      "Company"
126       },
127       /** Contact's position in the company */
128       position: {
129         /** The name of the contact's position in the company */
130         name:      "JobTitle"
131       },
132       /** The date when the contact started working for the company */
133       start_date:  "WorkStartDate",
134       /** The date when the contact stopped working for the company */
135       end_date:    "WorkEndDate"
136     }
137   },
138   /** Maps Portable Contacts attributes to TB nsIAbCard attributes */
139   mMap: {
140     /** name is complex */
141     name: {
142       /** The given name for a contact */
143       givenName:   "FirstName",
144       /** The contact's last name */
145       familyName:  "LastName",
146       /** A contact's formatted name */
147       formatted:   "DisplayName",
148       /** A contact's display name */
149       displayName: "DisplayName"
150     },
151     /** The gender of the contact */
152     gender:        "Gender",
153     /** The contact's first (given) name */
154     first_name:    "FirstName",
155     /** The contact's last (family) name */
156     last_name:     "LastName",
157     /** A contact's display name */
158     displayName:   "DisplayName",
159     /** The contact's nickname (alias) */
160     nickName:      "NickName",
161     /** emails is an array of a contact's e-mail addresses */
162     emails: {
163       /** The prefix for the first e-mail address */
164       0:           "Primary",
165       /** The prefix for the second e-mail address */
166       1:           "Secondary",
167       /** The prefix for the third e-mail address */
168       2:           "Third",
169       /** The prefix for the fourth e-mail address */
170       3:           "Fourth",
171       /** The prefix for the fifth e-mail address */
172       4:           "Fifth",
173       /** The suffix for an e-mail address */
174       value:       "Email",
175       /** The suffix for an e-mail's type (work, home, etc.) */
176       type:        "EmailType"
177     },
178     /**
179      * phoneNumbers is an array of a contact's phone numbers in the form:
180      * {"type":"Home","value":"(123) 456-7890"}
181      */
182     phoneNumbers: {
183       0:           "Work",
184       1:           "Home",
185       2:           "Fax",
186       3:           "Cell",
187       4:           "Pager",
188       value:       "Phone", // note that TB is inconsistent here
189                             // {Home|Work}Phone and {Fax|Cellular|Pager}Number
190       type:        "PhoneType"
191     },
192     /**
193      * addresses is an array of a contact's postal addresses in the form:
194      * {"type":"Home","formatted":"1234 Main St"}
195      */
196     addresses: {
197       0:           "",
198       1:           "",
199       2:           "",
200       type:        "",
201       formatted:   "<type>Address"
202     },
203     /**
204      * Links to a user's websites.
205      */
206     urls: {
207       0:           "WebPage1",
208       1:           "WebPage2",
209       type:        "Type",
210       value:       ""
211     }
212   },
213   /** Commands to execute when offline during an HTTP Request */
214   mOfflineFunction: function Import_offlineFunc(httpReq) {
215     com.gContactSync.alertError(com.gContactSync.StringBundle.getStr('importOffline'));
216     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('offlineImportStatusText'));
217   },
218   /**
219    * Stores <em>encoded</em> OAuth variables, such as the oauth_token,
220    * oauth_token_secret, and oauth_verifier
221    */
222   mOAuth: {
223     /** The OAuth token to use in requests */
224     oauth_token:        "",
225     /** The OAuth token secret to use in signing request parameters */
226     oauth_token_secret: "",
227     /** The OAuth verifier for OAuth version 1.0a */
228     oauth_verifier:     "",
229     /** The access token (OAuth version 2.0) */
230     access_token:       "",
231     /** The expiration time (OAuth version 2.0) */
232     expires:            ""
233   },
234   /**
235    * Step 1: Get an initial, unauthorized oauth_token and oauth_token_secret.
236    * This is done mostly on pirules.org which contains the consumer token and
237    * secret for various sources and signs the parameters.
238    * pirules.org returns the response from the source, usually of the form:
239    * oauth_token=1234&oauth_token_secret=5678
240    *
241    * @param aSource {string} The source from which the contacts are obtained,
242    *                         in lowercase, as supported by pirules.org.
243    */
244   step1: function Import_step1(aSource) {
245     var imp      = com.gContactSync.Import,
246         callback = aSource == "facebook" ? imp.step2b : imp.step2a;
247     if (imp.mStarted) {
248       // TODO warn the user and allow him or her to cancel
249     }
250     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('startingImport'));
251     imp.mStarted = true;
252     imp.mSource  = aSource;
253     // get an oauth_token and oauth_token_secret
254     imp.openBrowserWindow("http://www.pirules.org/oauth/index2.php?quiet&step=1&source=" +
255                           imp.mSource,
256                           callback);
257   },
258   /**
259    * Step 2a: The first of two substeps where the user is prompted for his or
260    * her credentials on the third-party website.
261    * In this substep, gContactSync gets the login URL from pirules.org with
262    * all it's parameters and the oauth_signature.
263    * This is done in step 1 for OAuth 2.0 (Facebook only at the moment).
264    */
265   step2a: function Import_step2a() {
266     var imp = com.gContactSync.Import,
267         win = imp.mWindow,
268         response = win.document ? win.document.getElementById("response") : null;
269     if (!response) {
270       com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importFailed'));
271       com.gContactSync.LOGGER.LOG_ERROR("***Import failed to get the auth tokens");
272       com.gContactSync.LOGGER.LOG(win.document ? win.document.innerHTML : "window has no document");
273       return;
274     }
275     response   = response.innerHTML;
276     com.gContactSync.LOGGER.LOG("***IMPORT: Step 1 finished: " + win.location +
277                                 "\nContents:\n" + response);
278     // parse and store the parameters from step 1 (oauth_token &
279     // oauth_token_secret)
280     imp.storeResponse(response);
281     // TODO use HttpRequest
282     imp.openBrowserWindow("http://www.pirules.org/oauth/index2.php?quiet&step=2&source=" +
283                          imp.mSource +
284                          "&oauth_token=" + imp.mOAuth.oauth_token +
285                          "&oauth_token_secret=" + imp.mOAuth.oauth_token_secret,
286                          imp.step2b);
287   },
288   /**
289    * Step 2a: The second of two substeps where the user is prompted for his or
290    * her credentials on the third-party website.
291    * In this substep, gContactSync opens a browser to the login page for the
292    * particular source.
293    */
294   step2b: function Import_step2b() {
295     var imp = com.gContactSync.Import,
296         win = imp.mWindow,
297         response = win.document ? win.document.getElementById("response") : null;
298     if (!response) {
299       com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importFailed'));
300       com.gContactSync.LOGGER.LOG_ERROR("***Import failed to get the login URL");
301       com.gContactSync.LOGGER.LOG(win.document ? win.document.innerHTML : "window has no document");
302       return;
303     }
304     response   = String(response.innerHTML).replace(/\&\;/g, "&");
305     com.gContactSync.LOGGER.LOG("***IMPORT: Step 2a finished: " + win.location + "\nContents:\n" + response);
306     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importRequestingCredentials'));
307     imp.openBrowserWindow(response, imp.logStep2b);
308   },
309   /**
310    * Step 2b: The second of two substeps where the user is prompted for his or
311    * her credentials on the third-party website.
312    * This just logs that step 2b has finished (the login page was opened)
313    */
314   logStep2b: function Import_logStep2b() {
315     var win = com.gContactSync.Import.mWindow;
316     com.gContactSync.LOGGER.LOG("***IMPORT: Step 2b finished: " + win.location +
317                                 "Please click Finish Import to continue");
318   },
319   /**
320    * Step 3: Gets the new oauth_token then activates the token.
321    * This step must be initiated by the user (for now).
322    * TODO - find a way to automatically start step3 when possible.
323    */
324   step3: function Import_step3() {
325     var imp = com.gContactSync.Import;
326     if (!imp.mStarted) {
327       com.gContactSync.alertError(com.gContactSync.StringBundle.getStr("importNotStarted"));
328       return;
329     }
330     // Get the new oauth_token from the window.
331     imp.mOAuth.oauth_token = encodeURIComponent(imp.mWindow.document.getElementById('response').innerHTML);
332     imp.mWindow.close();
333     if (!imp.mOAuth.oauth_token) {
334       com.gContactSync.alert(com.gContactSync.StringBundle.getStr('importCanceled'),
335                              com.gContactSync.StringBundle.getStr('importCanceledTitle'),
336                              window);
337       imp.mStarted = false;
338       return;
339     }
340     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importActivatingToken'));
341     // activate the token
342     // TODO use HttpRequest
343     imp.openBrowserWindow("http://www.pirules.org/oauth/index2.php?quiet&step=3&source=" +
344                          imp.mSource +
345                          "&oauth_token=" + imp.mOAuth.oauth_token +
346                          "&oauth_token_secret=" + imp.mOAuth.oauth_token_secret,
347                          imp.step4);
348   },
349   /**
350    * Step 4: Use the token to fetch the user's contacts.
351    * This sends a request and the token/token secret to pirules.org which
352    * signs and sends the request to the source's @me/@friend URL.
353    */
354   step4: function Import_step4() {
355     var imp = com.gContactSync.Import,
356         win = imp.mWindow,
357         response = win.document ? win.document.getElementById("response") : null;
358     if (!response) {
359       com.gContactSync.LOGGER.LOG("***Import failed on step 3");
360       return;
361     }
362     response = response.innerHTML;
363     com.gContactSync.LOGGER.LOG("***IMPORT: Step 3 finished: " + win.location + "\nContents:\n" + response);
364     imp.storeResponse(response);
365     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importRetrievingContacts'));
366     // Use the token to fetch the user's contacts
367     // TODO use HttpRequest
368     // access_token is used instead of the oauth_token in OAuth 2.0
369     if (imp.mOAuth.access_token) {
370       imp.openBrowserWindow("http://www.pirules.org/oauth/index2.php?quiet&step=4&source=" +
371                            imp.mSource +
372                            "&access_token=" + imp.mOAuth.access_token,
373                            imp.finish);
374     }
375     else {
376       imp.openBrowserWindow("http://www.pirules.org/oauth/index2.php?quiet&step=4&source=" +
377                            imp.mSource +
378                            "&oauth_token=" + imp.mOAuth.oauth_token +
379                            "&oauth_token_secret=" + imp.mOAuth.oauth_token_secret,
380                            imp.finish);
381     }
382   },
383   /**
384    * Gets the response from step 4 and calls beginImport to parse the JSON feed
385    * of contacts.
386    */
387   // Get the contact feed and import it into an AB
388   finish: function Import_finish() {
389     var imp = com.gContactSync.Import,
390         win = imp.mWindow;
391     // get the contacts feed
392     var response = win.document ? win.document.getElementById("response") : null;
393     if (!response) {
394       com.gContactSync.LOGGER.LOG("***Import failed on step 4");
395       return;
396     }
397     response = response.innerHTML;
398     com.gContactSync.LOGGER.LOG("Final response:\n" + response);
399     imp.mStarted = false;
400     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importParsingContacts'));
401     // start the import
402     imp.beginImport(response);
403   },
404   /**
405    * Parses and stores a URL-encoded response in the following format:
406    * param1=value1&param2=value2&param3=value3...
407    * The parsed parameters and values are stored (still encoded) in
408    * com.gContactSync.Import.mOAuth[param] = value;
409    *
410    * @param aResponse {string} The encoded response to parse.
411    */
412   storeResponse: function Import_storeResponse(aResponse) {
413     var imp    = com.gContactSync.Import,
414         params = (aResponse).split("&");
415     for (var i = 0; i < params.length; i++) {
416       var index = params[i].indexOf("=");
417       if (index > 0) {
418         var param = params[i].substr(0, index),
419             value = params[i].substr(index + 1);
420         com.gContactSync.LOGGER.VERBOSE_LOG("***" + param + "=>" + value);
421         imp.mOAuth[param] = value;
422       }
423     }
424   },
425   /**
426    * Opens a window at the given URL and optionally sets an onbeforeunload
427    * listener.
428    *
429    * @param aUrl {string} The URL to open.
430    * @param aBeforeUnload {function} The function to run before the window is
431    *                                 unloaded.
432    */
433   openBrowserWindow: function Import_openBrowserWindow(aUrl, aBeforeUnload) {
434     var imp = com.gContactSync.Import;
435     com.gContactSync.LOGGER.LOG("***IMPORT: opening '" + aUrl + "'");
436     // TODO - find a way to show a location bar, allow context menus, etc.
437     imp.mWindow = window.open(aUrl,
438                               "gContactSyncImport" + aUrl,
439                               "chrome=yes,location=yes,resizable=yes,height=500,width=500,modal=no");
440     if (aBeforeUnload) {
441       imp.mWindow.onbeforeunload = aBeforeUnload;
442     }
443   },
444   /**
445    * Begins the actual import given a JSON feed of contacts.
446    * It promps the user for a name for the destination AB (can be new or old).
447    *
448    * @param aFeed {string} The JSON feed of contacts to parse.
449    */
450   beginImport: function Import_beginImport(aFeed) {
451     if (!aFeed) {
452       return;
453     }
454     try {
455       com.gContactSync.LOGGER.VERBOSE_LOG(aFeed);
456       var res = com.gContactSync.prompt(com.gContactSync.StringBundle.getStr("importDestination"),
457                                         com.gContactSync.StringBundle.getStr("importDestinationTitle"),
458                                         window);
459       if (!res) {
460         return;
461       }
462       var ab = new com.gContactSync.GAddressBook(com.gContactSync.GAbManager.getAbByName(res),
463                                                  true);
464       // decode the JSON and get the array of cards
465       var nsIJSON = Components.classes["@mozilla.org/dom/json;1"]
466                               .createInstance(Components.interfaces.nsIJSON);
467       try {
468         var obj = nsIJSON.decode(aFeed);
469       }
470       catch (e) {
471         com.gContactSync.alertError(aFeed);
472         return;
473       }
474       var arr = obj.entry || obj.data;
475       for (var i in arr) {
476         var contact = arr[i],
477             id = contact.id;
478         if (id) {
479           var newCard = ab.newContact(),
480               attr    = "";
481           // Download FB photos
482           if (this.mSource === "facebook") {
483             var file = com.gContactSync.writePhoto("https://graph.facebook.com/" + id + "/picture?type=large",
484                                                    id + "_" + (new Date()).getTime());
485             if (file) {
486               com.gContactSync.LOGGER.VERBOSE_LOG("Wrote photo...name: " + file.leafName);
487               newCard.setValue("PhotoName", file.leafName);
488               newCard.setValue("PhotoType", "file");
489               newCard.setValue("PhotoURI",
490                                Components.classes["@mozilla.org/network/io-service;1"]
491                                          .getService(Components.interfaces.nsIIOService)
492                                          .newFileURI(file)
493                                          .spec);
494             }
495           }
496           // Iterate through each attribute in the JSON contact
497           for (var j in contact) {
498             // If there is a map for just this source, check it for the
499             // attribute first, otherwise just use the default map.
500             if (this["mMap" + this.mSource])
501               attr = this["mMap" + this.mSource][j] || this.mMap[j];
502             else
503               attr = this.mMap[j];
504             if (attr) {
505               // Download a photo of the user, if available.
506               if (j === "picture" || j === "thumbnailUrl" || j === "photos") {
507                 var file = com.gContactSync.writePhoto((j === "photos" ? contact[j][0].value : contact[j]),
508                                                        this.mSource + "_" + id,
509                                                        0);
510                 if (file) {
511                   com.gContactSync.LOGGER.VERBOSE_LOG("Wrote photo...name: " + file.leafName);
512                   newCard.setValue("PhotoName", file.leafName);
513                   newCard.setValue("PhotoType", "file");
514                   newCard.setValue("PhotoURI",
515                                    Components.classes["@mozilla.org/network/io-service;1"]
516                                              .getService(Components.interfaces.nsIIOService)
517                                              .newFileURI(file)
518                                              .spec);
519                 }
520               }
521               // when contact[j] is an Array things are a bit more
522               // complicated
523               else if (contact[j] instanceof Array) {
524                 // emails: [
525                 //   {email: somebody@somwhere, type: work},
526                 //   {email: somebody2@somwhere, type: work}
527                 // ]
528                 // contact[j]    = emails[]
529                 // contact[j][k] = emails[k]
530                 for (var k = 0; k < contact[j].length; k++) {
531                   if (!attr[k]) break;
532                   // contact[j][k][l] = sombody@somewhere
533                   for (var l in contact[j][k]) {
534                     if (attr[l]) {
535                       var type = contact[j][k].type;
536                       // not all arrays can be mapped to TB fields by index
537                       // TODO - support using original phone # fields
538                       // this would require NOT storing the type...
539                       var tbAttribute = String(attr[k] + attr[l]).replace("<type>", type);
540                       // Workaround for inconsistent phone number attributes in TB
541                       if (attr === "phoneNumbers" && (type === "Cellular" || type === "Pager" || type === "Fax")) {
542                         tbAttribute = tbAttribute.replace("Phone", "Number");
543                       }
544                       // mMap[j][[k] is the prefix (Primary, Second, etc.)
545                       // mMap[j][l] is the suffix (Email)
546                       com.gContactSync.LOGGER.VERBOSE_LOG(" - (Array): " + tbAttribute + "=" + contact[j][k][l]);
547                       newCard.setValue(tbAttribute, this.decode(contact[j][k][l]));
548                     }
549                   }
550                   
551                 }
552               }
553               else if (j === "photos") {
554                 // TODO download the photo...
555                 // possibly implementation-specific
556               }
557               // if it is just a normal property (has a length property =>
558               // string) check the map
559               else if (attr.length) {
560                 com.gContactSync.LOGGER.VERBOSE_LOG(" - (String): " + attr + "=" + contact[j])
561                 newCard.setValue(attr, this.decode(contact[j]));
562               }
563               // else it is an object with subproperties
564               else {
565                 for (var k in contact[j]) {
566                   if (attr[k]) {
567                     com.gContactSync.LOGGER.VERBOSE_LOG(" - (Object): " + attr[k] + "/" + j + "=" + contact[j][k]);
568                     newCard.setValue(attr[k], this.decode(contact[j][k]));
569                   }
570                 }
571               }
572             }
573           }
574           newCard.update();
575         }
576       }
577     }
578     catch (e) {
579       com.gContactSync.alertError(e);
580       return;
581     }
582     // refresh the ab results pane
583     try {
584       if (SetAbView !== undefined) {
585         SetAbView(GetSelectedDirectory(), false);
586       }
587       
588       // select the first card, if any
589       if (gAbView && gAbView.getCardFromRow(0))
590         SelectFirstCard();
591     }
592     catch (e) {}
593     com.gContactSync.Overlay.setStatusBarText(com.gContactSync.StringBundle.getStr('importFinished'));
594     com.gContactSync.alert(com.gContactSync.StringBundle.getStr("importComplete"),
595                            com.gContactSync.StringBundle.getStr("importCompleteTitle"),
596                            window);
597   },
598   /**
599    * Decodes text returned in a JSON feed.
600    * @param aString {string} The text to decode.
601    * @returns {string} The decoded text.
602    */
603   decode: function Import_decode(aString) {
604     return aString ?
605             decodeURIComponent(aString).replace(/</g,   "<")
606                                        .replace(/>/g,   ">")
607                                        .replace(/&/g,  "&")
608                                        .replace(/"/g, '"') :
609             "";
610   }
611 };