Source

trunksyncapp / trunksync.py

Full commit
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
#!/usr/bin/env python

"""
Copyright (c) 2009, Matthew Kennard
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
    * Redistributions of source code must retain the above copyright
      notice, this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright
      notice, this list of conditions and the following disclaimer in the
      documentation and/or other materials provided with the distribution.
    * Neither the name of the <organization> nor the
      names of its contributors may be used to endorse or promote products
      derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY Matthew Kennard ''AS IS'' AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL Matthew Kennard BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
"""

"""
How the sync works:

 - Request from iPhone a list of all notes and last modification date

   e.g.

   NoteTwo 1/9/9
   NoteThree 2/9/9
   NoteFour 3/9/9
   NoteSix 5/9/9

 - Request from local directory a list of all notes and last modification date

   e.g.

   NoteOne 1/9/9
   NoteTwo 1/9/9
   NoteFour 4/9/9
   NoteFive 5/9/9
   NoteSix 5/9/9

 - Retrieve list of all notes and last modification date that was generated at the end of the last sync

   NoteOne 1/9/9
   NoteTwo 1/9/9
   NoteThree 2/9/9
   NoteFour 3/9/9
   NoteSix 4/9/9

 - Work out what has changed locally and on the iPhone

   Should be:

   NoteOne *DELETED FROM IPHONE* -> delete locally
   NoteTwo *NO CHANGE* -> do nothing
   NoteThree *DELETED FROM LOCAL DIRECTORY* -> delete from iPhone
   NoteFour *UPDATED LOCALLY* -> send new version to iPhone
   NoteFive *NEW LOCALLY* -> send new version to iPhone
   NoteSix *CONFLICT AS MODIFIED LOCALLY AND ON IPHONE* -> Ask user which version they want to keep

 - for each note from iPhone:
     * mark as NEW ON IPHONE if,
       * not in last sync list
     * mark as UPDATE ON IPHONE if,
       * in last sync list AND last modification date > last sync list
 - for each note locally:
     * mark as NEW LOCALLY if,
       * not in last sync list
     * mark as UPDATED LOCALLY if,
       * in last sync list AND last modification date > last sync list
 - for each note in last sync list:
     * mark as DELETED ON IPHONE if,
       * not in iPhone list
     * mark as DELETED LOCALLY if,
       * not in local list
 - resolve conflicts where a note exists in more than one mark list (except can be in noth DELETE ON IPHONE and DELETED LOCALLY lists)
 - update according to lists (including trigger revision control actions)
 - save new last sync list
"""

import sys
import os
import os.path
import time
import calendar
import urllib
import unicodedata
import string
import select
import logging
from getpass import getpass

import pybonjour
import httplib2

IGNORE_FILES = ['.lastsync', '.DS_Store', 'Notes & Settings']

VALID_FILENAME_CHARS = "-_.() %s%s" % (string.ascii_letters, string.digits)

logging.basicConfig(level=logging.DEBUG)

class Note(object):
    
    def __init__(self, name, last_modified, local_path=None):
        self.name = name
        self.last_modified = last_modified
        if not local_path:
            local_path = unicodedata.normalize('NFKD', unicode(name)).encode('ASCII', 'ignore')
            local_path = ''.join(c for c in local_path if c in VALID_FILENAME_CHARS) + '.txt'
        self.local_path = local_path
        self.contents = None
        
    def __cmp__(self, other_note):
        """
        Notes are the same if they have the same name. TrunkSync is case insensitive
        even though Trunk Notes is not
        """
        return cmp(self.name.lower(), other_note.name.lower())

    def __repr__(self):
        return '%s (%s)' % (self.name, self.last_modified)

    def hydrate_from_iphone(self, settings):
        logging.debug('Getting note %s from device' % (self.name, ))
        self.contents = settings.iphone_request('get_note', {'title': self.name})

    def save_to_local(self, settings):
        # Make sure that local_path is an absolute path
        if not self.local_path.startswith(settings.local_dir):
            self.local_path = os.path.join(settings.local_dir, self.local_path)
        logging.debug('Saving note to local %s' % (self.local_path, ))
        utime = time.mktime(self.last_modified)
        f = open(self.local_path, 'w')
        f.write(self.contents)
        f.close()
        # Update last modified time on file to this notes last accessed time
        self.update_time(utime)

    def update_time(self, new_time):
        os.utime(self.local_path, (new_time, new_time))

    def delete_local(self, settings):
        # Make sure that local_path is an absolute path
        if not self.local_path.startswith(settings.local_dir):
            self.local_path = os.path.join(settings.local_dir, self.local_path)
        logging.debug('Deleting %s from local %s' % (self.name, self.local_path))
        try:
            os.remove(self.local_path)
            logging.debug('%s removed' % (self.local_path, ))
        except OSError:
            try:
                # Try removing without extension
                stripped_path = self.local_path.rsplit('.', 1)[0]
                logging.debug('Deleting %s from local %s' % (self.name, stripped_path))
                os.remove(stripped_path)
                logging.debug('%s removed' % (stripped_path, ))
            except:
                pass


    def hydrate_from_local(self, settings):
        logging.debug('Getting note %s from local %s' % (self.name, self.local_path))
        self.contents = open(self.local_path, 'r').read()
        # Update the timestamp in the metadata
        new_contents = ''
        substituted_timestamp = False
        for line in self.contents.split('\n'):
            if not substituted_timestamp and line.startswith('Timestamp: '):
                # Substitute this line with the actual timestamp
                line = 'Timestamp: %s' % (time.strftime('%Y-%m-%d %H:%M:%S +0000', self.last_modified), )
                substituted_timestamp = True
            new_contents += line + '\n'
        self.contents = new_contents

    def save_to_iphone(self, settings):
        logging.debug('Saving %s to device' % (self.name, ))
        filename = os.path.basename(self.local_path)
        return settings.iphone_request('update_note', {'contents': self.contents, 'filename': filename})

    def delete_on_iphone(self, settings):
        logging.debug('Removing %s from device' % (self.name, ))
        settings.iphone_request('remove_note', {'title': self.name})



class IphoneConnectError(Exception):
    
    pass



class SyncSettings(object):

    def __init__(self, local_dir, iphone_ip, iphone_port, iphone_user, iphone_password):
        self.local_dir = local_dir
        self.iphone_ip = iphone_ip
        self.iphone_port = iphone_port
        self.iphone_user = iphone_user
        self.iphone_password = iphone_password
        self.http = None
        self.uri = None
       
    def setup_iphone_connection(self):
        self.http = httplib2.Http()
        if self.iphone_user:
            self.http.add_credentials(self.iphone_user, self.iphone_password)
        self.uri = 'http://%s:%s' % (self.iphone_ip, self.iphone_port)

    def iphone_request(self, request_type, request_data={}):
        request_dict = {}
        request_dict.update({'submit': 'sync-%s' % (request_type, )})
        request_dict.update(request_data)
        headers = {'Content-type': 'application/x-www-form-urlencoded'}
        response = self.http.request(self.uri, 'POST', headers=headers, body=urllib.urlencode(request_dict))
        if response[0]['status'] == '200':
            return response[1]
        else:
            raise IphoneConnectError, response[0]



class SyncAnalyser(object):
    
    def __init__(self, iphone_notes, local_notes, lastsync_notes, ui=None):
        """
        @param iphone_list: List of iPhone notes (note name and last modification date)
        @param local_list: List of local notes (note name and last modification date)
        @param lastsync_list: List of notes as they stood at the end of the last sync (note name and last modification date)
        @param ui: A reference to the UI being used, None if no UI
        """
        self.iphone_notes = iphone_notes
        self.local_notes = local_notes
        self.lastsync_notes = lastsync_notes
        self.ui = ui
        # List of notes marked as
        self.new_on_iphone = []
        self.updated_on_iphone = []
        self.new_locally = []
        self.updated_locally = []
        self.deleted_on_iphone = []
        self.deleted_locally = []
        
    def analyse(self):
        """
        >>> iphone_notes = [Note('NoteTwo', 1), Note('NoteThree', 2), Note('NoteFour', 3), Note('NoteSix', 5)]
        >>> local_notes = [Note('NoteOne', 1), Note('NoteTwo', 1), Note('NoteFour', 4), Note('NoteFive', 5), Note('NoteSix', 5)]
        >>> lastsync_notes = [Note('NoteOne', 1), Note('NoteTwo', 1), Note('NoteThree', 2), Note('NoteFour', 3), Note('NoteSix', 4)]
        >>> t = SyncAnalyser(iphone_notes, local_notes, lastsync_notes)
        >>> t.analyse()
        Resolve conflict: A note with the same name has been updated on both the iPhone and locally since last sync
        True
        >>> print t.new_on_iphone
        []
        >>> print t.updated_on_iphone
        [NoteSix (5)]
        >>> print t.new_locally
        [NoteFive (5)]
        >>> print t.updated_locally
        [NoteFour (4), NoteSix (5)]
        >>> print t.deleted_on_iphone
        [NoteOne (1)]
        >>> print t.deleted_locally
        [NoteThree (2)]
        """
        # - for each note from iPhone:
        #  * mark as NEW ON IPHONE if,
        #    * not in last sync list
        #  * mark as UPDATE ON IPHONE if,
        #    * in last sync list AND last modification date > last sync list
        for note in self.iphone_notes:
            if not note in self.lastsync_notes:
                self.new_on_iphone.append(note)
            else:
                i = self.lastsync_notes.index(note)
                if i >= 0 and note.last_modified > self.lastsync_notes[i].last_modified:
                    # Get the path of the note locally, so that when the local
                    # note is updated the correct file will be written to
                    i2 = self.local_notes.index(note)
                    assert i2 >= 0, 'Note mentioned in last sync but no connected local note'
                    note.local_path = self.local_notes[i2].local_path
                    self.updated_on_iphone.append(note)
        # - for each note locally:
        #     * mark as NEW LOCALLY if,
        #       * not in last sync list
        #     * mark as UPDATED LOCALLY if,
        #       * in last sync list AND last modification date > last sync list
        for note in self.local_notes:
            if not note in self.lastsync_notes:
                self.new_locally.append(note)
            else:
                i = self.lastsync_notes.index(note)
                if i >=0 and note.last_modified > self.lastsync_notes[i].last_modified:
                    self.updated_locally.append(note)
        # - for each note in last sync list:
        #     * mark as DELETED ON IPHONE if,
        #       * not in iPhone list
        #     * mark as DELETED LOCALLY if,
        #       * not in local list
        for note in self.lastsync_notes:
            if not note in self.iphone_notes:
                self.deleted_on_iphone.append(note)
            if not note in self.local_notes:
                self.deleted_locally.append(note)
        # Resolve conflicts
        for note in self.new_on_iphone:
            if note in self.new_locally:
                if self.ui:
                    answer = self.ui.resolve_conflict('%s has been created on your mobile device and locally.' % (note.name, ), ['device', 'local'])
                    if answer == 'device':
                        # User has chosen to keep one on device, so remove local note reference
                        self.new_locally.remove(note)
                    elif answer == 'local':
                        self.new_on_iphone.remove(note)
                    else:
                        assert False, 'Invalid resolve choice'
                else:
                    print 'Resolve conflict: A note with the same name has been created on both the iPhone and locally since last sync'
            assert not note in self.updated_locally, 'Note new on iPhone but updated locally'
        for note in self.updated_on_iphone:
            if note in self.updated_locally:
                if self.ui:
                    answer = self.ui.resolve_conflict('%s has been updated on your mobile device and locally.' % (note.name, ), ['device', 'local'])
                    if answer == 'device':
                        # User has chosen to keep one on device, so remove local note reference
                        self.updated_locally.remove(note)
                    elif answer == 'local':
                        self.updated_on_iphone.remove(note)
                    else:
                        assert False, 'Invalid resolve choice'
                else:
                    print 'Resolve conflict: A note with the same name has been updated on both the iPhone and locally since last sync'
            assert not note in self.new_locally, 'Note updated on iPhone but new locally'
        # Make sure that no notes which were updated locally are scheduled for deletion locally
        for note in self.updated_locally:
            if note in self.deleted_on_iphone:
                self.deleted_on_iphone.remove(note)
        # Make sure that no notes which were updated on the iphone are scheduled for deletion on the iphone
        for note in self.updated_on_iphone:
            if note in self.deleted_locally:
                self.deleted_locally.remove(note)
        return True

    

class TrunkSync(object):

    def __init__(self, ui, trunk_ip, trunk_port, local_path, last_sync_path, trunk_user=None, trunk_password=None):
        self.ui = ui
        self.trunk_ip = trunk_ip
        self.trunk_port = trunk_port
        self.local_path = local_path
        self.last_sync_path = last_sync_path
        self.settings = SyncSettings(local_path, trunk_ip, trunk_port, trunk_user, trunk_password)
        self.settings.setup_iphone_connection()

    def get_notes_from_iphone(self):
        raw_notes = self.settings.iphone_request('notes_list')
        notes = []
        for note in raw_notes.split('\n'):
            note = note.strip()
            if note:
                timestamp, title = note.split(':', 1)
                notes.append(Note(title, time.gmtime(int(timestamp))))
        return notes

    def get_notes_from_local(self):
        notes = []
        dirpath = self.settings.local_dir
        for filename in os.listdir(dirpath):
            if filename.startswith('.') or filename in IGNORE_FILES:
                continue
            note_path = os.path.join(dirpath, filename)
            # For a local note the timestamp is just the files last modified date
            last_modified = time.gmtime(os.stat(note_path).st_mtime)
            # TODO: Will above work on Windows - or does time need to be treated differently???
            # However its title is preferrably from the Title: metadata, if this does
            # not exist then it will be the filename (minus the file extension)
            f = open(note_path, 'r')
            note_name = None
            line = f.readline()
            while line:
                if line.startswith('Title: '):
                    note_name = line.split(':', 1)[1].strip()
                    break
                line = f.readline()
            f.close()
            if not note_name:
                # Remove any file extension
                note_name = filename.rsplit('.', 1)[0]
            notes.append(Note(note_name, last_modified, local_path=note_path))
        return notes

    def get_notes_from_lastsync(self):
        notes = []
        # Assuming not the first time synced with this directory
        if os.path.exists(self.last_sync_path):
            for line in open(self.last_sync_path, 'r').readlines():
                line = line.strip()
                if line:
                    timestamp, title = line.split(':', 1)
                notes.append(Note(title, time.gmtime(int(timestamp))))
        return notes

    def sync(self):
        # Get lists of notes from the three sources
        iphone_notes = self.get_notes_from_iphone()
        local_notes = self.get_notes_from_local()
        lastsync_notes = self.get_notes_from_lastsync()
        # Analyse the notes, and resolve conflicts
        analyser = SyncAnalyser(iphone_notes, local_notes, lastsync_notes, self.ui)
        if analyser.analyse():
            # Update local notes with notes from iPhone
            for note in analyser.new_on_iphone:
                note.hydrate_from_iphone(self.settings)
                note.save_to_local(self.settings)
            for note in analyser.updated_on_iphone:
                note.hydrate_from_iphone(self.settings)
                note.save_to_local(self.settings)
            for note in analyser.deleted_on_iphone:
                note.delete_local(self.settings)
            # Update iPhone notes with local changes
            for note in analyser.new_locally:
                note.hydrate_from_local(self.settings)
                new_contents = note.save_to_iphone(self.settings)
                # Since this is a note which has been created locally
                # the note will now be retrieved from the mobile device
                # and saved back locally so the Trunk Notes header
                # is in place
                if not new_contents.startswith('ERROR'):
                    note.contents = new_contents
                    # Update the notes title
                    for line in note.contents.split('\n'):
                        if line.startswith('Title: '):
                            note_name = line.split(':', 1)[1].strip()
                            note.name = note_name
                            break
                    note.save_to_local(self.settings)
                else:
                    logging.error('Saving note to device returned ERROR')
            for note in analyser.updated_locally:
                note.hydrate_from_local(self.settings)
                note.save_to_iphone(self.settings)
            for note in analyser.deleted_locally:
                note.delete_on_iphone(self.settings)
            # Finally get a raw list of notes from the iPhone
            # and save this as the lastsync file
            raw_notes = self.settings.iphone_request('notes_list')
            last_sync_file = open(self.last_sync_path, 'w').write(raw_notes)
            # Update timestamps on those notes which were new locally
            # but were replaced with versions from the iPhone
            times_from_iphone = {}
            for line in raw_notes.split('\n'):
                if ':' in line:
                    timestamp, note_name = line.split(':', 1)
                    try:
                        times_from_iphone[note_name] = int(timestamp)
                    except ValueError:
                        logging.warn('Error in timestamp for note %s' % (note_name, ))
            for note in analyser.new_locally:
                try:
                    timestamp = times_from_iphone.get(note.name)
                except:
                    timestamp = None
                if timestamp:
                    note.update_time(timestamp)
                else:
                    logging.warn('Could not update the timestamp of'
                                 'note "%s" on device' % (note.name, ))
        return True



class TrunkDeviceFinder(object):

    timeout = 5

    def __init__(self):
        self.bonjour_clients = []

    def resolve_callback(self, sdRef, flags, interfaceIndex, errorCode, fullname,
                     hosttarget, port, txtRecord):
        if errorCode == pybonjour.kDNSServiceErr_NoError:
            # Only care about TrunkNotes service
            if fullname.startswith('TrunkNotes._http._tcp'):
                self.bonjour_clients.append((fullname, hosttarget, port))

    def bonjour_search(self):
        self.browse_sdRef = pybonjour.DNSServiceBrowse(regtype='_http._tcp.',
                                                       callBack=self.browse_callback)
        try:
            while not self.bonjour_clients:
                ready = select.select([self.browse_sdRef], [], [])
                if self.browse_sdRef in ready[0]:
                    pybonjour.DNSServiceProcessResult(self.browse_sdRef)
        finally:
            self.browse_sdRef.close()

    def browse_callback(self, sdRef, flags, interfaceIndex, errorCode, serviceName,
                    regtype, replyDomain):
        if errorCode != pybonjour.kDNSServiceErr_NoError:
            return

        if not (flags & pybonjour.kDNSServiceFlagsAdd):
            return

        resolve_sdRef = pybonjour.DNSServiceResolve(0,
                                                    interfaceIndex,
                                                    serviceName,
                                                    regtype,
                                                    replyDomain,
                                                    self.resolve_callback)

        try:
            while not self.bonjour_clients:
                ready = select.select([resolve_sdRef], [], [], self.timeout)
                if resolve_sdRef not in ready[0]:
                    break
                pybonjour.DNSServiceProcessResult(resolve_sdRef)
        finally:
            resolve_sdRef.close()



class TrunkSyncSimpleUi(object):

    def __init__(self):
        self.local_path = os.path.join(os.environ['HOME'], 'trunksync')
        self.last_sync_path = os.path.join(os.environ['HOME'], '.trunksync')
        self.username = None
        self.password = None

    def find_trunk(self):
        device_finder = TrunkDeviceFinder()
        device_finder.bonjour_search()
        return device_finder.bonjour_clients

    def confirm_instance(self, instances):
        chosen_n = 0
        while not (chosen_n > 0 and chosen_n <= len(instances)):
            print 'Choose Trunk to sync with:'
            n = 0
            for instance in instances:
                n += 1
                print '%s. %s' % (n, instance[1])
            chosen_n = raw_input('Choice: ')
            try:
                chosen_n = int(chosen_n)
            except ValueError:
                chosen_n = 0
        return instances[chosen_n - 1]

    def resolve_conflict(self, conflict_description, choices):
        chosen_n = 0
        while not (chosen_n > 0 and chosen_n <= len(choices)):
            print conflict_description
            n = 0
            for choice in choices:
                n += 1
                print '%s. %s' % (n, choice)
            chosen_n = raw_input('Choice: ')
            try:
                chosen_n = int(chosen_n)
            except ValueError:
                chosen_n = 0
        return choices[chosen_n - 1]
    
    def start(self):
        # 1. Find devices running Trunk and the port
        trunk_instances = self.find_trunk()
        # 2. Confirm with user which Trunk instance they want to use
        chosen_instance = self.confirm_instance(trunk_instances)
        # 3. Sync with this Trunk instances
        username = self.username
        password = self.password
        success = False
        while not success:
            try:
                sync = TrunkSync(self,
                                 chosen_instance[1],
                                 chosen_instance[2],
                                 self.local_path,
                                 self.last_sync_path,
                                 trunk_user=username,
                                 trunk_password=password)
                success = sync.sync()
            except IphoneConnectError, e:
                if e[0]['status'] == '401':
                    # Authentication error - prompt user
                    username = raw_input('Username: ')
                    password = getpass('Password: ')
                else:
                    # Unknown error
                    raise



def _test():
    import doctest
    doctest.testmod()

if __name__ == '__main__':
    if '-t' in sys.argv:
        _test()
    else:
        t = TrunkSyncSimpleUi()
        t.start()