-
Notifications
You must be signed in to change notification settings - Fork 33
Expand file tree
/
Copy pathasynk_init.py
More file actions
864 lines (703 loc) · 27.3 KB
/
Copy pathasynk_init.py
File metadata and controls
864 lines (703 loc) · 27.3 KB
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
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
##
## Created: Thu May 29 17:20:00 PDT 2026
## SPDX-FileCopyrightText: 2026 Sriram Karra <karra.etc@gmail.com>
## SPDX-License-Identifier: AGPL-3.0-only
##
## This file is part of ASynK
##
## Interactive setup wizard for ASynK. Walks the user through DB selection,
## credential setup, folder selection, and profile creation.
##
import logging, os, re, sys
CUR_DIR = os.path.abspath('')
ASYNK_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
EXTRA_PATHS = [os.path.join(ASYNK_BASE_DIR, 'lib'),
os.path.join(ASYNK_BASE_DIR, 'asynk'),]
sys.path = EXTRA_PATHS + sys.path
import utils
from state import Config
from state_collection import collection_id_to_class as coll_id_class
from state_collection import AsynkCollectionError
from asynk_core import Asynk, AsynkParserError
from folder import Folder
## DB IDs and their human-friendly names. Outlook is excluded -- it is
## Windows-only and has a different setup path.
DB_NAMES = {
'bb': 'BBDB (Emacs Big Brother Database)',
'gc': 'Google Contacts',
'ex': 'Exchange Online (Microsoft 365 / Outlook.com)',
'ic': 'iCloud Contacts',
'cd': 'CardDAV Server (Nextcloud, Radicale, etc.)',
}
## Ordered list for the menu (most common first)
DB_ORDER = ['gc', 'ex', 'ic', 'cd', 'bb']
##
## Text / UI helpers
##
def _print_banner ():
print()
print('=' * 60)
print(' ASynK %s -- Interactive Setup Wizard' % utils.asynk_ver)
print('=' * 60)
print()
print(' This wizard will help you set up a sync profile')
print(' between two contact stores.')
print()
def _prompt_choice (prompt, options, default=None):
"""Present a numbered menu and return the selected option value.
options is a list of (value, label) tuples.
default is an optional value that will be pre-selected if the user
just presses Enter.
"""
num_width = len(str(len(options)))
for i, (val, label) in enumerate(options, 1):
marker = ' *' if val == default else ''
print(' %*d. %s%s' % (num_width, i, label, marker))
print()
default_idx = None
if default is not None:
for i, (val, _) in enumerate(options, 1):
if val == default:
default_idx = i
break
while True:
suffix = ' [%d]' % default_idx if default_idx else ''
try:
raw = input('%s%s: ' % (prompt, suffix)).strip()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit('Setup cancelled.')
if not raw and default_idx:
return options[default_idx - 1][0]
try:
idx = int(raw)
if 1 <= idx <= len(options):
return options[idx - 1][0]
except ValueError:
pass
print(' Please enter a number between 1 and %d.' % len(options))
def _prompt_input (prompt, default=None):
"""Prompt for a text value with an optional default."""
suffix = ' [%s]' % default if default else ''
try:
raw = input('%s%s: ' % (prompt, suffix)).strip()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit('Setup cancelled.')
return raw if raw else default
def _prompt_yesno (prompt, default=True):
"""Prompt for a yes/no answer. Returns True for yes, False for no."""
hint = 'Y/n' if default else 'y/N'
try:
raw = input('%s [%s]: ' % (prompt, hint)).strip().lower()
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit('Setup cancelled.')
if not raw:
return default
return raw[0] == 'y'
##
## DB selection
##
def _select_one_db (args, coll_index, step_num):
"""Select a single database. coll_index is 0 or 1.
If --db was provided, uses it directly. Otherwise prompts.
Returns a db ID string like 'gc', 'bb', etc.
"""
if args.db and len(args.db) > coll_index:
db_id = args.db[coll_index]
label = 'first' if coll_index == 0 else 'second'
print(' Using %s database: %s' % (label, DB_NAMES[db_id]))
print()
return db_id
options = [(dbid, DB_NAMES[dbid]) for dbid in DB_ORDER]
label = 'first' if coll_index == 0 else 'second'
print('--- Step %d: Select the %s contact store ---' % (step_num, label))
print()
db_id = _prompt_choice('Select store', options)
print()
print(' Selected: %s' % DB_NAMES[db_id])
print()
return db_id
##
## Generic folder selection
##
def _get_folder_count (f):
"""Return the number of contacts in a folder, or None if unknown.
For Google Contacts, the People API contactGroup resource has a
memberCount field. For BBDB, we can count the parsed contacts.
For others, return None.
"""
## GC: gcentry has 'memberCount'
gcentry = getattr(f, 'get_gcentry', None)
if gcentry:
entry = gcentry()
if entry and isinstance(entry, dict):
mc = entry.get('memberCount')
if mc is not None:
return int(mc)
## BB: the folder object has a contacts dict after login
contacts = getattr(f, 'get_contacts', None)
if contacts:
try:
c = contacts()
if isinstance(c, dict) and len(c) > 0:
return len(c)
except Exception:
pass
## EX: get_contacts() returns the local cache (empty at init time).
## Query the Graph API directly for a lightweight count.
graph_client = getattr(f, 'get_graph_client', None)
if graph_client:
try:
items = graph_client().list_contacts(
folder_id=f.get_itemid(), select='id')
return len(items)
except Exception:
pass
return None
def _create_missing_folder (db, label):
"""Offer to create a contacts folder when none exist.
This is common for fresh Exchange accounts that have never had
contacts. Returns the (possibly updated) folder list from the DB,
or an empty list if creation was declined or failed.
"""
dbid = db.get_dbid()
print()
print(' No contact folders found in %s.' % DB_NAMES.get(dbid, dbid))
print(' ASynK needs a folder to store contacts in.')
print()
create = _prompt_yesno(' Create a new contacts folder?', default=True)
if not create:
return []
fname = _prompt_input(' Folder name', default='asynk')
if not fname:
fname = 'asynk'
print()
print(' Creating folder "%s"...' % fname)
res = db.new_folder(fname, Folder.CONTACT_t)
if res is None:
print(' Error: Could not create the folder.')
return []
## Register the new folder with the DB so get_contacts_folders()
## returns it. For Exchange, new_folder() returns a Graph API dict
## that must be wrapped as an EXContactsFolder.
if dbid == 'ex':
from folder_ex import EXContactsFolder
f = EXContactsFolder(db, res)
db.add_to_folders(f)
else:
## Other backends: re-initialize folders from the store.
## This is a safe fallback — set_folders() re-reads everything.
db.folders['contacts'] = []
db.set_folders()
print(' Created: %s' % fname)
print()
return db.get_contacts_folders()
def _select_folder (db, label, preselected_fid=None):
"""Pick a contacts folder from a logged-in PIMDB.
db is a PIMDB instance that has been logged in.
label is a human-readable description like 'first' or 'second'.
preselected_fid, if not None, skips the prompt and uses that fid.
Returns (folder_id, folder_name) tuple.
"""
folders = db.get_contacts_folders()
if not folders:
folders = _create_missing_folder(db, label)
if not folders:
raise AsynkParserError('No contact folders found.')
## Build folder info: (fid, name, count)
folder_info = []
for f in folders:
fid = f.get_itemid()
name = f.get_name() if hasattr(f, 'get_name') else str(fid)
count = _get_folder_count(f)
folder_info.append((fid, name, count))
## Build a fid -> name lookup
name_map = {fid: name for fid, name, _ in folder_info}
## If a fid was pre-selected (from --folder), use it directly
if preselected_fid is not None:
fname = name_map.get(preselected_fid, preselected_fid)
print(' Using pre-selected folder: %s' % preselected_fid)
return (preselected_fid, fname)
## If only one folder, auto-select with confirmation
if len(folder_info) == 1:
fid, name, count = folder_info[0]
cnt_str = ' (%d contacts)' % count if count is not None else ''
print(' %s: Only one folder available: %s%s' % (label, name, cnt_str))
return (fid, name)
## Compute column widths for aligned display
max_name = max(len(name) for _, name, _ in folder_info)
max_fid = max(len(str(fid)) for fid, _, _ in folder_info)
max_name = max(max_name, 4) # minimum "Name" header width
max_fid = max(max_fid, 2) # minimum "ID" header width
## Build (fid, formatted_label) for _prompt_choice
options = []
for fid, name, count in folder_info:
cnt_str = '%5d' % count if count is not None else ' ?'
line = '%-*s %-*s %s contacts' % (
max_name, name, max_fid, fid, cnt_str)
options.append((fid, line))
## Default to 'default' for BB, first folder otherwise
default_fid = None
for fid, _, _ in folder_info:
if fid == 'default':
default_fid = 'default'
break
if default_fid is None:
default_fid = folder_info[0][0]
print(' Available folders for the %s store:' % label)
print()
fid = _prompt_choice('Select folder', options, default=default_fid)
print()
return (fid, name_map.get(fid, fid))
##
## Profile naming
##
def _generate_profile_name (config, db1_id, db2_id):
"""Generate a default profile name like 'gcbb1'.
Returns a name that does not conflict with existing profiles.
"""
existing = config.get_profile_names()
pname_re = config.get_profile_name_re()
base = '%s%s' % (db1_id, db2_id)
for n in range(1, 1000):
candidate = '%s%d' % (base, n)
if candidate not in existing:
## Verify it matches the profile name regex
if re.search('^' + pname_re + '$', candidate):
return candidate
## Fallback -- should never get here
return base + '999'
def _prompt_profile_name (config, db1_id, db2_id, preselected=None):
"""Prompt for a profile name with a sensible default.
If preselected is provided (from --name), uses it directly.
"""
if preselected:
print(' Using profile name: %s' % preselected)
return preselected
default = _generate_profile_name(config, db1_id, db2_id)
pname_re = config.get_profile_name_re()
while True:
name = _prompt_input('Profile name', default=default)
if not name:
continue
## Check regex
if not re.search('^' + pname_re + '$', name):
print(' Invalid name. Must match: %s' % pname_re)
continue
## Check collision
if config.profile_exists(name):
print(' Profile "%s" already exists. Choose another.' % name)
continue
return name
##
## Sync settings
##
def _prompt_sync_settings (args, db1_id, db2_id):
"""Prompt for sync direction and conflict resolution.
Returns (sync_dir, conflict_resolve) tuple.
sync_dir is 'SYNC2WAY' or 'SYNC1WAY'.
conflict_resolve is '1' or '2'.
"""
## Direction
if hasattr(args, 'direction') and args.direction:
sync_dir = 'SYNC1WAY' if args.direction == '1way' else 'SYNC2WAY'
print(' Sync direction: %s' % sync_dir)
else:
options = [
('SYNC2WAY', 'Two-way sync (changes flow both directions)'),
('SYNC1WAY', 'One-way sync (first store -> second store)'),
]
print()
print('--- Step 6: Sync direction ---')
print()
sync_dir = _prompt_choice('Direction', options, default='SYNC2WAY')
## Conflict resolution
cr = getattr(args, 'conflict_resolve', None)
if cr:
print(' Conflict resolution: %s' % cr)
else:
options = [
('1', '%s wins (first store)' % DB_NAMES[db1_id]),
('2', '%s wins (second store)' % DB_NAMES[db2_id]),
]
print()
print('--- Step 7: Conflict resolution ---')
print()
cr = _prompt_choice('On conflict', options, default='1')
return (sync_dir, cr)
##
## DB-specific setup functions
##
def _setup_bb (args, config, coll_index):
"""Set up a BBDB collection.
coll_index is 0 or 1 (which of the two stores this is).
Returns (collection, db) tuple where db is a logged-in PIMDB.
"""
## Determine the store path
store_path = None
if args.store and len(args.store) > coll_index:
store_path = args.store[coll_index]
else:
store_path = _prompt_input('BBDB file path', default='~/.bbdb')
store_path = os.path.expanduser(store_path)
## Resolve the path the same way the BBDB code does internally
abs_path = utils.abs_pathname(config, store_path)
## If the file doesn't exist, offer to create it
if not os.path.exists(abs_path):
print()
print(' File not found: %s' % abs_path)
create = _prompt_yesno(' Create it?', default=True)
if create:
from pimdb_bb import BBPIMDB
BBPIMDB.new_store(abs_path)
print(' Created: %s' % abs_path)
else:
raise AsynkParserError('BBDB file does not exist: %s'
% abs_path)
## Create collection and login
coll = coll_id_class['bb'](config=config, stid=store_path, pname=None)
coll.login()
return (coll, coll.get_db())
def _setup_gc (args, config, coll_index):
"""Set up a Google Contacts collection.
coll_index is 0 or 1 (which of the two stores this is).
Returns (collection, db) tuple where db is a logged-in PIMDB.
"""
from asynk_subcmds import _apply_auth_to_coll
## The gc_user label is used internally to name the token cache file
## (e.g. default.token.pickle). The actual Google account is selected
## in the browser during the OAuth flow, so we don't need to ask.
username = None
if args.gc_user and len(args.gc_user) > coll_index:
username = args.gc_user[coll_index]
else:
## Auto-generate a sensible default label
username = 'default' if coll_index == 0 else 'gc%d' % (coll_index + 1)
coll = coll_id_class['gc'](config=config, pname=None)
coll.set_username(username)
## Client secrets: resolved automatically by GCCollection.login()
## via the Phase 1 default credentials
if args.gc_creds_file and len(args.gc_creds_file) > coll_index:
coll.set_pwd(args.gc_creds_file[coll_index])
print()
print(' A browser window will open so you can sign in to your')
print(' Google account and authorize ASynK to access your contacts.')
print()
print(' NOTE: ASynK uses shared default Google credentials. If you')
print(' encounter rate limit errors, you can register your own Google')
print(' Cloud project. See doc/google_app_registration.md.')
_prompt_input('Press Enter to continue', default='')
coll.login()
## Show the authenticated user's email if we can determine it.
## The contacts scope may not always grant access to people/me,
## so treat this as best-effort — the OAuth token is still valid.
db = coll.get_db()
email = getattr(db, 'authenticated_email', None)
if email:
print()
print(' Signed in as: %s' % email)
else:
print()
print(' Note: Could not determine the signed-in Google account.')
print(' (The OAuth login succeeded; this is a permissions quirk.)')
_prompt_input('Press Enter to fetch the folder list', default='')
print()
return (coll, db)
def _setup_ex (args, config, coll_index):
"""Set up an Exchange Online collection.
coll_index is 0 or 1 (which of the two stores this is).
Returns (collection, db) tuple where db is a logged-in PIMDB.
"""
from asynk_subcmds import _apply_auth_to_coll
## The username label is used internally to name the token cache file
## (e.g. graph_token_cache_default.json). The actual Microsoft account
## is selected in the browser during the device code flow, so we don't
## need to ask.
username = None
if args.ex_user and len(args.ex_user) > coll_index:
username = args.ex_user[coll_index]
else:
## Auto-generate a sensible default label
username = 'default' if coll_index == 0 else 'ex%d' % (coll_index + 1)
coll = coll_id_class['ex'](config=config, pname=None)
coll.set_username(username)
## Client ID: resolved automatically from config by EXPIMDB
if args.ex_client_id and len(args.ex_client_id) > coll_index:
coll.set_pwd(args.ex_client_id[coll_index])
if hasattr(args, 'ex_token_cache') and args.ex_token_cache:
if len(args.ex_token_cache) > coll_index:
coll.set_token_cache(args.ex_token_cache[coll_index])
coll.login()
## Show the authenticated user's email if we can determine it.
db = coll.get_db()
email = getattr(db, 'authenticated_email', None)
if email:
print()
print(' Signed in as: %s' % email)
else:
print()
print(' Note: Could not determine the signed-in Exchange account.')
print(' (The device code login succeeded; this is a permissions quirk.)')
print()
print(' NOTE: ASynK uses a shared default Azure AD app registration.')
print(' If you encounter rate limit errors, you can register your own')
print(' app. See doc/azure_app_registration.md.')
_prompt_input('Press Enter to fetch the folder list', default='')
print()
return (coll, db)
def _setup_cd (args, config, coll_index):
"""Set up a CardDAV collection.
coll_index is 0 or 1 (which of the two stores this is).
Returns (collection, db) tuple where db is a logged-in PIMDB.
"""
import getpass as _getpass
## Server URL
server_url = None
if args.store and len(args.store) > coll_index:
server_url = args.store[coll_index]
else:
server_url = _prompt_input('CardDAV server URL')
if not server_url:
raise AsynkParserError('CardDAV server URL is required.')
## Username
username = None
if args.cduser and len(args.cduser) > coll_index:
username = args.cduser[coll_index]
else:
username = _prompt_input('CardDAV username')
if not username:
raise AsynkParserError('CardDAV username is required.')
## Password
password = None
if args.cdpwd and len(args.cdpwd) > coll_index:
password = args.cdpwd[coll_index]
else:
try:
password = _getpass.getpass('CardDAV password: ')
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit('Setup cancelled.')
if not password:
raise AsynkParserError('CardDAV password is required.')
coll = coll_id_class['cd'](config=config, stid=server_url, pname=None)
coll.set_username(username)
coll.set_pwd(password)
print()
print(' Connecting to CardDAV server...')
print()
coll.login()
return (coll, coll.get_db())
def _setup_ic (args, config, coll_index):
"""Set up an iCloud Contacts collection.
iCloud contacts are accessed via CardDAV at contacts.icloud.com.
Authentication requires an app-specific password generated from
the user's Apple ID account settings.
coll_index is 0 or 1 (which of the two stores this is).
Returns (collection, db) tuple where db is a logged-in PIMDB.
"""
import getpass as _getpass
ICLOUD_URL = 'https://contacts.icloud.com'
print(' iCloud contacts are accessed via CardDAV.')
print(' Server: %s' % ICLOUD_URL)
print()
print(' NOTE: iCloud requires an app-specific password (not your')
print(' regular Apple ID password). To generate one:')
print(' 1. Go to https://appleid.apple.com/account/manage')
print(' 2. Sign in and select "App-Specific Passwords"')
print(' 3. Click "+" to generate a new password for ASynK')
print()
## Username (Apple ID)
username = None
icuser = getattr(args, 'icuser', None)
if icuser and len(icuser) > coll_index:
username = icuser[coll_index]
else:
username = _prompt_input('Apple ID (email)')
if not username:
raise AsynkParserError('Apple ID is required.')
## App-specific password
password = None
icpwd = getattr(args, 'icpwd', None)
if icpwd and len(icpwd) > coll_index:
password = icpwd[coll_index]
else:
try:
password = _getpass.getpass('App-specific password: ')
except (EOFError, KeyboardInterrupt):
print()
raise SystemExit('Setup cancelled.')
if not password:
raise AsynkParserError('App-specific password is required.')
coll = coll_id_class['ic'](config=config, pname=None)
coll.set_username(username)
coll.set_pwd(password)
print()
print(' Connecting to iCloud...')
print()
coll.login()
## Offer to save the password to the OS keychain
try:
from keychain import set_password as _kc_set, platform_store_name
store = platform_store_name()
save = _prompt_input('Save password to %s for future runs? (y/n)' % store)
if save and save.lower().startswith('y'):
if _kc_set(username, password):
print(' Password saved to %s.' % store)
else:
print(' Could not save to %s (see log for details).' % store)
else:
print(' Password not saved.')
except Exception:
pass
return (coll, coll.get_db())
## Dispatch table: DB ID -> setup function
_SETUP_FUNCS = {
'bb': _setup_bb,
'gc': _setup_gc,
'ex': _setup_ex,
'ic': _setup_ic,
'cd': _setup_cd,
}
def _setup_with_retry (setup_func, args, config, coll_index, db_id,
max_retries=2):
"""Call a setup function with retry on AsynkCollectionError.
For non-interactive mode (all flags provided), errors are fatal.
For interactive mode, the user gets a chance to retry.
"""
for attempt in range(max_retries + 1):
try:
return setup_func(args, config, coll_index)
except AsynkCollectionError as e:
logging.error('Login failed for %s: %s', DB_NAMES[db_id], e)
if attempt < max_retries:
print()
print(' Error: %s' % e)
retry = _prompt_yesno(' Retry?', default=True)
if not retry:
raise
print()
else:
raise
except ImportError as e:
logging.error('Missing dependency for %s: %s', DB_NAMES[db_id], e)
print()
print(' Error: Missing dependency: %s' % e)
raise SystemExit(1)
##
## Profile creation
##
def _create_profile (config, alogger, args, pname, db1_id, db2_id,
coll1, fid1, coll2, fid2, sync_dir, cr):
"""Create a sync profile using the Asynk engine."""
coll1.set_fid(fid1)
coll2.set_fid(fid2)
asynk = Asynk(config, alogger)
asynk.set_op('op_create_profile')
asynk.set_name(pname)
asynk.set_dry_run(False)
asynk.set_sync_dir(sync_dir)
asynk.set_conflict_resolve(cr)
asynk.set_sync_all(False)
asynk.set_label_re(None)
asynk.set_item_id(None)
asynk.add_coll(coll1)
asynk.add_coll(coll2)
asynk.dispatch()
return pname
##
## Profile summary
##
def _print_summary (pname, db1_id, db2_id, fid1, fid2, sync_dir, cr,
user_dir=None):
"""Print a summary of the created profile."""
dir_label = 'Two-way sync' if sync_dir == 'SYNC2WAY' else 'One-way sync'
cr_label = '%s wins' % DB_NAMES.get(
db1_id if cr == '1' else db2_id, 'store %s' % cr)
ud_flag = ' --user-dir %s' % user_dir if user_dir else ''
print()
print('=' * 60)
print(' Profile \'%s\' created successfully!' % pname)
print('=' * 60)
print()
print(' Store 1: %s' % DB_NAMES[db1_id])
print(' Folder 1: %s' % fid1)
print(' Store 2: %s' % DB_NAMES[db2_id])
print(' Folder 2: %s' % fid2)
print(' Direction: %s' % dir_label)
print(' Conflicts: %s' % cr_label)
print()
print(' Next steps:')
print(' Dry run: venv/bin/python asynk.py sync --name %s --dry-run%s'
% (pname, ud_flag))
print(' Sync: venv/bin/python asynk.py sync --name %s%s'
% (pname, ud_flag))
print(' Details: venv/bin/python asynk.py profile show --name %s%s'
% (pname, ud_flag))
print()
##
## Main entry point -- called from asynk_subcmds.py
##
def _setup_store (args, config, setup_func, db_id, coll_index, colln):
"""Login to a store and select a folder. Returns (coll, db, fid).
This keeps login + folder selection together so the user picks a
folder immediately after authenticating.
"""
print(' [Store %d: %s]' % (colln, DB_NAMES[db_id]))
coll, db = _setup_with_retry(setup_func, args, config, coll_index, db_id)
coll.set_colln(colln)
print()
pre_fid = None
if args.folder and len(args.folder) > coll_index:
pre_fid = args.folder[coll_index]
fid, fname = _select_folder(db, DB_NAMES[db_id], preselected_fid=pre_fid)
coll.set_folder_name(fname)
return (coll, db, fid)
def cmd_init (args, config, alogger):
"""Interactive setup wizard handler."""
_print_banner()
## Step 1: Select first store
db1_id = _select_one_db(args, 0, step_num=1)
setup1 = _SETUP_FUNCS.get(db1_id)
if not setup1:
raise AsynkParserError('Unsupported database: %s' % db1_id)
## Step 2: Login + folder for store 1
print('--- Step 2: Set up %s ---' % DB_NAMES[db1_id])
print()
coll1, db1, fid1 = _setup_store(
args, config, setup1, db1_id, 0, 1)
print()
## Step 3: Select second store
db2_id = _select_one_db(args, 1, step_num=3)
setup2 = _SETUP_FUNCS.get(db2_id)
if not setup2:
raise AsynkParserError('Unsupported database: %s' % db2_id)
## Step 4: Login + folder for store 2
print('--- Step 4: Set up %s ---' % DB_NAMES[db2_id])
print()
coll2, db2, fid2 = _setup_store(
args, config, setup2, db2_id, 1, 2)
print()
## Step 5: Profile naming
print('--- Step 5: Name your profile ---')
print()
print(' A profile defines a sync relationship between two folders.')
print(' You can create multiple profiles for different sync pairs,')
print(' run them on independent schedules, and when you inspect a')
print(' contact you will be able to tell which profile synced it.')
print()
pname = _prompt_profile_name(config, db1_id, db2_id,
preselected=args.name)
print()
## Step 6 & 7: Sync settings
sync_dir, cr = _prompt_sync_settings(args, db1_id, db2_id)
## Create the profile
print()
print(' Creating profile...')
_create_profile(config, alogger, args, pname, db1_id, db2_id,
coll1, fid1, coll2, fid2, sync_dir, cr)
## Summary
user_dir = getattr(args, 'user_dir', None)
_print_summary(pname, db1_id, db2_id, fid1, fid2, sync_dir, cr,
user_dir=user_dir)