]> code.delx.au - pymsnt/blob - src/legacy/glue.py
4616261ce5b84e45d7ea1c0f2b25649b2ca5ad1a
[pymsnt] / src / legacy / glue.py
1 # Copyright 2004-2005 James Bunton <james@delx.cjb.net>
2 # Licensed for distribution under the GPL version 2, check COPYING for details
3
4 import os.path
5 import utils
6 from twisted.internet import task
7 from tlib.xmlw import Element
8 from tlib import msn
9 from debug import LogEvent, INFO, WARN, ERROR
10 import disco
11 import groupchat
12 import ft
13 import avatar
14 import config
15 import lang
16
17
18
19
20 name = "MSN Transport" # The name of the transport
21 url = "http://msn-transport.jabberstudio.org"
22 version = "0.11-dev" # The transport version
23 mangle = True # XDB '@' -> '%' mangling
24 id = "msn" # The transport identifier
25
26
27 # Load the default avatars
28 f = open(os.path.join("data", "defaultJabberAvatar.png"))
29 defaultJabberAvatarData = f.read()
30 f.close()
31
32 f = open(os.path.join("data", "defaultMSNAvatar.png"))
33 defaultAvatarData = f.read()
34 f.close()
35 defaultAvatar = avatar.AvatarCache().setAvatar(defaultAvatarData)
36
37
38 def reloadConfig():
39 msn.MSNConnection.GETALLAVATARS = config.getAllAvatars
40
41 def isGroupJID(jid):
42 """ Returns True if the JID passed is a valid groupchat JID (for MSN, does not contain '%') """
43 return (jid.find('%') == -1)
44
45
46
47 # This should be set to the name space the registration entries are in, in the xdb spool
48 namespace = "jabber:iq:register"
49
50
51 def formRegEntry(username, password):
52 """ Returns a domish.Element representation of the data passed. This element will be written to the XDB spool file """
53 reginfo = Element((None, "query"))
54 reginfo.attributes["xmlns"] = "jabber:iq:register"
55
56 userEl = reginfo.addElement("username")
57 userEl.addContent(username)
58
59 passEl = reginfo.addElement("password")
60 passEl.addContent(password)
61
62 return reginfo
63
64
65
66
67 def getAttributes(base):
68 """ This function should, given a spool domish.Element, pull the username, password,
69 and out of it and return them """
70 username = ""
71 password = ""
72 for child in base.elements():
73 try:
74 if child.name == "username":
75 username = child.__str__()
76 elif child.name == "password":
77 password = child.__str__()
78 except AttributeError:
79 continue
80
81 return username, password
82
83
84 def startStats(statistics):
85 stats = statistics.stats
86 stats["MessageCount"] = 0
87 stats["FailedMessageCount"] = 0
88 stats["AvatarCount"] = 0
89 stats["FailedAvatarCount"] = 0
90
91 def updateStats(statistics):
92 stats = statistics.stats
93 # FIXME
94 #stats["AvatarCount"] = msnp2p.MSNP2P_Avatar.TRANSFER_COUNT
95 #stats["FailedAvatarCount"] = msnp2p.MSNP2P_Avatar.ERROR_COUNT
96
97
98 def msn2jid(msnid, withResource):
99 """ Converts a MSN passport into a JID representation to be used with the transport """
100 return msnid.replace('@', '%') + "@" + config.jid + (withResource and "/msn" or "")
101
102 # Marks this as the function to be used in jabber:iq:gateway (Service ID Translation)
103 translateAccount = lambda a: msn2jid(a, False)
104
105 def jid2msn(jid):
106 """ Converts a JID representation of a MSN passport into the original MSN passport """
107 return unicode(jid[:jid.find('@')].replace('%', '@')).split("/")[0]
108
109
110 def presence2state(show, ptype):
111 """ Converts a Jabber presence into an MSN status code """
112 if ptype == "unavailable":
113 return msn.STATUS_OFFLINE
114 elif not show or show == "online" or show == "chat":
115 return msn.STATUS_ONLINE
116 elif show == "dnd":
117 return msn.STATUS_BUSY
118 elif show == "away" or show == "xa":
119 return msn.STATUS_AWAY
120
121
122 def state2presence(state):
123 """ Converts a MSN status code into a Jabber presence """
124 if state == msn.STATUS_ONLINE:
125 return (None, None)
126 elif state == msn.STATUS_BUSY:
127 return ("dnd", None)
128 elif state == msn.STATUS_AWAY:
129 return ("away", None)
130 elif state == msn.STATUS_IDLE:
131 return ("away", None)
132 elif state == msn.STATUS_BRB:
133 return ("away", None)
134 elif state == msn.STATUS_PHONE:
135 return ("dnd", None)
136 elif state == msn.STATUS_LUNCH:
137 return ("away", None)
138 else:
139 return (None, "unavailable")
140
141
142 def getGroupNames(msnContact, msnContactList):
143 """ Gets a list of groups that this contact is in """
144 groups = []
145 for groupGUID in msnContact.groups:
146 try:
147 groups.append(msnContactList.groups[groupGUID])
148 except KeyError:
149 pass
150 return groups
151
152 def msnlist2jabsub(lists):
153 """ Converts MSN contact lists ORed together into the corresponding Jabber subscription state """
154 if lists & msn.FORWARD_LIST and lists & msn.REVERSE_LIST:
155 return "both"
156 elif lists & msn.REVERSE_LIST:
157 return "from"
158 elif lists & msn.FORWARD_LIST:
159 return "to"
160 else:
161 return "none"
162
163
164 def jabsub2msnlist(sub):
165 """ Converts a Jabber subscription state into the corresponding MSN contact lists ORed together """
166 if sub == "to":
167 return msn.FORWARD_LIST
168 elif sub == "from":
169 return msn.REVERSE_LIST
170 elif sub == "both":
171 return (msn.FORWARD_LIST | msn.REVERSE_LIST)
172 else:
173 return 0
174
175
176
177
178
179 # This class handles groupchats with the legacy protocol
180 class LegacyGroupchat(groupchat.BaseGroupchat):
181 def __init__(self, session, resource=None, ID=None, switchboardSession=None):
182 """ Possible entry points for groupchat
183 - User starts an empty switchboard session by sending presence to a blank room
184 - An existing switchboard session is joined by another MSN user
185 - User invited to an existing switchboard session with more than one user
186 """
187 groupchat.BaseGroupchat.__init__(self, session, resource, ID)
188 if switchboardSession:
189 self.switchboardSession = switchboardSession
190 else:
191 self.switchboardSession = msn.MultiSwitchboardSession(self.session.legacycon)
192 self.switchboardSession.groupchat = self
193
194 LogEvent(INFO, self.roomJID())
195
196 def removeMe(self):
197 if self.switchboardSession.transport:
198 self.switchboardSession.transport.loseConnection()
199 self.switchboardSession.groupchat = None
200 del self.switchboardSession
201 groupchat.BaseGroupchat.removeMe(self)
202 LogEvent(INFO, self.roomJID())
203
204 def sendLegacyMessage(self, message, noerror):
205 LogEvent(INFO, self.roomJID())
206 self.switchboardSession.sendMessage(message.replace("\n", "\r\n"), noerror)
207
208 def sendContactInvite(self, contactJID):
209 LogEvent(INFO, self.roomJID())
210 userHandle = jid2msn(contactJID)
211 self.switchboardSession.inviteUser(userHandle)
212
213 def gotMessage(self, userHandle, text):
214 LogEvent(INFO, self.roomJID())
215 self.messageReceived(userHandle, text)
216
217
218
219 # This class handles most interaction with the legacy protocol
220 class LegacyConnection(msn.MSNConnection):
221 """ A glue class that connects to the legacy network """
222 def __init__(self, username, password, session):
223 self.jabberID = session.jabberID
224
225 self.session = session
226 self.listSynced = False
227 self.initialListVersion = 0
228
229 self.remoteShow = ""
230 self.remoteStatus = ""
231 self.remoteNick = ""
232
233 # Init the MSN bits
234 msn.MSNConnection.__init__(self, username, password, self.session.jabberID)
235
236 # User typing notification stuff
237 self.userTyping = dict() # Indexed by contact MSN ID, stores whether the user is typing to this contact
238 # Contact typing notification stuff
239 self.contactTyping = dict() # Indexed by contact MSN ID, stores an integer that is incremented at 5 second intervals. If it reaches 3 then the contact has stopped typing. It is set to zero whenever MSN typing notification messages are received
240 # Looping function
241 self.userTypingSend = task.LoopingCall(self.sendTypingNotifications)
242 self.userTypingSend.start(5.0)
243
244 self.legacyList = LegacyList(self.session)
245
246 LogEvent(INFO, self.session.jabberID)
247
248 def removeMe(self):
249 LogEvent(INFO, self.session.jabberID)
250
251 self.userTypingSend.stop()
252
253 self.legacyList.removeMe()
254 self.legacyList = None
255 self.session = None
256 self.logOut()
257
258 def _sendShowStatus(self):
259 if not self.session: return
260 source = config.jid
261 to = self.session.jabberID
262 self.session.sendPresence(to=to, fro=source, show=self.remoteShow, status=self.remoteStatus, nickname=self.remoteNick)
263
264
265
266 # Implemented from baseproto
267 def resourceOffline(self, resource):
268 pass
269
270 def highestResource(self):
271 """ Returns highest priority resource """
272 return self.session.highestResource()
273
274 def sendMessage(self, dest, resource, body, noerror):
275 dest = jid2msn(dest)
276 if self.userTyping.has_key(dest):
277 del self.userTyping[dest]
278 try:
279 msn.MSNConnection.sendMessage(self, dest, body, noerror)
280 self.session.pytrans.statistics.stats["MessageCount"] += 1
281 except:
282 self.failedMessage(dest, body)
283 raise
284
285 def sendFile(self, dest, ftSend):
286 dest = jid2msn(dest)
287 def continueSendFile1((msnFileSend, d)):
288 def continueSendFile2((success, )):
289 if success:
290 ftSend.accept(msnFileSend)
291 else:
292 sendFileFail()
293 d.addCallbacks(continueSendFile2, sendFileFail)
294
295 def sendFileFail():
296 ftSend.reject()
297
298 d = msn.MSNConnection.sendFile(self, dest, ftSend.filename, ftSend.filesize)
299 d.addCallbacks(continueSendFile1, sendFileFail)
300
301 def setStatus(self, nickname, show, status):
302 statusCode = presence2state(show, None)
303 msn.MSNConnection.changeStatus(self, statusCode, nickname, status)
304
305 def updateAvatar(self, av=None):
306 global defaultJabberAvatarData
307
308 if av:
309 msn.MSNConnection.changeAvatar(self, av.getImageData())
310 else:
311 msn.MSNConnection.changeAvatar(self, defaultJabberAvatarData)
312
313 def sendTypingNotifications(self):
314 if not self.session: return
315
316 # Send any typing notification messages to the user's contacts
317 for contact in self.userTyping.keys():
318 if self.userTyping[contact]:
319 self.sendTypingToContact(contact)
320
321 # Send any typing notification messages from contacts to the user
322 for contact in self.contactTyping.keys():
323 self.contactTyping[contact] += 1
324 if self.contactTyping[contact] >= 3:
325 self.session.sendTypingNotification(self.session.jabberID, msn2jid(contact, True), False)
326 del self.contactTyping[contact]
327
328 def userTypingNotification(self, dest, resource, composing):
329 if not self.session: return
330 dest = jid2msn(dest)
331 self.userTyping[dest] = composing
332 if composing: # Make it instant
333 self.sendTypingToContact(dest)
334
335
336
337 # Implement callbacks from msn.MSNConnection
338 def connectionFailed(self, reason):
339 LogEvent(INFO, self.session.jabberID)
340 text = lang.get(self.session.lang).msnConnectFailed % reason
341 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=text)
342 self.session.removeMe()
343
344 def loginFailed(self, reason):
345 LogEvent(INFO, self.session.jabberID)
346 text = lang.get(self.session.lang).msnLoginFailure % (self.session.username)
347 self.session.sendErrorMessage(to=self.session.jabberID, fro=config.jid, etype="auth", condition="not-authorized", explanation=text, body="Login Failure")
348 self.session.removeMe()
349
350 def connectionLost(self, reason):
351 LogEvent(INFO, self.session.jabberID)
352 text = lang.get(self.session.lang).msnDisconnected % reason
353 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=text)
354 self.session.removeMe() # Tear down the session
355
356 def multipleLogin(self):
357 LogEvent(INFO, self.session.jabberID)
358 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=lang.get(self.session.lang).msnMultipleLogin)
359 self.session.removeMe()
360
361 def serverGoingDown(self):
362 LogEvent(INFO, self.session.jabberID)
363 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=lang.get(self.session.lang).msnMaintenance)
364
365 def accountNotVerified(self):
366 LogEvent(INFO, self.session.jabberID)
367 text = lang.get(self.session.lang).msnNotVerified % (self.session.username)
368 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=text)
369
370 def userMapping(self, passport, jid):
371 LogEvent(INFO, self.session.jabberID)
372 text = lang.get(self.session.lang).userMapping % (passport, jid)
373 self.session.sendMessage(to=self.session.jabberID, fro=msn2jid(passport, True), body=text)
374
375 def loggedIn(self):
376 LogEvent(INFO, self.session.jabberID)
377 self.session.ready = True
378
379 def listSynchronized(self):
380 LogEvent(INFO, self.session.jabberID)
381 self.session.sendPresence(to=self.session.jabberID, fro=config.jid)
382 self.legacyList.syncJabberLegacyLists()
383 self.listSynced = True
384 #self.legacyList.flushSubscriptionBuffer()
385
386 def ourStatusChanged(self, statusCode, screenName, personal):
387 # Send out a new presence packet to the Jabber user so that the transport icon changes
388 LogEvent(INFO, self.session.jabberID)
389 self.remoteShow, ptype = state2presence(statusCode)
390 self.remoteStatus = personal
391 self.remoteNick = screenName
392 self._sendShowStatus()
393
394 def gotMessage(self, remoteUser, text):
395 LogEvent(INFO, self.session.jabberID)
396 source = msn2jid(remoteUser, True)
397 self.session.sendMessage(self.session.jabberID, fro=source, body=text, mtype="chat")
398 self.session.pytrans.statistics.stats["MessageCount"] += 1
399
400 def gotGroupchat(self, msnGroupchat, userHandle):
401 LogEvent(INFO, self.session.jabberID)
402 msnGroupchat.groupchat = LegacyGroupchat(self.session, switchboardSession=msnGroupchat)
403 msnGroupchat.groupchat.sendUserInvite(msn2jid(userHandle, True))
404
405 def gotContactTyping(self, contact):
406 LogEvent(INFO, self.session.jabberID)
407 # Check if the contact has only just started typing
408 if not self.contactTyping.has_key(contact):
409 self.session.sendTypingNotification(self.session.jabberID, msn2jid(contact, True), True)
410
411 # Reset the counter
412 self.contactTyping[contact] = 0
413
414 def failedMessage(self, remoteUser, message):
415 LogEvent(INFO, self.session.jabberID)
416 self.session.pytrans.statistics.stats["FailedMessageCount"] += 1
417 fro = msn2jid(remoteUser, True)
418 self.session.sendErrorMessage(to=self.session.jabberID, fro=fro, etype="wait", condition="recipient-unavailable", explanation=lang.get(self.session.lang).msnFailedMessage, body=message)
419
420 def contactAvatarChanged(self, userHandle, hash):
421 LogEvent(INFO, self.session.jabberID)
422 jid = msn2jid(userHandle, False)
423 c = self.session.contactList.findContact(jid)
424 if not c: return
425
426 if hash:
427 # New avatar
428 av = self.session.pytrans.avatarCache.getAvatar(hash)
429 if av:
430 msnContact = self.getContacts().getContact(userHandle)
431 msnContact.msnobjGot = True
432 c.updateAvatar(av)
433 else:
434 def updateAvatarCB((imageData,)):
435 av = self.session.pytrans.avatarCache.setAvatar(imageData)
436 c.updateAvatar(av)
437 d = self.sendAvatarRequest(userHandle)
438 if d:
439 d.addCallback(updateAvatarCB)
440 else:
441 # They've turned off their avatar
442 global defaultAvatar
443 c.updateAvatar(defaultAvatar)
444
445 def contactStatusChanged(self, remoteUser):
446 LogEvent(INFO, self.session.jabberID)
447
448 msnContact = self.getContacts().getContact(remoteUser)
449 c = self.session.contactList.findContact(msn2jid(remoteUser, False))
450 if not (c and msnContact): return
451
452 show, ptype = state2presence(msnContact.status)
453 status = msnContact.personal.decode("utf-8")
454 screenName = msnContact.screenName.decode("utf-8")
455
456 c.updateNickname(screenName, push=False)
457 c.updatePresence(show, status, ptype, force=True)
458
459 def gotFileReceive(self, fileReceive):
460 LogEvent(INFO, self.session.jabberID)
461 # FIXME
462 ft.FTReceive(self.session, msn2jid(fileReceive.userHandle, True), fileReceive)
463
464 def contactAddedMe(self, userHandle):
465 LogEvent(INFO, self.session.jabberID)
466 self.session.contactList.getContact(msn2jid(userHandle, False)).contactRequestsAuth()
467
468 def contactRemovedMe(self, userHandle):
469 LogEvent(INFO, self.session.jabberID)
470 c = self.session.contactList.getContact(msn2jid(userHandle, True))
471 c.contactDerequestsAuth()
472 c.contactRemovesAuth()
473
474 def gotInitialEmailNotification(self, inboxunread, foldersunread):
475 LogEvent(INFO, self.session.jabberID)
476 text = lang.get(self.session.lang).msnInitialMail % (inboxunread, foldersunread)
477 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=text, mtype="headline")
478
479 def gotRealtimeEmailNotification(self, mailfrom, fromaddr, subject):
480 LogEvent(INFO, self.session.jabberID)
481 text = lang.get(self.session.lang).msnRealtimeMail % (mailfrom, fromaddr, subject)
482 self.session.sendMessage(to=self.session.jabberID, fro=config.jid, body=text, mtype="headline")
483
484 def gotMSNAlert(self, text, actionurl, subscrurl):
485 LogEvent(INFO, self.session.jabberID)
486
487 el = Element((None, "message"))
488 el.attributes["to"] = self.session.jabberID
489 el.attributes["from"] = config.jid
490 el.attributes["type"] = "headline"
491 body = el.addElement("body")
492 body.addContent(text)
493
494 x = el.addElement("x")
495 x.attributes["xmlns"] = "jabber:x:oob"
496 x.addElement("desc").addContent("More information on this notice.")
497 x.addElement("url").addContent(actionurl)
498
499 x = el.addElement("x")
500 x.attributes["xmlns"] = "jabber:x:oob"
501 x.addElement("desc").addContent("Manage subscriptions to alerts.")
502 x.addElement("url").addContent(subscrurl)
503
504 self.session.pytrans.send(el)
505
506 def gotAvatarImageData(self, userHandle, imageData):
507 LogEvent(INFO, self.session.jabberID)
508 av = self.session.pytrans.avatarCache.setAvatar(imageData)
509 jid = msn2jid(userHandle, False)
510 c = self.session.contactList.findContact(jid)
511 c.updateAvatar(av)
512
513
514
515
516 class LegacyList:
517 def __init__(self, session):
518 self.session = session
519 self.subscriptionBuffer = []
520
521 def removeMe(self):
522 self.subscriptionBuffer = None
523 self.session = None
524
525 def addContact(self, jid):
526 LogEvent(INFO, self.session.jabberID)
527 userHandle = jid2msn(jid)
528 self.session.legacycon.addContact(msn.FORWARD_LIST, userHandle)
529 self.session.contactList.getContact(jid).contactGrantsAuth()
530
531 def removeContact(self, jid):
532 LogEvent(INFO, self.session.jabberID)
533 jid = jid2msn(jid)
534 self.session.legacycon.remContact(msn.FORWARD_LIST, jid)
535
536
537 def authContact(self, jid):
538 LogEvent(INFO, self.session.jabberID)
539 jid = jid2msn(jid)
540 d = self.session.legacycon.remContact(msn.PENDING_LIST, jid)
541 if d:
542 self.session.legacycon.addContact(msn.REVERSE_LIST, jid)
543 self.session.legacycon.remContact(msn.BLOCK_LIST, jid)
544 self.session.legacycon.addContact(msn.ALLOW_LIST, jid)
545
546 def deauthContact(self, jid):
547 LogEvent(INFO, self.session.jabberID)
548 jid = jid2msn(jid)
549 self.session.legacycon.remContact(msn.ALLOW_LIST, jid)
550 self.session.legacycon.addContact(msn.BLOCK_LIST, jid)
551
552
553
554 def syncJabberLegacyLists(self):
555 """ Synchronises the MSN contact list on server with the Jabber contact list """
556
557 global defaultAvatar
558
559 # We have to make an MSNContactList from the XDB data, then compare it with the one the server sent
560 # Any subscription changes must be sent to the client, as well as changed in the XDB
561 LogEvent(INFO, self.session.jabberID, "Start.")
562 result = self.session.pytrans.xdb.request(self.session.jabberID, disco.IQROSTER)
563 oldContactList = msn.MSNContactList()
564 if result:
565 for item in result.elements():
566 user = item.getAttribute("jid")
567 sub = item.getAttribute("subscription")
568 lists = item.getAttribute("lists")
569 if not lists:
570 lists = jabsub2msnlist(sub) # Backwards compatible
571 lists = int(lists)
572 contact = msn.MSNContact(userHandle=user, screenName="", lists=lists)
573 oldContactList.addContact(contact)
574
575 newXDB = Element((None, "query"))
576 newXDB.attributes["xmlns"] = disco.IQROSTER
577
578 contactList = self.session.legacycon.getContacts()
579
580
581 # Convienence functions
582 def addedToList(num):
583 return (not (oldLists & num) and (lists & num))
584 def removedFromList(num):
585 return ((oldLists & num) and not (lists & num))
586
587 for contact in contactList.contacts.values():
588 # Compare with the XDB <item/> entry
589 oldContact = oldContactList.getContact(contact.userHandle)
590 if oldContact == None:
591 oldLists = 0
592 else:
593 oldLists = oldContact.lists
594 lists = contact.lists
595
596 # Create the Jabber representation of the
597 # contact base on the old list data and then
598 # sync it with current
599 jabContact = self.session.contactList.createContact(msn2jid(contact.userHandle, False), msnlist2jabsub(oldLists))
600 jabContact.updateAvatar(defaultAvatar, push=False)
601
602 if addedToList(msn.FORWARD_LIST):
603 jabContact.syncGroups(getGroupNames(contact, contactList), push=False)
604 jabContact.syncContactGrantedAuth()
605
606 if removedFromList(msn.FORWARD_LIST):
607 jabContact.syncContactRemovedAuth()
608
609 if addedToList(msn.ALLOW_LIST):
610 jabContact.syncUserGrantedAuth()
611
612 if addedToList(msn.BLOCK_LIST) or removedFromList(msn.ALLOW_LIST):
613 jabContact.syncUserRemovedAuth()
614
615 if (not (lists & msn.ALLOW_LIST) and not (lists & msn.BLOCK_LIST) and (lists & msn.REVERSE_LIST)) or (lists & msn.PENDING_LIST):
616 jabContact.contactRequestsAuth()
617
618 if removedFromList(msn.REVERSE_LIST):
619 jabContact.contactDerequestsAuth()
620
621 jabContact.syncRoster()
622
623 item = newXDB.addElement("item")
624 item.attributes["jid"] = contact.userHandle
625 item.attributes["subscription"] = msnlist2jabsub(lists)
626 item.attributes["lists"] = str(lists)
627
628 # Update the XDB
629 self.session.pytrans.xdb.set(self.session.jabberID, disco.IQROSTER, newXDB)
630 LogEvent(INFO, self.session.jabberID, "End.")
631
632 def saveLegacyList(self):
633 contactList = self.session.legacycon.getContacts()
634 if not contactList: return
635
636 newXDB = Element((None, "query"))
637 newXDB.attributes["xmlns"] = disco.IQROSTER
638
639 for contact in contactList.contacts.values():
640 item = newXDB.addElement("item")
641 item.attributes["jid"] = contact.userHandle
642 item.attributes["subscription"] = msnlist2jabsub(contact.lists) # Backwards compat
643 item.attributes["lists"] = str(contact.lists)
644
645 self.session.pytrans.xdb.set(self.session.jabberID, disco.IQROSTER, newXDB)
646 LogEvent(INFO, self.session.jabberID, "Finished saving list.")
647
648