diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd6163..b09f0a2 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## CHANGELOG +### v0.4.2 (2022-12-22) + +* Replaced the JSON parser with a simpler eval-based solution which is much faster. +* Macstodon is now a Fat Binary application and supports PowerPC architectures in addition to 68K! + ### v0.4.1 (2022-12-21) * Fixed a dumb bug that would cause a crash if the Mastodon server returned invalid JSON. diff --git a/Macstodon.py b/Macstodon.py index f35e602..40eabab 100755 --- a/Macstodon.py +++ b/Macstodon.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # # macfreeze: path Software:Programming:Python 1.5.2c1:Mac:Tools:IDE # macfreeze: exclude msvcrt # macfreeze: exclude SOCKS # macfreeze: exclude TERMIOS # macfreeze: exclude termios # macfreeze: exclude _imaging_gif # # ################################ # Splash Screen - hooks the import # process, so import it first # ################################ import MacstodonSplash # ############## # Python Imports # ############## import AE, AppleEvents import FrameWork import macfs import MacOS import macostools import os import W, Wapplication from MacPrefs import kOnSystemDisk # ########## # My Imports # ########## from MacstodonConstants import DEBUG from AuthHandler import AuthHandler from ImageHandler import ImageHandler from TimelineHandler import TimelineHandler from TootHandler import TootHandler # ########### # Application # ########### class Macstodon(Wapplication.Application): """ The application itself. """ # Creator type of this application MyCreatorType = 'M$dN' # Location of prefs in Preferences Folder preffilepath = ":Macstodon Preferences" # ######################## # Initialization Functions # ######################## def __init__(self): """ Run when the application launches. """ Wapplication.Application.__init__(self, self.MyCreatorType) # All applications should handle these Apple Events, # but you'll need an aete resource. AE.AEInstallEventHandler( # We're already open AppleEvents.kCoreEventClass, AppleEvents.kAEOpenApplication, self.ignoreevent ) AE.AEInstallEventHandler( # No printing in this app AppleEvents.kCoreEventClass, AppleEvents.kAEPrintDocuments, self.ignoreevent ) AE.AEInstallEventHandler( # No opening documents in this app AppleEvents.kCoreEventClass, AppleEvents.kAEOpenDocuments, self.ignoreevent ) AE.AEInstallEventHandler( AppleEvents.kCoreEventClass, AppleEvents.kAEQuitApplication, self.quitevent ) # Splash Screen MacstodonSplash.wait() MacstodonSplash.uninstall_importhook() # Create image cache folders # While the Wapplication framework creates the Macstodon Preferences file # automatically, we have to create the cache folder on our own vrefnum, dirid = macfs.FindFolder(kOnSystemDisk, 'pref', 0) prefsfolder_fss = macfs.FSSpec((vrefnum, dirid, '')) prefsfolder = prefsfolder_fss.as_pathname() path = os.path.join(prefsfolder, ":Macstodon Cache") acctpath = os.path.join(prefsfolder, ":Macstodon Cache:account") mediapath = os.path.join(prefsfolder, ":Macstodon Cache:media") macostools.mkdirs(path) macostools.mkdirs(acctpath) macostools.mkdirs(mediapath) self.cachefolderpath = path self.cacheacctfolderpath = acctpath self.cachemediafolderpath = mediapath # Init handlers self.authhandler = AuthHandler(self) self.imagehandler = ImageHandler(self) self.timelinehandler = TimelineHandler(self) self.toothandler = TootHandler(self) # Open login window self.loginwindow = self.authhandler.getLoginWindow() self.loginwindow.open() # Process some events! self.mainloop() def mainloop(self, mask=FrameWork.everyEvent, wait=0): """ Modified version of Wapplication.mainloop() that removes the debugging/traceback window. """ self.quitting = 0 saveyield = MacOS.EnableAppswitch(-1) try: while not self.quitting: try: self.do1event(mask, wait) except W.AlertError, detail: MacOS.EnableAppswitch(-1) W.Message(detail) except self.DebuggerQuit: MacOS.EnableAppswitch(-1) except: if DEBUG: MacOS.EnableAppswitch(-1) import PyEdit PyEdit.tracebackwindow.traceback() else: raise finally: MacOS.EnableAppswitch(1) def makeusermenus(self): """ Set up menu items which all applications should have. Apple Menu has already been set up. """ # File menu m = Wapplication.Menu(self.menubar, "File") quititem = FrameWork.MenuItem(m, "Quit", "Q", 'quit') # Edit menu m = Wapplication.Menu(self.menubar, "Edit") undoitem = FrameWork.MenuItem(m, "Undo", 'Z', "undo") FrameWork.Separator(m) cutitem = FrameWork.MenuItem(m, "Cut", 'X', "cut") copyitem = FrameWork.MenuItem(m, "Copy", "C", "copy") pasteitem = FrameWork.MenuItem(m, "Paste", "V", "paste") clearitem = FrameWork.MenuItem(m, "Clear", None, "clear") FrameWork.Separator(m) selallitem = FrameWork.MenuItem(m, "Select all", "A", "selectall") # Any other menus would go here # These menu items need to be updated periodically; # any menu item not handled by the application should be here, # as should any with a "can_" handler. self._menustocheck = [ undoitem, cutitem, copyitem, pasteitem, clearitem, selallitem ] # no window menu, so pass def checkopenwindowsmenu(self): pass # ############################## # Apple Event Handling Functions # ############################## def ignoreevent(self, theAppleEvent, theReply): """ Handler for events that we want to ignore """ pass def quitevent(self, theAppleEvent, theReply): """ System is telling us to quit """ self._quit() # ####################### # Menu Handling Functions # ####################### def do_about(self, id, item, window, event): """ User selected "About" from the Apple menu """ MacstodonSplash.about() def domenu_quit(self): """ User selected "Quit" from the File menu """ self._quit() # Run the app Macstodon() \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # # macfreeze: path SheepShaver:Python 1.5.2c1:Mac:Tools:IDE # macfreeze: exclude msvcrt # macfreeze: exclude SOCKS # macfreeze: exclude TERMIOS # macfreeze: exclude termios # macfreeze: exclude _imaging_gif # macfreeze: exclude Tkinter # # ################################ # Splash Screen - hooks the import # process, so import it first # ################################ import MacstodonSplash # ############## # Python Imports # ############## import AE, AppleEvents import FrameWork import macfs import MacOS import macostools import os import W, Wapplication from MacPrefs import kOnSystemDisk # ########## # My Imports # ########## from MacstodonConstants import DEBUG from AuthHandler import AuthHandler from ImageHandler import ImageHandler from TimelineHandler import TimelineHandler from TootHandler import TootHandler # ########### # Application # ########### class Macstodon(Wapplication.Application): """ The application itself. """ # Creator type of this application MyCreatorType = 'M$dN' # Location of prefs in Preferences Folder preffilepath = ":Macstodon Preferences" # ######################## # Initialization Functions # ######################## def __init__(self): """ Run when the application launches. """ Wapplication.Application.__init__(self, self.MyCreatorType) # All applications should handle these Apple Events, # but you'll need an aete resource. AE.AEInstallEventHandler( # We're already open AppleEvents.kCoreEventClass, AppleEvents.kAEOpenApplication, self.ignoreevent ) AE.AEInstallEventHandler( # No printing in this app AppleEvents.kCoreEventClass, AppleEvents.kAEPrintDocuments, self.ignoreevent ) AE.AEInstallEventHandler( # No opening documents in this app AppleEvents.kCoreEventClass, AppleEvents.kAEOpenDocuments, self.ignoreevent ) AE.AEInstallEventHandler( AppleEvents.kCoreEventClass, AppleEvents.kAEQuitApplication, self.quitevent ) # Splash Screen MacstodonSplash.wait() MacstodonSplash.uninstall_importhook() # Create image cache folders # While the Wapplication framework creates the Macstodon Preferences file # automatically, we have to create the cache folder on our own vrefnum, dirid = macfs.FindFolder(kOnSystemDisk, 'pref', 0) prefsfolder_fss = macfs.FSSpec((vrefnum, dirid, '')) prefsfolder = prefsfolder_fss.as_pathname() path = os.path.join(prefsfolder, ":Macstodon Cache") acctpath = os.path.join(prefsfolder, ":Macstodon Cache:account") mediapath = os.path.join(prefsfolder, ":Macstodon Cache:media") macostools.mkdirs(path) macostools.mkdirs(acctpath) macostools.mkdirs(mediapath) self.cachefolderpath = path self.cacheacctfolderpath = acctpath self.cachemediafolderpath = mediapath # Init handlers self.authhandler = AuthHandler(self) self.imagehandler = ImageHandler(self) self.timelinehandler = TimelineHandler(self) self.toothandler = TootHandler(self) # Open login window self.loginwindow = self.authhandler.getLoginWindow() self.loginwindow.open() # Process some events! self.mainloop() def mainloop(self, mask=FrameWork.everyEvent, wait=0): """ Modified version of Wapplication.mainloop() that removes the debugging/traceback window. """ self.quitting = 0 saveyield = MacOS.EnableAppswitch(-1) try: while not self.quitting: try: self.do1event(mask, wait) except W.AlertError, detail: MacOS.EnableAppswitch(-1) W.Message(detail) except self.DebuggerQuit: MacOS.EnableAppswitch(-1) except: if DEBUG: MacOS.EnableAppswitch(-1) import PyEdit PyEdit.tracebackwindow.traceback() else: raise finally: MacOS.EnableAppswitch(1) def makeusermenus(self): """ Set up menu items which all applications should have. Apple Menu has already been set up. """ # File menu m = Wapplication.Menu(self.menubar, "File") quititem = FrameWork.MenuItem(m, "Quit", "Q", 'quit') # Edit menu m = Wapplication.Menu(self.menubar, "Edit") undoitem = FrameWork.MenuItem(m, "Undo", 'Z', "undo") FrameWork.Separator(m) cutitem = FrameWork.MenuItem(m, "Cut", 'X', "cut") copyitem = FrameWork.MenuItem(m, "Copy", "C", "copy") pasteitem = FrameWork.MenuItem(m, "Paste", "V", "paste") clearitem = FrameWork.MenuItem(m, "Clear", None, "clear") FrameWork.Separator(m) selallitem = FrameWork.MenuItem(m, "Select all", "A", "selectall") # Any other menus would go here # These menu items need to be updated periodically; # any menu item not handled by the application should be here, # as should any with a "can_" handler. self._menustocheck = [ undoitem, cutitem, copyitem, pasteitem, clearitem, selallitem ] # no window menu, so pass def checkopenwindowsmenu(self): pass # ############################## # Apple Event Handling Functions # ############################## def ignoreevent(self, theAppleEvent, theReply): """ Handler for events that we want to ignore """ pass def quitevent(self, theAppleEvent, theReply): """ System is telling us to quit """ self._quit() # ####################### # Menu Handling Functions # ####################### def do_about(self, id, item, window, event): """ User selected "About" from the Apple menu """ MacstodonSplash.about() def domenu_quit(self): """ User selected "Quit" from the File menu """ self._quit() # Run the app Macstodon() \ No newline at end of file diff --git a/Macstodon.rsrc.sit.hqx b/Macstodon.rsrc.sit.hqx index 8350544..90d9751 100755 --- a/Macstodon.rsrc.sit.hqx +++ b/Macstodon.rsrc.sit.hqx @@ -1 +1 @@ -(This file must be converted with BinHex 4.0) :%NeKBh0dEf4[ELjbFh*M,R0TG!"6593e8dP8)3#3"$2l!*!%CKp6G(9QCNPd)#K M+6%j16FY-6Nj1#""E'&NC'PZ)&0jFh4PEA-X)%PZBbiX)'KdG(!k,bphN!-ZB@a KC'4TER0jFbjMEfd[8h4eCQC*G#m0#KS!"4!!!$2l!*!$FJ!"!*!$FMJ5$D@P8Q9 cCA*fC@5PT3#PN!3"!!!q!!#bhIRhhmNS0`#3$3iB,!#3$NeKBh0dEf4[ELjbFh* M!!&1)A*cFQ058d9%!3$rN!3!N!U!!*!*G28!!$-C-KB!!!d!$J$VGARfRHHa21[ -[(@EXFh5SXm[GZ9jh8*P,hHb2AqcMr*Va!CQ,cr2Dp2[bQ)lpY[dqVa&&lYX$pM Y@'F,2lVCTZf%E"rdc1XmGS[&cbhFF$2j,6c2,XmSplGjeV(Fl23Dq"l3GA9peh9 `GhlIl513!%$bm-N!(KR"Nc`mFTa!I!*2((6iM!NBBclbC@2@ijdhV`MCYBe0$DX D'pDZE'9fFAe,mpUe+jDhY"BL@hV$m[V@YZD9c8f9,DdYp6F%8F2%SXAAe3D-k`j QQ6rhl(ZKVdFcEI(QMlD'N!"SE&V9h0E`dEE@!L4RE'KVDfjUcH0J'jS%!X3VY'U SV@TBfe$IeMK3B$[NSf"&Bp0+5DcId,UQG4J5kaUD0LaGhGc@6'aPTYBeMD[DeMB JmlIim5R",a*dKaVA3AD1IAZ9I!+Qf-rI[i8$CQVjC(S'dB[8b$bcIfq*e!NV`)[ !$Mb)Fefm@66HJ[FZ[$q,pcDmr`!&RebQ-I1XBchlN!"REjQ!iqBlrLH1f@I2RRC c5Hdb8a6CBS+c,)E4E!2Lq"lmE2G))![9rAE'Fp0*($*$6DNT-f9Eb[EMMGa3e2c ,1"D0VXhlDlD-`c$+%Bic&cmqaDBi-`k5hMK)[4Q(cIPQM"PVaZ%h&TRc8I%[`eK XpQrH8S0K+)##62$aCDA'A&plh5a4"$c6qpf6mcmbrb23M1$m1Q1U5XBhQ9"eT4Q pZ[6@$pm8@lhSPTXAa@kCY+biUQ6q0mf+QcqpE%j958fP'BThD9@P#H-pBPbP14r 9ler`ZKQqE-+[kJ1G8KrL*-&*0hpkr0,j(iiZ4Ff)qCfIA,HmX3Pi%)2j(jkriCi EELG-FjXDfk6+rIDFRibVrTe+-aci&&r5C-TR0,RIhf3326kRb8feYd8A98d`SIE @'Bqd(jiiVQT)qmHU5YTr&l8RfMrErNANGL2hG2Z8#CAZdq[$A3p&+phR@X*(Ged @Fhp`FD9lT#emr)(,TVY2GRkbFbYU(Sh1fhAT[#BcBV8ahlLbF0R`k+,S'V-`ZR" SY4P@E9B2A636,"R41IZV-jZ19qIG2r[DQ2[pDe$ar1`G-aI2DJNm1I2DkHlMEIP 2h@U#0F(*jce9@a0Xrd1GSLTF8eK6kAkrrFjjXjS[@CrIb4kQqVc1fXiQpf#0bID TG"q[+@Lr%pKpHml(c+Ef9FbdlkbT--(U5[GJCkacmD)B1k+LB+$L$i$Ja(Q9*V4 kG&8af$LLTK"F,+dU!4Y$B1X-XV'U%YaT"9YZarYCBF[RVRpaD'q@Jl8YbfmcCRM imV-2@AQLUX5b$ib-,X``F2a5-1jMQ,Fi`m'DDm`9l6ZNjN%lG`RQ,[(QTKl-hr" TRAT1iqSeDr%'r6e0q*4@cPcEh%T2CNaYB-[SpH2RM*mhr)EKFpF8I6C`9c!@M!D MqH(m8#JI[m#@#dr9p0EdMh2(ZFfd)XjQTmDT5Be,PI8@iQH+B!!`"iFCYQAiqZ( A$jmhSAK#F-lC1E4qh+PaUA(XMbPq08GJLriLGeeqlfERmRXMGhN9R#&[[riZ2$c YqFf1kfjfTMeri@'[#Jd+m62Q0(YCcaA'llpk,Uh+G4c-5N'HbGZ#5E[b6Z'p(lQ ")9JIh#,3Kr'1"Fr@$hUkcq&"6!qHHqJebNJA`$Pd2H)aL'IEQ(R8X9ip5Hjjb`, 8pDRT(AHUV+X3Q(1AmHC6daX1Kk,j`2`re[mAe,02QH[ZckX&iNm!mFN@mArXplL JrLCU'G3$mRY,!UlrFlh-lD2qhrZqC3'Drih$re6rpfMqjlj$IV!-9TU@)r!-(%M rN!"CmcI!mca+*`S('TS+PhX9("8FDAKSV3QX$FeB[$k`c!6ZmMb4QbUIB'EGZ+# UH-EUQL(YfkV(A@2Qc9UGreHe6D%Vlea[d1Z$kddicm4-&)lYVX&1YJXRZR,SV1a m%kkG"45Dp&ec6E6d5kpPXZ(`PeklYYEY4&EHIVh05[eelR2)bYZ[YeR@fqaaC$1 pQFhh*f@f3,,(m6lTpcf*pfQr,c+QA$1DRB!Z9eDkh6FZQ0j[0XjCf(j[pEMI-I0 Q0Z9[0-[Q@IccL(rRl'Z-jU[ca-p+c95bBZJhVQX+IH1k9D-Ac9a@rPHecF(&-qd %S@r@VYf(UUpFHFIkJ1GM2p@'l4mfNLf06DY0-2!pF0LCmj-aeGadA!N1JmZP9dd d"Cqpa36KBdG%&m*lKdD$ZG866A!*CUmHhYPNKY0P!d@R1[mQe06NFBq42MA2`%F 2A4fY+J&bPDl6IZHISdGc3Fdel[IDl`#lKj!!fGak#,*h!T@VL-Upp09,X0'FfE" f,G3'U(b&bJ*8U#VP%l%c)GHJ)pGjc[Tp#pS24jGH0XFp#IpmdYp`I*hZQNjrP3Q 1YCZ1jpURC$FFTmGM`h&dpk4+pqM4Kb[0N6Z1E)0L&D`cJ5C1GaPf$Hj6d+ZRGPe k#D*,&MeedSbiGT(lj,AJK,X28Ejlq)2hI+MfXRRZ9kl*Vbc'hL8aVmPpF[AlhlF !U$qjCfadkIM3[$Y@ci[1P(N+h&j%a@i+@24b@e&cARXlFq!c[2rpb($b+bDBm'G LeB'Z15IRG%ir5Tj,PkUm2H2!jl&$Vc"&e@E9L'[b&Xf-cZ[mEQ@S1[rqQGLe**B 9,*VjjlAVMXbEY3il6Xkb2YbjaJ5a3GRhlKLBM)VU#C)rM(b"j,%Mq5U`D5jQpYh )B'F#E,jkUbPB@eie"%KfBhY9!(Cp"G`q$'D(cmiKFrfkFk@EU#T%jbpkRE@6-'[ LXV(cDV',mATGBr+JJBr2@BKq6Hahf@*`jPB`,C@EHA%-D2F[BZ5`,M3"BK&C(3T 9`Y(dV5i&@al((0ZJTDZ@$EACHjKPVRhRM0AY1mLSpJG&)iTA&FK1k1)-B`C4Y9Z D%YR42)SG$CAQGSQiTrRk,rG$%fU3!daieDVUkYcEK!IcUeE*0SIU1aVk"UdE33G P99MhMM-@aeBAiah&qmTPNl'lbU[*PdeLbEJK-pijrjZTTlNcA-DY9VB5KLQ!fQq b9MAfh'i4$cFdm+q&qp@'ENjY6QR'FhpDAGC9eT@haAAdPlH&HGY"0c5eCDCkIb` k+lSYr#IhrmRpfm+cSV&SpDQbUl#i,8@(B*QTfEqjDh1AHdTrc09NkmQdc"bak"D CBdZBZImhKrlqpacK*GIG[15'5eE10qh9PepeZA"Q+&Bb3Jeeaib1CCqcD43-TSm G+bRaYq9)bF1-[*NF0@U8-CNhRrq82hciX$'C0jpcqGaMlKaiJ-,CY0R2*qImT5" AG#kraG`PM`PT[-A'@b2F@33LN8JiJ&mNFM(@L2cCq')[IPGe(Rr[mQ0pUQr8a`b 4F+%TJ',+X`S2P[3XN!$BQ&K0LR[)CH1kbPjHlIfJ9kR0[CY-6GHi,UhIV'X2lmR @bc,QaKYM`6elYK6IH11b)3rH-fILqaSH#Rm#@dpM2S1&663XDl(m5d,4D(M*L"( &%bC-Z'6*L%XQ4LH%4e`I(M'KEG+N+bH-Z'VL"kmD-@EbL%N62r#cTjpDprkrrNE GklFZZrDjThG9l[hV,4F#Vj'i3LRYa#*Xh@%6I1lV*Y#maTMR1NdHYQ@&VYZ&VGQ TF5kFSZZQm#CUrqHG`VXAle1BSk[QGAG,$C!!*YTPTb$IEJScTdc!lB9p3S%pfcJ RBVkfHFm[bV-0rU(FEr#VFY[J(mZe!I6N(apErXE'klIeAS0XrGRiYr9Hi@rV,q, $r#rLLq+rUIpYrlF#U(q'$r1rL*&iLi6kAceSFpmr24cM0qAre-!U35ErLqTFr5q Ilp[R9rArSi(rr,E"EbEicBL$PE*`9JFRVSk2GA6LlJE,T8'fA"VNdQ``U0&K@%* [@(@(@8(fmrD0h%!HlcIbF*,'C0jdP[mh$`GU61C04rUrmVR(qPI[@AJf$p!(bc@ r-*2@I*DL'GpV(aVLA*i0j-Neb19YJm`3Jq9QM1`p`-pCLh!IafI,h*Rchml%Grk 5GF`86Pqim(UPm5dMPpbq[L(D[#TDhp+`[+eKCA49ipU'eLAcTpp`rNa@0,ImII% &dcHd0DpEhYCB[hcYfYZMVHXEQk,e'eTDQeYUCla[5@4qFeYd3bY@eG(k05XE@bC F1Q2'h#9Pdq[4!R0%@pZ@YfR0X0U'YFY[Mml%3@[cfJC@K"BeY$DdE'aBb@6*JYZ DFX@MDjZEhYN@EF4&AH2bYBdIDiLqGd0MrDhFhV%mXQ"p3j1#&'eGXla&KaLfD(P MDd0dmGcC#qFZ[)i9jA80,DfiUSe1`(d[jUPIXlaTGF1P",CNrSCeLcHX@Y9Bhp# kB1Em*8-QmVPqmC+C"9,f86BT%p+X!MGDrCVK(Pd'brcqei&Y%iH[EfPBeG$5d&6 I%0fS'*!!,qI2E@TVD&Q15pq0$G(PUj!!LlE@Yc5ZEf0a+F"DJ@ZaD11kpFdY8K* DX,kYF4hJCc*m)ik'9h'UP5$@bXCQ&JfYE9LaBA9d2DkC'i55Spr6d,!qHKX1KTY [L`,jTZD@GF[A4KXqfLJ6AA#f[+'P"6"ke@2Q0i0c'BLD-6kDV!3(QeHc36ND,'p CA9q&B'1dBGf'YC!!"X9fe)+ea'9jdmVP,5Xa8Af$0'refVBfYM9FY[lfYM8BU(A $HJpLL01lcA5DPL(9PC-V,jmSdD6SPkkS[Z++k1,kjVDfk')JZ$D+bD,ec8fiM-" PG(0,+eE+@!KQ@jIjTpa4bD1`H''A()qH,bVicpRcc8eQBq#Hi#T[G3JD2f9-0Hp 6Kf[HrBREBriXd,Fr'$L$-r+KLr8Xl'!`q-[cC&EbH5"`fT4I0@2Z$CQkf5ehVer cCbYZm8qAfHZcRp$pMYHA6fDH%(V*FCrrc2XfGS`mPI@1K4M",JBCIqjch&Hj,Na $2Q2k(([!AX!Zb'#A*IBba"MpLKLMAc&Mp#Z4DE"8j66S0i`a+NSCSarfB*rl(2U &'D0I4+B'AcJeqTh('2e'-%EP5-ES9miBr8B*1Z!Hd8'r#aLMhfM'k(FKBh3B`aM p,K)8M4P,&2('$Sj'f83CSprE'+2IaBc4kHhfA2m5SShF1aLMhhM'k2G1aZJhJ6( kA5UX-'BL@B&-KEAINaLKhf@-dDq5-IT9@A0Hc@VdZj`a+UjJM(l!8[JaQ6(k6C% TM,Q58k$IZaQMhe@-8ANeBr5EbKMpTXQdaPc$DG([@XESpcZ-dDq'-6V%'+2IG%% &Aa`J+ZJhNc(HYBc4lcV'k$H,-6V0YUZ&1832rHBb4XdmaZMh(XESGceMp,Y"8-E e$e&'CJ&Mp&[)'2hHbaMp&M&'[mA#"Q1@f%2%'aQMSSiaqYh%#2eZCSaqlj-UBpl 2+[6l!'2dqb"M9(k)-ITpQ$(k,CAZaLaMGr4EcKMp9M"'[hV'k,#5-ISeb*5J!+G %[p@-d@m0Bl`E'D2I,Bc4k9C"!pq2)"VSYiiaqM8a4QdcBr4EcaMp2L+SiIX04!f C9XESemBBrH$HK"mE'D2IEB+Z-4mPZZKh1f08I)`aq[dZBr6Ea"Mp2LiX-1Ehb!, dfm`BrEB`4Z9@Zb6i"#2dZm1Z$$l*,2VGb4MpYM&'[qf-dH%ZaZKhYh3ejKjf4Ep 2-8DrHaQMhhf-mIjpaZMdDEZ3!,LIdk(I(c"'[mm`4VmrC)`HIm3BrIk9S'$-(a- &C2ieBr6l,'2d%rDLhqFCSpmA"#eMGK!Yp0[*'"9IC)aqIm)BrABa4Vm("&9M(L5 Uk,HE-ISpa"L9ImSBr4jQM(k2#2V'Y"0pp2X5Br6E`aMp[X`B(6VXRQL[4-CmK4( kr4PMp2XUBr6l0ic4EjrG0Ae0ZKRcGA9SjYmb4VmrCiaqIm%BrEl"',f`Z"&qI*0 6)I-YA31D[f+-IRr0'2fqc4Mp[L26iq#$dk2I!FDSq"['k2GGaZL(cfJ)2rkGS'6 -Sd3*r4jMM(i('D2bHhC4KF0BiFHr&c3a*0&%[rr!'2eq`"Mp$M&'"ja+#$1Hd!f EH9,FXQ[q6KHC"MjD*[qK,V,-dic4kCQ6*i8IKepi3H*R[`A%m6bhCir%2l,EcHG [`md@RKGN'@I-N9SjJMF[FKq)Tm[Z*emb4LBkLPDF#1F)-Y&aIUJ$mBqjq86mXM% bd8qiZ82m#Qk510'VZQ%d*h4cD9lM8*MSTc,m#qCRM$'4S)D*AYG9Uq&P"LIkZHi #p6JF%f&,)"0ekq,8a%r+Ba)[b'0k[L@2kGdMMqQcqpMqfq3a5EZ!60A+Bp)Abf- FHqZ3!2XBMEFcd(MS9PGrB[MIUM%ErCBE21'FF*cm9p02T&pe('B2jcV%[ZG@1dp d22,`VSFhlF*[ibDmc[eBZ'[6``mmr2!MkEf[1LG1j#DBp`0hUV2hB@Rpq8dIhlM TJBf)2lpTemCGA[4aL@@@MfXSdDk(pcl4rATZPJ92Z$A1)fLV)fLIA4cU#j[`f[K a&##*J2-mB-H3!(PdVNf2T(qH'fM*hlQeH`'0['3-pN-[)L*6BKDCP88k$1X*dkD 0$bL!'hIY2G'GQqUQ(lUcdc+%$+E6%4j"6JX%HD'%iJ4@%$f82F!H5(2#AA[MZF% qm)cl(NH!dEk#N!"3!#&Ba!U%V2X#D%A!"&)KbqH"Vb!*VRj"J(4kFX0pq,!lra& 8%`iLK4IQ84)4+J[C,Ja)kYQT13L(9iTa3)`T9CYHkHV,cIH*jpch1YQH-UVP&6$ (S)3-mePmKAXkj!2-+ir48ZNX-$c5hBraIZB15flYcrc5Hp`#@e"J+qj5$&Trj#j fP-G+BY"%dD8)bB$#%Q@qF&+3!#)1j"D"!8D-22QKhR3P"3A6%cFQY3GadL505GU KlhMHAH+JSmcQcF[H%#fV0"Ta(1*,C!9HLl*SJT8`5LM89U6#QcTY6"b,EEijI@r D"1*dRP*J3IMB#qj0MR)j`b+-44)SYdK!d3XV'3$I8X)A8H%#98pD3LA3FY-M*p) $k1IK8fZB2%Z!E8IFQm&mJZQ46HDA-6QY5*kRZ&UMV3#'+!T&`9-mBBh`J[Q(GAJ l00r"1&%)p-46IYVLF2X4Gi%9HlDf@Uk`%R-Vc6TEMUMmCEKV+D0-FMB9U$Pi`[P R$IM8Lqk()+jL8)3!)TV#Ee9pP8%l(UUYm$bJ%&R9&(HN@Q`9e`Ve#GF1ljJLc+q [Af["TLlh`fPV#MbR)d+SkQE&32RJqaI26SPp%f@`E2LmZ!N)J88-*SYZk0G8Z![ 6Ur84&I$mN!$(1FlQZ4#YY*ca"%0XNMS(ED@""3lC9l1bf#m)&"!*[+`HH%KXIXR 0haZ*6#Pa)RP65L*6dK($9!P+R%JK+L)P8b)Pk8J"-e*dAP8%&9)R)9p6NT&Lc60 !%i0"!NXcXT3f3f6m$"8m)RlLU&[D*300+Bk8K*'SFL,P8h3DrX*9%4ap)bUH%RB La98X3TX`X4L2YT0,`P-LH*@%5b+9E!83T%8%m`#-iNM%'D"LEa*hqB+'4i)lMlT MpU*eTF!3+CNF#CGJ,%DB,%a%JL96`))43!GP+!3mK)36SrTY!K9'&[J"#*X`eRD )lP!)U-F%Rha8MFUU!Q"iee*#%#DS3,ii&)J(3dSaT"82$)akPT!!#N#L*"9j1jY kB!%9B%iQ%4-f*`)4d0T-m455M26-NYL%$#-$BiqjlqM#)'5$m+%i,Q`*b(aJTZ) "H00&)d+AKBVN,Ep*b"89KC*&3d*&+,V-+3T0BMeET)[FF,j`h*aic4hZT!ImkYC Nb[Qa1p6Tm+U5(FjaYm6Tf#TCTY2Z'GGkfBSH%iUE2XNPAhH(Te1$`fa0TBqlCHR 83&&&K[GSf6&Bc!&IGmF-e!cm82qmd[6EapcJAY+T%#3"S-93f($3SbM8#R)8+Hi e"A'-LPqIX$b2NkI-H5c*[S*K80RXIGh0lkMV5F4I0m'Hr[3*0pK4PdK!TqY5*p` mTkm(bIaiEp)&"8`mJElShA[D$6Vp25G0X++2,G0p&6qMdk'LS"36[)kTHlFkTpd IBj6Hj"(&lk[(h+&,`@0)Ja!mT#DaV!K`8P5J*#bFc$,XK122Qf#S*"biSqZd'qM S`CBCc3,arTqkT[q8#I3j*`P$(+QH00'a8&0Im$f!RM0SQU$JG*1bJRQImc-hN!# -Bdq056aIKMjpm4-Q8*%mif,$M6%#[HNZ9D4h%$'`LcT2E45AQcEKH"&NL,)SiH4 )X5GXX$c@qJS@&K)3`kZ!*8`8"jCfBHSNB-SB4V4'J`VRP"Y)`kKN#`&k43I+YLE k5@e2[11"V8lFKEYKA[Y``YkiQqm3"SL1ECE!c1P6+!-Y!-$3!4e3HaJR`rVH+!A *iZ$"m)'bc&#TPj6BKikj`i6$PYL8RM#S5ke@ae!5+BVh+!0`$8"qNVI[`ZV1G(J E&&*HH35i32QN-1Nd+5rC8e!LcKid[8l#UK(l3HCk%Q30pBk6pD"P"eUQ%U3*VK% X09,SNK5+%J1d"PG``j!!SDDT1kU!ZZ!p,4@-"f@eT$"T#Y'LL'B2mNY&J-FU*Mi iB[$f2i)A!I"bP#YF*C*G6SA+-'%1p$MGi*X['!!bLB+dVakp&+FqBQ`&4)PVHP( 5k`fC0-8q'bX`8(kDK+#jdp'&50)ffH00bljH0EM4Rq"0TP6`$DEd(e2K#aahKcQ 4%T9UqSZLAR0H-DfP'%peZClDHZ*h8X&5fbefhb6)e$k2(cQ9""4EK4G%,Ch&-Ti '6E9-65[JL+GkA(qf2Ui4HRTFNr4Q)I(4V`pU!ZR8'GNl)Hi5RASi$5Y"[RLkafA +3cfYU$P@,Z-Gaj8#lchZQVe1C*K)'2JHUF6K+lVPdaG$"8,LPk%$i4iB!K-)&pX IAN8qh"LV8)ZFF"l#SMjbZ`j`+NMj26"i(HQdk3#D(9D*S"X*X+#!Z#UFbD3660E "!!4kX6JfI6jSTLq9,Za)pIGkICeHedRRB5+ar'PCZ6MT)!Tk)6'*2STS4lUMMb9 @U+#@#6IBlr%cf*rZ5!'-G0TE&HJ8[DlCUVc'#,eT"5aYqY1Hd3!R)6K#clJ1J"P 6Q+&#+@9FH11pBBL+HJ9Z9`*TZ)X3A"4Y2IB,)Le9B@Aq-(`T!5Sm"5c$'jDS0)3 NFQL')!6b!c)""PkPSZ2RVNRhU3X#&#PS6##Hl)1Dp&SkEJ8(R!6p3Aq[QjH#+P$ d6qZU#)U81Jf"C5qS#T`2Y3AC#Y&2Y+"JfCd&adUc"Kk#e+'FpEVCCA!mVK42NJl p2lCqmVKEIMGA1$#8-"EFSd`T#S@+LLNS*Tb1&1R+*&,XD90TSDa5Z&&#@'5&PB3 ,L"FT3$pBN5)K*8LBKhF2#0%RY$i&$brk')K$&laP03N#dJ5F2V!Ql[5j*U9#`-, q(MMiZ"QLeK1q@Qb90FTT%9&-N3DkDK'",T5&ZdkV%b!*9GKU3JV6B8be,kMXpaI $3%QF+cLV8J`SAPDD[1fi@lL8H!2fG'3dc4D00YdN+)20"i@#pMZX)JVc%1"+"&5 Bl%6b5r"08L%+k81a3%04F!S'`H[M%NameHYfH[S%)*@fIK+1+GqTX*U"1XL,%"+ '+G"[N!$!VN#h@@J'BTLY[YK#0Y58D1Z!ibG4#'e"#kXIF'fqr3!emUb65I45+Rd 25AlNeQC+%8TT+ABT)Sl365lFa**38d'-2V#!LTQJKT*(PA4aPC(,%YJX8$R%P'2 R@U4cdDN5k!4'lRYG9F-DaAlL6faS3i"r"`Yl5'YV[qTS[Z&UL'-IHD(V$ZlqN48 j"Da3$0Z[$ldpkfE&3ReUUYm&I93R[)R"e9le1L3#iMUV%HG4)iJX0-+*A%MkFh8 U%S&&-6Q"0&HiXTc4YaYhXBY-43aN)U!QVGm-%e-a'5k[%U[H)MXiB!G)@-fCY#9 rACmEV,2q"TCHZ*,3'U@iHM1@eCh*@)UXK)Sf@0p*"[8$6r88XLP3Q`85G&K*%X( d*+P2[,SR"CjXfU924MM2TbVJ9ml&#aH#N@RFBj4(k#kQM%,*U-QJ4kmilN+aYQ% XY)16)fApGP&@L*TN*"r0+*Mj9-UkNpKU*V'0c,`XHd`&J*5PK1k4m["*3-%D10D 41I#X2A3)rDNkddmb@)+"'-'%1(V3`LiT'B*5TNj69MEYIBP944)NQ99%Dm#5DS! J,KB,ci$CGi896Lah4cC-'c9ee,6bDD-NPJ!rCeVTe'P6NH324@&TRB5(B$9qj@J eV3bD1V+3!%fRX4Z+k$ENCEeN'P[69dKP)LRbjfNh5)XeXe"$GL*@0K)G[IjfA)' RD#381+DKRB+YNU[IFeQi*b$Rb)FHe[Mq#P83+iJU&NQHELDX4!52Zq1APJ01`%q Bba9XT-Z48k#eZPb9jlc!+'%%S%D%qf@PDRkm%"@T8IPXrkiq!4q-hKSAlRR,&E8 #`J4H354I"VI9j`358,6FC3)KYke&*c0E13#[8S*5-3aH5b+I83Ua$@S@J(l@0DK **#%Y"dBGGpm*$4!"X13AJ$fD%fK9Kc$B&Z%FK5JL6GKU@TRUqE#3!&*(D$30mQN `,FDXNffU"hpfak5')FpE%HSpc-YZX-rIb+%6M&Lr%%-i$9VNFANPT)F1N!$6b!J P-KiU#$Z,0B`p-ZKhmcJ+a[4XTE9cS)9PPHFXV4CliYJ[iN!1%1STB))`3p4"pC+ !4j!!QDc3PN)%f%T&Xmac9*3'bk94j@8UJEUra&D'j1%ECq"CA3JiG5bbDYfAc0f 9!5f4"iZ@l+3cLda53GPq9K3Yqd3@I3d!"e3Aa"b!)jiSM$cZ&M83H5'q-fS-`)X )i&-B!(r`3C3#Ri9%ih!#L+1"0U%*5()2D%,68#Am3@F4"8SM9NKT1N02%58@+qQ l"(eMBr9MVQD%SE58D4'$c"*'M3(Q%M93Lr+Q41UL`EYa)"Ym"S!2JAlZ5d%&0[" -dU#PY*)3'1*,`V4b)!fY!rf&$+5m%N6p`95VLN*q&9(i"58!(*1+bE4`cM'TA() 9*4[DdllNLc"iT[+BLTM((%KN6pUaMXP6-b&'cQCc(T,#fQGI*+@Eb)2G026lZbV ``9rMR,00aGLhq1UJeXNC08,3pb95NZ@bNm)a4`*TCj4ia`KFSdjH5YFN,*!!YM! 5P%AZkp,qP8UkS`F,A#Y1eLlS(8$b+"`1EQX5(Djr9fEpJC9*3+mbSPG[`S"qQl6 Uk'mCS)m@H0"J##bdAHISSS&FmRC2eX0k,U%2NY!JTXFUSP$EilakK&'65BM*rE` liNTK@KL'@4[!*Q!Y"HmNRP*8!Ij4TU4VPDdU4hGXN@F5F'f#mihFLT93T[ac3c9 C)J4LR83IFD`ei*6B5Nq94"3YdQ)GTDRkCNpU5!!Tmf3JrlKlk9+a0HU4V"VSMiK 19[bRPCF,JB-"&3(k"1LHXT5h2HUE8*Z(VUM6l5fG![G#fXH6)DZ$BK5XNd5MC"I 0&+jSiPZY9E,E*MPkXM4j`b0BPq6[h%3)V%QaDZKCaB4G(Q4'pPK3#J'ifrI$m!A LJqN,%#N"4"#JFhD4AqME504D6c!X4%IKX8LPN!$Sbri`"5mY@dK92ZSJ)4BER(c 41qhbMME%&24a@CIG,XNQMX"$&+f8#!8'GdYUaM1LQ,0*BMZXcFbY&$cVk!NLPV( MGCp!N!$TLG3CUM93d%Q)+HA`5(*-THX%ZiC5Fd#@D%rklJLpJDlHZ*Ee+%`q@SD +(&KFF@%Al%M%Ibkh8DHXG4*-K3Hk3XSXl(eM*0Nh9LVU#1!4P3Z"Z"K&rj+0'(M 5E!9Kq((hiVZ"-*dba##h05#LB$PVUAEdcUTZBC9!9Q,[4%[BCmTKMcc#d"EN0Q[ BY!)j%3"a"lTU#-3(V1),m!qb9[I2$(Jq9)%26q6@pEjMSKF3LI6f94Q*P%12h-T aF0(%e5dJX@U6NfR21"Z+T,S%+j0@%G3GUk0Q2(8U$PZmCB)JA`DFZ%S32G$G`l6 bR"l)jM!0L3`Lb1cB4-ail*&mRTCD,mAS,")iKa[F)DL@CCEb[e`J[1Q6l$iTBjQ p,9YQHjP9KE&hqqXehC)T(@4Y`'fM-bUI&"$2c,Y+'U-TYKRHCElh'"D#YG!k1JJ HVRKq`IFh9)@X1&SIBC)riJ@3!))[AA&10VLIKjI`pJLjm`erbDBS@2rXlHe)#f@ 4Z%I['-+A5MZ6h50i9L'1eD09KDah&)Rdr6"C`Y9MJV+JD`@a8q+ES3PX1"RG`Z5 (ZTFF%5d5rPj*5CPm9Li99$89)Tc)RMh@q!HRi(P'ccQrX9ccR,2R'$05k8P"1F4 `,l60mh[Ph!ISMS$1N4j"&QeJH-EF#pDd!ka9mp`['a4I"d392AYQClDE$fXIa5q )'RM'm$#@,GU89aKRlhM%D&N%)!8%15NEK1`Dj+`jH'1jaQX%p9rH,L9cY((11A, KHVIedE""XLH`aP"A+YjUD4UZ$-Z`$r62&Y!5+PLZ-SbGSh8**)TJ)DZeAXSMb'" h@EKlSN1`AZNCcq,CBdfFC!fHHiP03N1lMXri"1&ljM$$N`4[SDCk,,B45`Dlc4( E+P0Q9fVMMdb0A63e0M8fCMUL-E&B19r6Tdi[Mdh$Embdf06TBkD1QDV@UV3`9Si qV#f264e*I5bdkHPS-T8qQUEP&ll!XYpI1$q0HfVH"`U`JV[ha3V[$N'`%R0icJj iai6d!jQ&UHq6,0[&&@68+G0S3+3Y&iBHGiGm,4DE&CZ9MJ841E(b@E(BE+Q3!*m Aa$,,R@'&8M4GJkYpHI3k)CjeG3D!$)lBpX%fqVj'M))k'9kSE%e@'1G90jL-@cU T6!VAG4RM13@lKeG,IXir$LbBI*pdpKV"-``@*FmkAh$F$Ak0*)JH&SJ468GJFC@ %c9kGJ#SQP!9+NC(kV32L@q%eBc3bCj@b"SILB,@4ac9@'ceUfq[&$Y`$TT3a0%' pRR'bLfIC1&KLfSfEEi!iJ4SSHh2SH@TFb(Tl0@qKJ1-P04AH9Z[0+`8X)0rZ+#@ bU%[SF88!Gf++cl$#@GT%bUp@f4P@10[M&8UcbeG6S8VVV60%!!4#l`!`FrFQUd) F"!*-DhE)#ZpS"HHG!UqhLmH9Q(!rGlb8ZAIc0dkCLdKF)qE13`H2%XBHGd-[HR, Ja2+BF@*$&(dR0XcbJH@qDK8kXH*CXBZekdMBNL'85VB,UA"N&J5kK[3FMh@5eLN 4H6fh)[Bm$%@c)#M$hVcCar%HKK$h)1GZ@@d!,l)L!kA`6$4QbXU"e8r[8L%2qU5 m%Ch`I1`EYccCcF[jX&CG@@PiaTH(R'$`9k1%$bFd66-",q($A3#2-6dfLaa$IlJ *ZiQc&YIEN9X"*9"@31AH4DpUF3)VamLiMF5"ZcAH(D4'p[4,+H9G!ZV40-iLm2Q 4Fp**DQEdeITYlj!!(FI-fF1[0fmAH18cV%ZdJCa3rX0`!H#bLeN&P-GBab"RrI4 B+T0`Tb1a*Z$(93UIY@d4'@XXr0eF@Jqd["@P0BbikYA,0eTV(,ad9)MkiE6FQZK !"5qdSD8b5%Af*Kehef53!$"1h)9hJkeR)jif@P(e9TB8D*!!4e`SBmcXVbAH['Z i#$Ffj)1))3d$f1(%#QD0!IZC'HUaB+6P(Dd"Z*&'$a'0Ule&[e8@84Zlk1H&MrT V1JZZ,&9FX,Z9Hrc-88l3f5UE!fJ&EKZi8%R!"mJaM"b3!'h0V6'4Nl@-LJ'N`rT ph2i(RB6p`)6h33Mp%%$D1`$b,fYPDB0MH'm0qJm($rR(A!-@M#@Ucm6%4+LS`Q* RV$P%"*[F3V9D+VId'B9a+b*L9'D0p-rK5!Yq(L(Hc49E!P+JSNqP6pBC+)6ZX[S "#PC@`842ADT2A6D9$cYY@8GS3ecAmZX'r%j"Ye`NfbqHH0pA3BqHH*hGI&1Km3% %-SP,L(LGY`IN0aaqIHEJ((0(0!ak#(J$`4f15m46Dm981,,1K3Ihk4'6(BA#Ah5 jf0)a)Vh''JZ`[YGENZ$MXEjf!X8kjS&4$cjd)TIjqV8BZq#2Bm'IYa89Z#N%JLU ,qTNIYEEGiK*H8@FQc"$P)HKX)c[K([rZ(YIX[lKb'(r-(A+,"I`LIlFJ!2Z8%1T I$FGE4Y*E&B!mNQLbhTDmB(mi0X)UMHlUJ,jq*8C"mkpI45MPQbM-kK8-[Jr#laZ 45*N2Td!L8)X["FQ*[VIZX4q0m3b6[-r`'c6HdXXlG[,YC1kV-8S(E"i([rk$fEB Q"Jakj[,"*2!GVeZ8(4j,r'd$Xh"5UJKM-RS!SfR0HciedZTd%D66'Jm@mKiN*8U 4`1GZU!,iK(%#Li1dhFZ6!`#a&h)35#E`[@(j"NlI59PJH!FXq&K3[N-EEXm0&36 F&1+63I`NKRibL&B6Ad9**H)[mh-KhPeGaMqpHHp3FX`00eKX2I5IF@)4cb*`k5, 'XdCfZ5)&9L1QAadh`bEP[00)P85!kmLh0A,Id2'qF80Y1Z2QFh9N,d)!4lcrT-8 hd`T-aC%B&+4E&h(ifP!5iZH[QIL9,Pa2iBY*m[8`lkXcH0Z23cR@D@6H[&[(9i[ H1*MdM(BC41$hXhXBRa,@+ILqDDT[YdUjb,-pRS&e5YS26`P4Y2ZBf09bqU&ZQar 4UZ#hR$+l+RiUc#kaH2K5dI%D8I8f6lhTel"1J6h+IVD)(`T,bk'%LJ(EpU4qkSB FqDD1P9bjIE$@!3e6FPZ@qB43-0j(3FjXZpmmJ3JFFdIHIBi1eNf8#HLdfRCP"4T bmb,ZL+C4R&1IE21XVRME2&eTrY`GeV%9hh(c"CU`!)X%[J#6UUX!epeiX+)ZpBS la'%HN!$Kh$54k0fDGYeAB6al+lLPJkI-5rIh#,K+MraiSUH[iaApUS&qCmRDJp0 3Q54'84,`meL*#RcclHICQkKc"V,[Q$YkhaX%X&T!L5qNXiE"eSf$9qFYDD#P3K6 r*3Z,S+`UD-qari[0iQIVm0pa6VKKrFG(f9GD5erMCJE[)q!##l%cHPNVEB0A`(f [jiqN@4$j0$)TYY3CE'qY2B(DY'3i@,IA*B@NI[R(0"pcParaYBh`f8fNj`NmrQI @FhLTBk",cKCCidd$18#J&`BqrBb"mlJ#G'+`&GUIdM'')f9mKbr(c)SV95U0pkb EPRPZ*UIMKeIL)hRCJ4I#(AI90pEA0kEU6@1k2YKB[l)AQ36HIIAj+&cCk05E@jc k)5ZP6Eqd-5b5Gl,H(1&hA2f*eKee2l4p4H1f&IAh0M*Xh)CJcEBeL*PEJE3QlPh "h*VYDk3#ME6E'P6j6IaQfTRKPlX'aVheU$[j[QhEkMRPGRPYDpbqV9j5U%'K*PC XhmjJK45Xf,jQZl1YK1AS*GA1YP*JJ-TYGSj'CeZ`FG[fe0PK2j!!@P0r(rX)2"L 'hI@&!@e#`@M%P'ZfVlJ2Z6@FQN#[N9V#fVJG$+K(K"I)!LbfG`d-HmY4prTpJJD D%TVlm"0)14&kDQ)0Nali(&%QC`-15P"@e-X8!,6iAMBN(lE,"jMp@4Z2ZZp,#hc %iel,!!klJYNe##40VK"&55U29Qbl&lJ3(3jpRh)@66#ep'T-b4HRr9&A(h@(G!Y ,R@hcbAIKK3b`V4kc#Q,+AANTAmQLc2b0Q*N-d$H4NNTmN58lD-0,lShES383&L' -r'4`J%C!PDFF8%6#6ZEpP$8%&,e)26,Y2J#f[@[J)q$e,lQ,-4mjSih*&fU3!(* ##8qbJkdBR60)VBS9U`'Gd&P`9fTKb)(2Q#prbAe[Kdmk1aTqQ&@B+4)JSbNM@)S Ud3[4*+'b-&Xiba(fBCH8(A(T5qi#4hZMJA#Dh'5D+SRHbJmT3X!1bKPKNK#3!*5 TCrQ+EIGK[Uk",mGrk#AhKLjU'H[[VCI"20#3!0VZGRfYibl9,XY,NSM84!`1db) S!TKYhiZ1fcA`TIZ&AHk&bMATUaSTq)!KpIHH5,T[Fp,llYZqr6l9D8j+k%"S$26 lAplA!F"HcNed$5EDKpl#0lB8jSSH%61BCXI*Hp%jJlljEMIqkY3,ZHj9k1kJN9" 8H@[&4!"1Blh9r@5ZiF3AhG'U)9C+P0NULY[ZqrDjA[XX@8@Qe0`3&ZM-pS&HPaj a,q$8H)'KSRY@e!Rri&K(h0(ThBrYa)ZrJlXI`qq,51hHMGbA[j2V9A(%[E$li-i $1`rX2S"HL2Rc%qRp!meH!"P3M2)(d@d(-JGflcM`'&kXF!i-0([H[I$3MJ-lph- `a!GflMa`B$IQe''G[mNeQr3MGm`K6)-H,(l``)-+aJlQGMl@fIfhZ@l[rT%leMQ iN`LJN3#e3cXqaU+GMadkm5Vf!*f($Kh%*i'cM@ZHFk2GQ*+c%E`(L6T63&!3HZa 3CrGTYk5lXr233eVE16$!['IG5jcGK!8Mlf3%$!Mr6Na0&Z`!I!F2(IT6jj!!HFJ j@#SG8%9q!-D"@HBqidikL&N)K#,(*J!###UD+&CdK6-21MZ,f!Q%12"deipcSma qfSed@P3a`S-J'P,1cS["6ir1Zj@i(*HSDEF$1r"0VX&"hYep4KM)DTQHq1iJ-8N 6CJ%+!C8+N[p"!@m3h1ZHFUH!SX)8-'6hJf!SQT!!f3H)"dB%)%6KdCf2N[C#0R3 Ca'2QNqkl$K&,MXKjLD)`P6!V!Q!*Za-(M#XeJeK-I`*,T81%$Aa2(hJ(!"(T%'+ !6CKD58icI)!ieKpbU,J+K$#125%S4$X+U%S3+!XlABjdRMZ8'Q2CpY`,p#B) -MC4)+(Z,LK&@+hS($h@G1*TVIZAMlRJ)NL@YcNG4S,3U[`@"a`ijA5m1Q*I(h(& 33A%"dPN9JJPfhh(`i'NXfA,1iE[ZD-VQ3p!P+LP%i&(BKSF1G6VGAHk*jh+paRi AeLIT&RHIHGSjijcTGVUG*h,ei@qkjX6hhJVb[hlJCDVY2e(arU1Fr40DH,arXq6 pX9$j2dTX1rKAMI0r1Zf5#qp&bCMJ2M0LiGbC5r$&Ychb"h!$aTPjik,&TL"3MY9 UNmN,h$,laVr!Imd0I(iKrU38iSIj0kY`K,#[XEle+Z3I4c`CmG1)mEHi!NIelh% &6ME@Vf9p2f,8"`YQc+r&Iq3+$YFre"8FUhqS+cKab3hileb"i*@,jllr1K--SXX #r%r5B%[Yh#AXphXVkpY@),i(ra,+rhZSJ4r+[iR#rkRKj!Er*5C`[q5H-6MY#E` SZF0Q"[VbhdZC`,-'r`NUq,Z51d0'A)4rF+[r2#TUJMV(-fBj[Xb0i`@TZbPUK[b aRI@$b"h4@GQlB,cfBkj-HapfhA`6PVmQ'RK@FMG,lMR*mFm@BbET,Imj-I"$jJT ACQB+[6mc8a(rc+1C)2p4*icr6)-(hce'$KF5E1+k862QUrNrR3T)*IFDFU([r#9 l4)3MQKXZH0MF(f9cq%H%AZkmjc1j8IJlI(Lf-(F"rXZ022JIPD09DD4Z02m#-l! aQj!!'k(B'"c-AIJ*r5mk`LhlCj%PGi'1)EPh@QlK6bKI0-AQS%-A[GIQ2J1&NRq J8l,NYZEVmBHEU8NPpIaVYrLc@bh0Ya8eirq)55DFVF+I'@YF@HUA5VTB8LhmVlK &NX,rm'V+Ri2rYT8rUl'eV@3''c@dF-"5r#Fbr%'`PSE@jJdYp3hj&,amUYf3!1N VQMF-42mI!%%h!!!: \ No newline at end of file +(This file must be converted with BinHex 4.0) :%NeKBh0dEf4[ELjbFh*M,R0TG!"6593e8dP8)3#3"$2r!*!%laP6G(9QCNPd)#K M+6%j16FY-6Nj1#""E'&NC'PZ)&0jFh4PEA-X)%PZBbiX)'KdG(!k,bphN!-ZB@a KC'4TER0jFbjMEfd[8h4eCQC*G#m0#KS!"4!!!$2r!*!$FJ!"!*!$FV2X$D@P8Q9 cCA*fC@5PT3#PN!3"!!!q!!#bhIRhhmS(I!#3$3j-X!#3$NeKBh0dEf4[ELjbFh* M!!&P6R*cFQ058d9%!3$rN!3!N!U!!*!*G28!!$-GF(d!!!d!$J$VGARfRHHa21[ -[(@EXFe5I[6jaDimVeZSl191YZG[pP&qMGM!l1ARH@hkA9PXahkEAKpVdF8Zf`0 fqeL$K4rGE00f3VB2HZCe(V[&iZF@EVLCr"BHbbl2+2HhHEE)FV26Dq"l30I9p9h A`GhjIEH23!,*`bF$H'3%6r,`b(%#m3NmFG$K-bCJM2R)9iaCKhIHh#*Nec3f0Da XE&LcSTAC4I8Yc@[@,&r@dPU)E1Q0bqTEfjTA0$G9YV5fe0mB4!d6#aGGAaX`VMZ BCIlFXrH&[Kl0Y-@E2pSD3U+aD@9c@m0(feS,N!#F[VkYVEQT0Bq$V@m5#"![ekU KYUTK680p@q0!JHf3!)q#jBe0+b5aERhVkYCK5+aYD&UrC&9c@c1aPCPD9cHZE&[ 6J-cIi-HR",p)d"eUA!ICfIEY9I)*Q')rIrpQ$TLTjC2T'83[8L2cc2Upa9)RV!! [!M[`)-jem@E4H$2HZr$q,0jEmIi$&(abUFE-Xilel%1H[@8#MT[Zr*miCTrGZp[ 0*E9,69&NX`R1Y"K'X`f)ih[`XpdMJ5a8ppXCcddRFFJ-0D@Qc*4Y,YZ(0h*$8I- [ieJdZMEYUpNm$X-S4cM1(2ci&*[Lc$K)HZ-Jp@BF0ZHE-@DX'BII@'614m@r$'1 afEGTF`f'S3!+-X%RPTBDFd2Yp60&%I"-khG2c[[)[)p!-i,ckSbT+KRIC%,9P@E dUY,E2RacE0A#@fpC',[eXUA&95Ac[Q@@hr,TTE1V5QSUc9#m5kXU64M[%H-UcIQ SI[rmemh`T40q94rSP2S3*`PHGXZRabqCpq(S%Y5-Q0IjbEA,'TZ!"c'Bpq&jkqq pm3l#0+HTX8fUh1r-rZQikYqT0-1"6r%P6DCmHT2lJid'd41cQpa8HeYdBG8%%fT [RIjSqq'*ikU'Y(qXUU6pGe&lS[fcl9p%lL(N$V92RP$T(PSAlRSi@ZNqea)qZZ[ bQ2[$Lb[G)fhKi`pF2XepU[16R9Y3meKdlUj,jcDC%DZ-qHC9K8Z(4aG'9jX&d39 $Umf`DV0Uk-)CB-Q)cPPIQp&d[$V[rPRAaG`IA)Z+jfIYQ,&SCN[JU4RA6A1ID-Y rqMB6V!P11ZrTfTTJqarU&&AKQX+D5[F(lAI0RGPmbEVm6[B`eHGeeRBfZ3GU6,C 2TIY%68(lAF$Z1l-rCMDfVf5QI@G0K3P@9lS(1Q1GLaE'f"%9"3-9I`!%*mkY0+& 9SkZ+`FB40BAJBQP9#GJB!PZRNie9PH"1+pKb"pl2#PXqGm1,3hZc(+aY@ADl-F2 $9japb-S695@@I@"NG%''JH1AJ(%I`lc&'3l@A'ZZE0mK03rDZ8X`GiNh0r9Jh[T 2kp5c'eHYAS-hk1pT`UHdFXDDjPCk-Q0U!jY(VaXrHrcFi6F1Rl1kk,1"Zi1aB$3 Bc3rRKd,jq!8fAhLUTVHQIj`lcQfQ&A%f16916@TFUUbh%$p6"!1!16M-X-h$e`f rBIMF#F86JV22cU(eidk05ieMIdcaUcN#Qr8AZIZ+lCZF+lC(l[BU1%2H2[eGH(M UmjXFephN6(hq`X0H&4S8iQI-DIDbRLZ-hhreA&U9kcLBPB)mNlFCNhEPRF*l(h) $3l!qZ&QJ$q-G#jkY(r4dRm1$Q"impp"VP*%ZJ(2S1X4M%-qb-I1SBlekNYccPJ@ Ske261qj8@9FK-1FZimfRTMFF$NAcJIPrV2m[U'HI-YIGPeF,a*m%iT-Xi[rBl`P "r8h8-UJ(j2H@"&crjhUCfdIp[rGpb`)draZ(rkRqlp(mchf(r(!TV$3Y4q!C1*! !rL%cjkf(jhQ-6K310$3&,[GU1#Sid[$3@K0B%jUqD&eJU3RFlANL0e8q`FbmDAj 9mI49082DYeD2ZpE-RENUrbpVQd*AhEA1S0F(ejP`RSQC+"cEhB1GE"G1G0A3QGR j*P`h%bJdkE[QfQMTPer,C-2K,lpfADhELDbmrAUEPIVVhHH3!*@hAfqcV,ICimK QHM1Eldr+E)&NMq0pdZpl%Zr6IPpN6,PQ0$X"ADkUG,Y[QMqYhfbB[D"pHr@ihc& cCc6PEc",jeVmmiKrjkaVMHDVmm625Xd8XQ,S0kp[#RhcqT@M&mjB@[kAYFh"462 X"+&[eDlCLkU[ARARZS$RBcr9KZdI0T)YM8fV6$$`IA$BQIh6-GAFG&`&$S2,T9G 20!@I[G8%i@0(4"I!HiG'JlR9%de`-@D[(YlCC)E6C30&TcVrCY68j('2N6ieem" ($ed9V5S"FT@ZdhlARk&(Fd(0YHlhfqm%ZiH3!0RFHJLbG`'9UiR+G[VUaGKScQK BX`CU!e5q5Q8"+P59mSRBQC!!Dp#4kceRrEljlBHM5bkIlCk%IclTEcLq3AG0Tlr 5"-ID6FGclC1c'il6il(K12V3CCAZdD129*SMGalC#X8U@'X#6CcZFZ`Dh+HK9dr [Z[354*FXI2UN'A(G3[HTkm!*GbqLI2I`"qrp81hPFpf[ATYI@Bbp5f*ZNr[8U[H rEcj3IfVhf1L5mD'jGkkD'jdKma5i[BL+h45`k1@fSZDmpREQ`'GirrZ4iH4A6M$ Kcm5U!efc6mlZR(D82*FZ9AQlai(2BiGHDBUUcFS4eqBYR"'GfrQpbP"er[dcX'Y *,#eB1122DYFHQ6Yc,ADFR'9GZ(1e#@+$X[IG-6!C&G86*(mBq3,*BdIb0@$6A-c XZj("cJ6BI1df8l#Q['S)N!$Xa[DU!1ck+VKp'-`1Rje$j[TejdSh89@)cPrd1QX RBGE%T@2ReQ)Air@keZ4"!jqB[3$pQYM[mNAJc'eJ@LShmk)Bd1jIb-KKA@J#a#+ b+K5UK+2T@e8+YMb"1EC#5eFZ(@Ucpc,,A2[1kD[DGj!!8Hd2LNB8Vbb3!*h3a4R '$+*UYc3PXU0j$$XD+XdG%R&2mieIlSFQe*!$Q2$+PGA9ZEF*$qCAVT4Y$Y9h023 0@MH#$XUUX1iGTbq+V5V'1iVh98XRBAH99j-[Qm55F81Q[h2HYe+(Z$0FbUe@YK+ '+B$DEl&@0IEFEK%20c6`ViAle)CZ5Qe+DFCcIeTGePA@PEICGI5AYjPjfd%h0,9 PTRTI,$ScZMAmTIZrG2r@m-aS,&TpUZaU,'j,d5&BCQVfEHVDe1@HdKpc0GPk-Ld c4bbk@HEB('EZrmfK[rmp4hMapEFX[['5&I0-Hr899emKR"Q+PBa33pdaSf2Cjf` D"B2TBmG+5[aY19,b-#0[*NH0'Q9-jXhR2q82(cjX61E0je`qpjLl"KkJF$CYp[( *1AmTb"@GbfmfGmYM3KT[Y['@#(F@J8JN%JlJ&iPFM$8LIcDqf)[I9Ch(hl[m@*r UQr3a3b4FB!UJQ2+Xa)-P23XN0LC@Nq)HFZQiVV+A9hNrk&9U8qp'8p-eVN[V0qR D`hZbpE+-ZHQQ@($hlXh&0pfdG-L$pmkHq,k'Km1I`0E6Q-pJB4-0beSXrj*30"T H2'*%mB3*%bjC21+5LG%*i4%hK%G-D,[XXUXQM,KkiJH[(M&QdSM,*RlJCiHHA[[ q[rTQhHZh,EhZZ81l+[ImeHB,JGG)A+'8GQ)4Y[D`#6lh$40SAQh-FjdQ$pZb3YI Y`YEXe$JA6Y&e8hJ6YIrc6Z(GLrFTc0&9mlUlZ3C)%qfb8j!!EcH&Q9-Qi2E#2U( !RQfF%c&IflcR&qAC"[p3lMIi9EPYm)rPfJ"kmSq2,ApMirAEHUp"Y[jXr0YkVr# hp4IaBIiAm8Aahp6rY[pE!G3r`iIjAm4)[%9#rDmHY,R[Rak1mC[bIfTJP5#6rd9 eV[kAc`rXmk[krp(!IhlEi$F6r'E%`8TC1+Z$%eI(acSkFAH$jG)J@bi0FQNf'06 S-#bK0kbk`k`JqhRl4QiJMrFEH6K*Bc*[1X[rQiF$05EcTL2pArRFBrfVpb`iQ`I SJq@DAj!!5@Xq5p'-ll82$A%Zc`Ebj"VNmVC"CSM"FM0'pKlJjmb&Z)rMXhR1M(P [Cq+lIm%kCJUR,9K`Jp,ieT',leMA%'eH'De[D9M@eV!LZV*a682ViRR6EMar"LZ D@rkqq)*TkpZDebjVDkaIYQE0(G(@GBe0dIVe,Dh0,EA6hlFi-Uqj,EUq&D[UD2h U&BdY%bkG2Rh1iV*Tp@L"1D+YEF[DY'CBEF1DCAG%Cq#JYAP0!bY##aYD'eSf0+a JXQ6ql8fjiY'ec8h[E)XfiU+ZFGQDaSme40ql[V(q0Ql[@"kC[kkK58'+YUjHeU* $$&ZiV,'e)ETScU`&FaCFciVbZSD@9Pc94LIJ[KIce+pHeV5Ui9)#@c*[rGT&keH ZE+a[D*drBplL)42jh,"SmB`#+IXSQj3*D9D#'keqcA#2,S0PI[rV`ED*`pHe0+a XD'PSUQq)EP!-b*Icjc5e0E3X`kA[KSESXTA)49[V@aVAYE'i&'!Yal9BY((YZZB @+3R0ApI@Z"E`-aQq#8I$+cR9#K"V4@-cLiE@0LaI[bUk$YI-$8,*dHpTD&JA[4d (`mfh4i&m8h2,fQ9VSJdIEC5*,MKEhY$5!KLpkM(cQX'j$%60'"p09S#$cD[BS"` 0PV@XUUp#X#(DX(Ep'NL$BMYUrKVLXUaTaE+@&CLS[N'DYhTY@a[E'LjIGdIEDJc 8ZRkG"c(%kGeQ1Nh,N!$UbNQ99dk8k,,SPkqX[[,+k+,kjVDfk#)JZ#D+bD,ec8f iM-"PG(0,+eE+@!KQ@jIjTpa4bD1`H%'A()qH,bVicpRccFeQ3q$Hi%T[G3JD2fe -0Hp6Kf[HrDREBridd,F['$L$-r+KLr3Xl%!`q-[cC&EbH5"`fT4I2Ah1MCQk@5h hV&[pTmY[p8qAfHZcRp$pMYHA6fDH%(V*FCrrc2d1GS`mPI@1K4M",JBCIqjch&H j,Na$2Q2k(([!AX!Zb'#A*IBba"MpLKLMAc&Mp#Z4DE"8j66S0i`a+NSCSarfB*r l(2U&'D0I4+B'AcJeqTh('2e'-%EP5-ES9miBr8B*1Z!Hd8'r#aLMhfM'k(FKBh3 B`aMp,K)8M4P,&2('$Sj'f83CSprE'+2IaBc4kHhfA2m5SShF1aLMhhM'k2G1aZJ hJ6(kA5UX-'BL@B&-KEAIPc&#[mXCSemPBr5VXZDmQYASG`9M9&c*'2f!TI"M%Q2 dQba6'(-9Td#rGc0'[kXCSr)DaZJhK6(k6C9TMEQ@dk,IGBc4lhFBSem0Bh5)-8D rDB)+[MK!90"["Q1mDaQMhr@-d@mQBh5DC9F,XiNHqXeKM*UjM0([2Bc4l`E'k(H MS)cV(k+-c(c'k,H!-IUpPc(k,@5-ISZ%$FBXYSH)0c&'44eMp,ZC%IVG`KMphLG 9aVbI9HMh!FESpd('U2`3Br6l-'2d@b,GM9R+lZLhM$(k,@H-I[@-d@%&Br4VN!! T33&1LAkV'+2IDXCi0c*'[eXCSp0YJJDq(d%dd'mYBr4VBScDCXESYiiaqRe%8-2 h'iJD-Uf-dDq0-IV"[3Nr0M"'[pX&A@-q5R64l`l'U2JBBr6lAFESYj%aqReF@'$ -lj%&k,H*-ITYCSc+,AC*m!P'k(HRA4PmNPRdZiXaqQePM(lE'+2$hBc4lalTDXb pl)TqRf+-IYXCSpppM2(qIFESp'QlN!#iRp1Kham`4Vr2-%Dr2f5-(Rr%'2hqPD" Jc"m6"@6q0@2dqbaMp"2fSYrR'D2I&`3YBhB3,I6Eb4J9Af5-IPpLM(kl'+2I!i+ U-3m59I4lL$(k2F`BPAr#'2dHBBaqM`VkaV36II6l-Q2dfmdBrEl#'"dkl*jSMd6 'I*84q[dTBr6l'Q2dqcH-d@q[h69pAESCm`eeD1EI-NDr2f1-IRr1'2fqb4LpX,J 4IRb,8b(cE9d$QVpNM(jra4Mp[X-BrEiVdq2JJp1Mhhl'U2KVaZMh2FESKmpS#$r qRD"Nc'0%#IdHCiaq"aLMm[Yf8BA$@1(([aFd-5644,rr`"MpIXJBr3ib4JHF5JJ cRY30QhP+h,*VrPBAQ3Bq@LErN5kbc#('k26-bC2#Mm-[[#$aXpm'iRLHfleEiKr ElHEcYq0Q#mm,XS`cjNLY(-'E&lN2a00Ppj-['5-6(88V6S4c"*RS1$r8JIJRh(` LIYNBQHLRh0`KIJ8h5CcS9Gd`QK1kZ65[F5K-p(Fbr![QCi`aND#'L9lA9D[KC3B RqVRZ![8i("0K5b!6GH[Le-42bQ-5,mKMHViYMqRG,BrTXr[BrY[P-8QlJ%c9bQ2 5&mYM(([VN!$l')fh-p"ik"CAIf,ihkSa'rff'ccKR(#Fr&I66kCIG4aQ$qFka,l [9MY2GMckb+j(0Zl#Em0'[-lp@,KVib-222,)SqNpVcSR6Z3QQ2Y$GiUcja&TrIQ 0(pq`mB%0L$qrFGH'A9ldFBPPPSpV+0'Z4rBmfIekETEj6lSecU0SUb0SRedFkJX EmGV`F43JLB$c2'$(N!"jG+k0MkCrRKYSmGqkYAX!MEaN$2C$,b)L8f)@QC9&1Jc V#G2'$3mSJ"Yfl6R4RC[UjKqjXp)bK!bQda%H38i,"(QKK1)%9K!pP$h!(NKc`Pe liVR"2[#-qaj(J0'qJT!!8!!K@-3+K+cl!QK&`!45)F[RJDmJ#Djq3B"dHR,$IIL `1qp49"-1)S8AjP%5%5S,f5i-51VCU6N)KeH+F8#-+98EAqRUbmhhLHIFpcVCRM+ Uj48`ak#%$20CI)9l1Z3$c#Z2d9,T,$!mfYf2mAlQ$NYZkFrmdV[G!PY3B#[Z9Ja DIq`ZFT6(5Q,340'P#-Q!`K*P[R"5N!!L$Z3@J3&'M$cjSGjd*38&da-h*V8EFG) NM8RDSHpmhPhXS+2-jXh,hK!YUc3DF4cL5f3&ASZbD)+9-%SSe&DN`TXkE8`FLff q1Ae[fJ6LG*j5B%(if![ZcBjb1F-LM%85+,G)30%,+aN!he,#&e(K!P92@N)Pd(, MSbI5!qMRi90VQ$a,J+e(h&[!I),TN8hQPc%jV8LHTlKDSkd!KLJ+4F&62''0m), j4h4i1c6I`6K4#26%8hlDiR$(%AHq&AZfYPUZX"*c+mdk@ikSr'@iDbQM6()f&UJ jH0,jC`hie)[ZKb#ZBP#%!#+D`Qp9IC9"1akUVI!mS""Ce44hT&TX&GF+p3RA$Zq B)Xb[VepV`FBZpm0TD`SmTb0#U1TQa8$ji2XAcdk*I40PX'ci[,J*#)&&$#D,EZM A9,JEdk[e%4A`r*!!acR1jVN3VE5Fm34$E*)k"ffPJ381f9HcXYJ[#"33#EbX(RK )E(V*cGm6L8`ZF5*jNdXLNp-4`e3*5Ta))5SL*C-M*HP)!605G&j9""95*b&INj1 4BXdc3"1$33*,-V+80N0Nr!`92#*qiUKEfL8$65k1P)54U()LjC0e'[l#94%FI5- URKaf)X99,%+E-,%BMlD65X+6)hL9K%XLP@`&%+4&"2-!M1*)a"QJBQm5GrQ#KNH #ZikkBrDJGDA!%#QC&!QAB#a'Q#a-4))PNm'#%8!(C5J%2)5%%k2kE3)94KEi!3L E-0CfL1j8#+M("*pm9)h+UJ*JH0F53K!QU%#q1"5)"d0+-D39$`b-HTD3!!T!SL3 9H6ZEHQ!"&@"1*K%60LF#%G$D62B8NScdc*,BK!`M!f12ZHrS`L"NJr#K1#jX#FK mB+EL!AM6450#PiH+j#frbj!!+bS+*BZ'K)T3G,P6&,U-p@b4,R,$qF*aFq)eGlL 6([#V@j)TjbIZ8+I$UdTf1-IG%UGMLf5C6VYRA1YP+hT-+'lk**Gmh4fH6Jd1Xb@ 92Zk@T9-$448ChU0Paf!a"hcG(603-r"$rI0+dqmFFi0l5+G#N!!%J"C$BF0"Mk* 3+mK4T,MA&-3a+Rjp`[)m6Tibjl%Nq`U'3@@cjh8h[k1Z*a&rh34lqY-Rh'"(A5) "RDj,RA$cR,iH*22M[8NA&$$a"2ULGqpT0qMdpj`d`BSqYNch9Ib-6SH+JP*-m$U QlYhLR(CrJP&kNdF8[kmGFiFZ!BmK$8,`N!#Da,)L`%P4JC+`F",,X"112fq#SC* `i-kZdfkJS`GECM3,a2[rcMApTdbJccP*'1*)pD5*MS@DqS,[!I5F3G-%"DHEP"A -qjbIZB&N((YU61,j-[6TLjm`JBVN'4FEES`4k%ehU5+pJiL"AG4jDU1ih,3*aiX J3j4G&LMeKJq@aePH`X*!!J"KH"5aKSML`T!Y6*`&6aM#L04T81+IF3"T'*9X )d#Xk8,BPd8pUHq)G$faaiLlF$I2DKa2faYemKc"!G'bc"'C1Rd)CD!%!KJlSJ0V $1"R@pdBT5"B($iB2P'@'5VfNa$jic"dQ(,E%T[5%39eUY6U'NNK4[%FCJ'X!mT1 mI4G@GkE$fk#3!2,+)m!&bLH&5DG*HFQHJK*apU$TG4*@MGJ2-YH6)'ZSGjbX"bd ld$+9)%e`M@#TN8+AT'+!eZ))EKJ`e6Ge4"G3&lfQTB$`SUb@&591)&N8dHj! !AbS#2&BamF%4JlIr%E`)J*HMA1%UNHab+P5'#A1JaqN'hhc"!*!!545NIIAST6M e%@-V)%TFdiZ5AQr)T#Rff9L"JI,6*!60RBiZ4*+fb4j[@[EeUX'0rJ4[-U@#Ec# PrjJ+Aq#i1mb*P+K8dem8pCVcLQNYaALUbrA8eK1rN`U@fQkaqbC"T[Cjr-LT*+$ B)V`JDZNXP[%dD+TPDPS"4cc9irUcpA'0d02MQU3h#iQ2IRe3%dLRcXMH#A'Ak06 $D9J*mXA625j6(ZTT4FfaFKR[1+i8H1paeqaa)X0%`X$h5#81Ap%YRliB+K!5[`` G#2I!%*K!Z0Mqm#VbiFCBK9VNK2-3&[@4fh@!8d(+li("kdLR63I3l,"+"0e)J!8 &a&AK6#DGB,)1"L$3Lm@akI0"-hfTG'&(UVrAkq[dZNik$a1*j8r,bX9*"e(3#iP *p&&%1p)GI5ba3J@e6,M"ISqI`Ijd4`TJT02HUN#Rk(A0&Z8e4ZK0+f"TdjrfM!B i#F%4HXCe!-bB`J`95LRM`K[[#808e#Y`Za*)`ef%i+*Skl&I%'QT#L[cKq&,#9$ Kb@!ChV"%T5%NN8-c"#'3!"q3!!N`m#S9(6ph6ET2A4#J5%&M![&N(p5Nep*a#cM J*1J2qR[G["48JD*r@PG&8+68D3JXHd&9i(bS,FK@L(kL"3A,lL`i9TSem"#N$Z@ XemdZJq0aTAL5G1MrLI@6aphbHlM#JD'%XH!HCA*4+&486%%aiA5N5&FQN@*2QdS ,CCA#M4,#)LZX*&a![%J"qX'+&!NT3F)m[(Y!L$kKp5PiH0((3"bki#fV54#3!#E Jp)%eFDI205N9!KEfpm$"amd3YCl`e@+VV&&1LiKLLM639BX)G+%Xh(9DR3"*U-* @%e+B$Q1UI8&P[lmB"NVLA-&CP@*!mE,5j'h(hF)Pa"Z`Tb1MDECSY1NQ34PX2LJ 8Y0pK&9'BK`"A)U$#*#H5Ai*[NJT45"q+"4U+JP-`#&iIPf$LUekhdp-R!+QdpC0 `62P1KG8-e%&HK*!!-%b"IS-%GJ@kc8)c%-0XmF8@XU'Q4&X(($q*3QJ,@PMpJ'[ cl3HSN@HG6++A8ZPl52)MYcC6LP"+5l&,%A'%ER,K*TD%QJTLp)%&9-`%0C3mUU5 ,UiaFRX"QJFSKTK`leb+GLdk93#F`FYrVUKV@+2B6If*$'`,m1eMB3eTEqe9(m`e A3aclb!YGGh$hMkc)+@#&BYKqIHMY@6FV&ZT68rdZk+-ki8d-V[DUeb%4%0GCM6L 2'N&NS4&1j%,5RkY6N3JXLXN*T,R#PH@-[YfiLeeN+Q)J%`%eDIeQQ*L+5A"jP9M e&YR"!6Y!`QV1T#hjkrVFB*he0l$d`T@%eLM&eCZaV1j-aP*N*95d`IT1-UJIH+U RN!"0JGSXN!#J`dU5#+BR5AhLe6dTm'66,Rdb`RNq93'rFLjHZ"#-618HSca#Gc& j&%T'63)pHX9a&iUe$@1K(C`8+HZhLl*#e#3Mq@K'`FbR8YDGa&BcL@eNjQACBbS !T#`PG)q8Kdm##YE!XBl-J@IYS82S6p@CIT,"%Jc%##E%dB-@GNR*%*3bGCUbXQR [5k`UNL$*V#*D!jC8!`4aX9Ki"Xbq+ka`BVNlXQ(UU#QMTTC2(5@a"2Jj8dZR6*f #*(mS#N[V*$`%Ur%V4kZTCG$8NB9X1TAG8%5h)5rV*G2BQVj#+K0*N6p2Zd&DV*Q &'V)6XE+4k1MeYq-+2%8MSF!a$Hd8E*9FrCl,`Md"18Fqp,$'peHSJPK"9,&)mR3 cB58LH0`G[k3FF!*q`PbZB#0GMT`#VGAPUMcR"8B*)`!e)Y`[+eAcii@S5)h+CrY hp3RiB25@Z($2@kkS&4!Qm!SLq6+iV6iRN!#!SZ8Z%`LjE5dkQGR+!AL9%T5+BI" D%[Q-8SKY8,-!p,1Z38dL#@Nj-1UiqdjSJ!L!*Em!l0'F3+XkK-'f#1FS4"&T`PC 6be62Ki@81N+MUC!!6i0T-@DGE&-pq,-l*M8-HGk+81pKARD$IIj'$TeJa2U&'-* Td#+2bbXK2A5!R%C'+*(a8%(B@DaKl*&"[j[(86#QCbZYR3-Y,+XmCfQef"2(IK% (FS"36`B6K"QL$UUA"$b#c#5&YK3L`&BUQQ@HSk)d@#k0+Lp6#G6p*EBb*!rI1!2 2kN,!U@149HZqC1kZ$'L*2&LdC#HG@@55#XVfXk*Sf5Hbk'X!1+#k)1B!(2&%BH4 aYkL"b![aR9&M!&j%!*r-!2L$$k)8q#`N'SF63"`0Y!P03**l3"1DLLVK$cU,+&! DX8*+daPkLLLa@%RI*HJE'kZIF$8M$+@P6)XBC*B`DJ``PkL"@T3h*9)A$Gk0!pR J-`"m#24cA`SUX)&RNJBYTC@%`""I%UD@!fPS(HJ[C#$PP5$U$kCB946bUiM#,bJ "i*K86+D'Fij*jC+V+0R3R[BPAi6"-jA(9-3mjN!LHp+1G8bHQJNaFMDEmj!!&0B qqb)Th83Hl+DKhpp9J3rq'ZHFE5V'[X9A"l91cUJ4JVi[NC)XPjd8MMN55$ZMa$Y 'i"TemP+k*Q'"Y)@4S#abAjIfVe65(6eBi&TaXRC"l`#54q&`F&Z6k($pZc,V$ka -!RU9%Eek%`Edfk494hr,!(fd`)-'3f#Kl6T(&`hNNVGlXKl@F`PpN!#%"M%p9K' &fKlRe51-QN4#61VRh4&A#P2$--cD!$B"DbPi*r'8SJV`Mc)PADYX96QkBiXmNi" V%ja[j&DXK$,PRaZUb4)K%1XNqSKMV3'Ra&CkUL5LD*%@kbK0e6Gl8N-#5*NR!rR (h8ZAL+e4Mf693(p%G*,L2l@mA!JF$+J)d#G!pj5P[1e4hi6D2(4&R@j[k45i&p) qRJaC(45MB*dN'L@lD+C`442IBUf5h6E*dC1Pb4XH`ESNIqFQ3Q"0LP9$cbSQl2) J-l,(JP))`$fq(iB[%"p-Ai")#5##!*fcLra#hdDLeRU#B5%k#Sp&+S9%ArD(+AK Tf8+UmP%(#E(Bi15,hQQAGl3KTU#2blVXGNNfF33HSQLP4#J`Z&Y5-ji4aCa0%YY KE@CZTH"C4dm3XB`GVrX%JNa2T-j3VB'#6N*-,SG(NQ-UA5IB0C5D!l*%Hp*h4qJ 0G2A'YDa(BI,4-P6N`1++#lYJ4b,qFlQ01Q@YNf!U20!98QCKlaXMbEka8P&(!)q SA!M%a5MkPfc%`*0Q+`M$MlXAh`1%kC3K"VQY!4%&beP,YD0h9R8,U`5b%RXR@X) q8`jlj"''YL#h@F1Q&FL*!)Jld&9$)$jJ&9q!Ij!!YETrCX$cS3TmH#+hV[FG%lf !5+5hVmT)T"akj&D1JiXQVQi"L9@ER%alaYP3*08P@*QdLU$Z@"deibP6F0ML,4- %q6,Ja&@#k)(Z(UD@jr4!0SGT5'333@E(*Q,'BirNml68HLP'Cj(!1GcJ$N'e,,1 8rq8#i8fIC2G*'F[XEGNbfmZX+Sbpaeq[kCC-k5"V!fiER9(jT)"iCYj9dKK0YXh `,[1pal!3V)A@d8(`F-Ac#lkrS5TNaG(k#*2m-5q!&(cTLR1b`Idm[)5h4mLGErK ,0NA"qQG[EdGD+)[%2AV(%,j8fTRX(X'c#R'X(UdUC,fM5+6[KmN5VKi6P!9G+iL G%Ym-6@$$5HJ@*Mr8[H5)D*(`pdT+bZ5cFUQJUUN3i86fl,('2cJ&cc0kc[Q0jCV RR$h(Q*&+6`V+)BClS'fHhb[R2N"h"(51p!LbD!2$-qCHX+BGB+fDjhlCS2Jk)+V Sf6-lXpem@2XSIN(8`$1'Kl&XdDDm`MKlab0'bb)!+5$)5GNJC0FJCmh"'mXeAL1 Srr*f+CQMMA21N3[AHkb2KJf52B%eKVT5m9C,8h&P@)CpS(qfJ*C3`A+9BH`FV8X J833,@Dhe8Kj""V[,`Yd6(B,e5Xpi&XmHDq)NDr$F5f`5'YTeI-BR#0mcKaQH*(J ,0G9MXBeB-YKYMYK@Q6+l8KYrC%VXSLQa+E%ada#0LFA+qCSfC9TjE#TqBkE'TN` E-fA-&,9@TB@aF[4KEAPXbNMUBk&06d16+I640#frm!@@rIl#q4$ZUANI+-!+lYi A+l`l"-&+c1%j1q!G%p)2C"DQ[NqbE"GAN!"4TdbM!C'fA"Kkh"hbp9KXCQaQ1KC %j-6+CmCLXk4#IPi3bbahKK9+d630V[(PdHZ%H1Be'3!b1',E"p[SqaSa#ZTNH+' b*9PKR&IGB$*ZkD3b+9cACBcR&1`HALhj1ImiX'$bIG,CD`62-&L821Ym`A%hq(@ 5)(TB)%Bd$B(&94)fHdd#UTK3&LK&4ZUh$SK[KGH-dFLF9FSD()U$e8BHeeKYp+K YVaFlF!qB8XE3"29kaXNZRQAMB)PT0fkq!H)%DU$XcD(RUA%Kkqh9[)8#MTI89(K EV6H[&,#!I,ZMP-LL,U((&3(FL5Nq``TRDK-T[dCPCeMK,)pA+-dZAdf&+UfhcK! "%!Lp!m$-hCZX#R%3#$#Yf5%V[+-9R(F+[0iZ(PGL`[hFm9,QhXhI1'8Z)R'0Q$X 2(6a+'([F$EhSbB%6bf2'L3e4p*hB--X(P[ZU9HM%LQI',YDZ)f&,KP!Uf5kN`T& C%1JDdR-meNPDTd6NpGb+f2-`&-f#S!almfBIahXB3Yb$R,YPY3'mb)S-P-)cdCJ T+`G@2le,K6cSNr*'G-,cX@rFmQ3h,qI$@R9PTH%CAajbJX&IM4)qR0!dc35mK!p h!6c'Y0K-FJcpi5EX*XjDA'p(EJ@83&N"PAXA[DV&#D`F)q-f%JIZeRKhN!!DfG- [TC4h#DK(dcL,`1G(cNNRUCR49qZh[80f($0R$lrH[&hJPFq`,Y%'FN,j$m-&J-X ZCK93(Q-GJjcedf1T6-+GMX5DJ"pA+Ac@YN9NV,(`Gh0T2G$b9T6@-1+U9brID+e am0*4)HU(dh*VSJ-9[0#'PXSJ&GQEG0aGNd(#1(%AhJffRSeifQK&e9YC8U""(R' KM$'c[jCimklK)YcBN!!2)SBd$'#(%bZB13EXCfDSai+4PRHd"Z"''Me%0+la&[e @@84Yl++I&clUVqNXZ,*8FF(Z9Zla-dFj3@H,E!kJ&EKYi%)P!4mJac"b3,BPYmC %6YBb+JD3!!lVph(l(h35pJ-6hJFKp%-!DHm!b,qXPD80MZ'p0HJr($cN(h-0@$# @U$i6N!1KSJU,RE(Q%"&XFJ[9DURFdQF8aUf)L&'C1G)rKb-Yq(Q%H$GAE!P)JBS qP6jCCk!3ZX[U"bKB@3862A@T2RAC9$lXY'8GS3eaAFZ['r!l"GebN@brH1*pA`8 pHZ*eG[00KFB(%-JN,L(LGGiHN!$IF2MeQB0cc"h4-1JKi!d%GcJZ%8qY&92Kb$S A(YbR4daf&!Trd49L5mH)p"TV,-$kAQp*JSr(qYS*&1ZB"dBpq0#*A1EVef,XJMq 1"ArH&P6JTK!)ULcUChl8fRD,5hK&RCN`3j5(S,10l)4lr,YlA,2riXTKr$&hb+d @m)[mhB)!l&0#U(m0('mC5@p9!2*)SXPk@r+#rH(B#+XdZUX$q[U9'!A0[hi9SC4 [SM#V9c$i2JLrEd3LC6kF!SP!,Ei8*#IkhVV(IM6'-dcb2X0[d(K,,qrBbEH6ZDr '+"f`H4cmqJpQfj)B-1LCb`H6`(HmEP9fH#caY`h-`NQT)Sc*k!'-TMA[qG4)Up0 &N!"1DcaBb(Z3!*3S43+IZk%+i"2'#5`1dRB[6`i!a&l)35#C`2H'j4XiI5GPJH% GX1"M3IN1EEJp0e33F&1)6`EaNaMkb5"D6A`9*C@)[mc2KAKhG4Rrp1Dp3mNa0pa JXIA3ImD*46b,`+@,'-mDfH@+&&L0Q(C0h!bl,1HG4USN!Pa([Uf4qiD1pidEDY- C0jqV)hX4!MMLr5FY[TP@B#U1a+!JhEU)`pH'NK!rImh%Vh6KHJTI6*+[KhPIRF( EIKc+X8iMmqEG1VjDp-E"T'Hdbb!#[jrG`rL8X%l"pde6I,Y9bN@HlI%-V&25IRK +L+,Gam5ZNG-2GG[mL&B&[q@8f9Aa8f&fLFA$PiU1eiLUYhRU6Eq'G3VX8IDc4Ia 3@&S1*93-f,BRpAGZb*&[kPM*PGX(Dah3-#@hCCP2#!AMI46Nc,Elc41)`$&hj$h Rk'$G4*Q!6UYY9eDJ)6F[iSjS'X8jpFNfcqU+Ymh6PHE2h@%G@r!G0eqJ#3Z`51! ,-+Qk#R$GM3FVkP+[Z%-FjJ%CcNd6LGiYDGGp&FDcYi*E1RM+[(4rMi#Vp-L2*hV k1Pl4VaVSGjDX26J0P8PL"2iq9U-!hhhkH[BNkCb$lMVQMplj"!+X&P2K#1QX BE0diH(AHNJCD+N6aAl+`#-UUJ[BFqlrB6(kf$[mGji3EeRpmP(fPYI3eEQE`2J) ZX"!lSjHedMCi"GchH[jBQJ@46b16BNZG`IE@fK1S68Z'Jh9lA9*)kTGr62-aGpN 4ApX)Rpe%HTl!ihpQ2BHA1JDkj'b40GidN!!$"(TKi022'$L2+d!R"PZKr5NGBcK 5aRIiFXbXZ&+PdRM2ZQQCjfCb1Rji"6k5Paei!Gaa9heMIAeMUYidTZZ$MI8VHT& *i0eARir#&Be1[ER9U4qb3YVd5a[$)RNRkmd4IXI9RfMY8IG$fjBhEPeH[lf4BH0 @"+ZhVNE-h(+N0E&p1A1VYkf@#M65EUY4j6IaQfPRKPrT'KMhYU2ZT2ZfEUhRP0[ NYE9afpCk5D%'KCTB[QdEJq95X(cEkQh1eK+@SjG81eY,J3%UYpSj'TfY`FDYfe* RKre!DRApIH`Mm'!BGYFA"V3*"D-48klHY[`qj&CcDJ+p@QS*Dq-f-+!H%9iJ#l$ BeM8`l+e(h4[f#KTS5QMZ`dmJj86SUBR96(VJFd5CR!di+%&CALp6!0$Ll@a)2Qb 6$c$lXcBHGGqA&[L)ahE,!!klR0R9##40VK"&55U2PQrG$Pb)$SHq6cQ,*TKDHM@ Qj)[6rULVMVT$ZS@PcYCjj,[`3JEB@SpC"6(PVVb8Vf44C[j'c%`'k*Y)55@qb*) GY1%PpkCYd!))La"'IM)i3#1JbP-1+#*K*r0qbKS#LPkN(TPf(`$EeMA`%I$kPpa &Q)qFdFEN#c9)1D'%*pR"9Sc1'D4@aBV9J%lS,,JVY6$N`'I-PlhN[VI$*jdG$6r -+X`8#C!!dC34,%@9k)9SNP"CQ#fFj3KlX8[+MVMN*AHqSlh43$K0EM*0P84[jBF 8)@!(jB``53K)bY5cI2R@qc"Iem#AicrdNRYM&l@-pG[VC6!20+5fZ9eIllKEYF[ bNL3L04'$`l3)LJ"QfrZLihB0I1Pq3CGlSA*0qUT'#MjJ52hf%dRhE8jklhhEYYf R1Xe*#4d)MB&qrbYl1`$BblQ*VX9%Hp&Eq-D@`Pc4)f)'dq`iH5mkCp!hhqh'Aje k)GHp#YdG0"+++QqYQ!M!DDbhZTr+0CciSMYD0F4+L6*E4A(VIGmjefZ[*D[)P*S E`J+Gf6E3kp)MlJ@F'Lm`9(62LMVK(acVL$XkrG$M1r(Llm"$Mq2h4D3HHJLjVh` hek[LL(YKpi'GqhIZIfJrHL(QcdqNp`ddH`&N3$(+(d5h(FMXIfM(rXIaBS@cIk$ Cmqk&"hIXhlQ2Jb(H[h2Rr[d2B8iGe[RVA,2,IZb11BKTd)2&$qjr8-(B`Gc1acZ lrbEAlGdrGXFk"hB5!635S(CSamGCY22aJbGHa4kJmq$"!rJNF,CacA0ZY"Y6FMD #pb"4C`S)#N+2(qcX2Zf@G(Gf(RaBDcX("TMlV(Z*ma"K`FJl'3%$`Vm68j-&1`$ IJB-(rm3jD"jf$T4+"e54(i"aB*BjclLA(F!X"%+4Ba-!!339643VZX+C"jfG4H` %3Z`re2@6h#Lc$VQ46SXU4RJ34%2+fANaq1R4q5%P,XFPDYTYr`jmNfY`N!"hGjm 4"V*DTLHq1dK-dS4CJ%*!TB,NIe$!'`6hqUIGbD#S-!8-HHK"-"40b1cpa!-M!K# Lm0M1adKl)4Zk$1)aibRhA3H**8IN[%44Q%UB&3'`K0f*!mD9QN%XTMf*TG*"`JD qTrHr!i#)G!Ja`#C-VD3JS5A'Ea#(QS0Z94H"8)D44b5P#!GK93P#"3(Rkr(1%mG b!dcpJ9Z"rJ4"KNC+**5p4F8)Ua@p!`Hl6Kc00ErU#AFm"-Q59ZHM+&"DPGq#`1- (RDiA"mc,iqiiU+#i!1QX#X%%ZqmiF1!dPQ`jjr!pGc4Pmf(S%T88)[!BE-2$"cZ GlLlha(1jAQ1r"qZ6G)ZlcaabcMKRZTeZjmPFIIKEVMRarEH#r+mIH*PUqdp8[2m SCrq%&Kl[hbajIba8rSm5f`lq9H2m3dGfMH&rA4S6h'Y',*JcBc'qq,CErJ"Z`$J cEPUib"3%bV&DE6*jJ9YRhI6Rq+qjJFm[`*q83[`)rfB9MK$f0YDhAShm%iJR)6k %'(q,+h"8rajAi'4Mr4V@pb0'IE"JqVaDr%HZi($p3eh"XIU(ZS)6&pq)rmi9#&k eD-llVcI"),V-armN$EE8cPR-IVqhSVjY1H*lm5qKr,q('[L4r*XSr*mD6Ql`Af) #pd[Z'B26RX#,NMYXTU-[rlf8#6aVm*qJJVmVZ60Na%Ai"lIkck1L*UKc2'1@iF[ F1&k3!,UESfE)(pYC2iMF%Cf9[3['DcrQbV6hBGI00f(jDk+"Cb9hLq5HNacrE$& QNYlbRa-$2f+ZF%9QTY$l-c-9mFmmQJRb(hA#q-mdH2$GBq4`)F%QVKXeBlk@IqL &MI`(8XLpKY`R[rXAl"%4MQKZZ1"KFhq8cH%I%AUjmjl2j%EKlr$KfFcF"IJ[0r, JIe519U@4ZY(m#mc!aQa%ES4LBh!`Gq%Rp,rS#,IXRd@@h!8kKZ6HDEQ&2k&md@5 EfilFHfhZ-e!Sq3Fk*BY[Eli"IlLCQP45clpfLcqleG*mHe%crSqBC-,C+[bCXFB 9TAkTT)XPeF,rLPXN+I`2VkEmfIK[@rNc'e[E5UDc88-,"bc&Ib,$(`4VD@KYAYp 5hj!!6m(,TpS0QEDmHIe!p2m"!#)@!!!: \ No newline at end of file diff --git a/MacstodonConstants.py b/MacstodonConstants.py index 8191c26..4e7ba18 100755 --- a/MacstodonConstants.py +++ b/MacstodonConstants.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ######### # Constants # ######### DEBUG = 0 INITIAL_TOOTS = 5 VERSION = "0.4.1" \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ######### # Constants # ######### DEBUG = 0 INITIAL_TOOTS = 5 VERSION = "0.4.2" \ No newline at end of file diff --git a/MacstodonHelpers.py b/MacstodonHelpers.py index 068f9b2..c157bf9 100755 --- a/MacstodonHelpers.py +++ b/MacstodonHelpers.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import EasyDialogs import Qd import string import time import urllib import W from Wlists import List # ################### # Third-Party Imports # ################### from third_party import json # ########## # My Imports # ########## from MacstodonConstants import DEBUG, VERSION # ######### # Functions # ######### def cleanUpUnicode(content): """ Do the best we can to manually clean up unicode stuff """ content = string.replace(content, "‚Ķ", "...") content = string.replace(content, "‚Äô", "'") content = string.replace(content, "‚ħ", ".") content = string.replace(content, "‚Äî", "-") content = string.replace(content, "‚Äú", '"') content = string.replace(content, "‚Äù", '"') content = string.replace(content, """, '"') content = string.replace(content, "√©", "é") content = string.replace(content, "√∂", "ö") content = string.replace(content, "'", "'") content = string.replace(content, "&", "&") content = string.replace(content, ">", ">") content = string.replace(content, "<", "<") return content def dprint(text): """ Prints a string to stdout if and only if DEBUG is true """ if DEBUG: print text def okDialog(text, size=None): """ Draws a modal dialog box with the given text and an OK button to dismiss the dialog. """ if not size: size = (360, 120) window = W.ModalDialog(size, "Macstodon %s - Message" % VERSION) window.label = W.TextBox((10, 10, -10, -40), text) window.ok_btn = W.Button((-80, -30, -10, -10), "OK", window.close) window.setdefaultbutton(window.ok_btn) window.open() def okCancelDialog(text, size=None): """ Draws a modal dialog box with the given text and OK/Cancel buttons. The OK button will close the dialog. The Cancel button will raise an Exception, which the caller is expected to catch. """ if not size: size = (360, 120) global dialogWindow dialogWindow = W.ModalDialog(size, "Macstodon %s - Message" % VERSION) def dialogExceptionCallback(): dialogWindow.close() raise KeyboardInterrupt dialogWindow.label = W.TextBox((10, 10, -10, -40), text) dialogWindow.cancel_btn = W.Button((-160, -30, -90, -10), "Cancel", dialogExceptionCallback) dialogWindow.ok_btn = W.Button((-80, -30, -10, -10), "OK", dialogWindow.close) dialogWindow.setdefaultbutton(dialogWindow.ok_btn) dialogWindow.open() def handleRequest(app, path, data = None, use_token = 0): """ HTTP request wrapper """ try: pb = EasyDialogs.ProgressBar(maxval=3) if data == {}: data = "" elif data: data = urllib.urlencode(data) prefs = app.getprefs() url = "%s%s" % (prefs.server, path) dprint(url) dprint(data) dprint("connecting") pb.label("Connecting...") pb.inc() try: if use_token: urlopener = TokenURLopener(prefs.token) handle = urlopener.open(url, data) else: handle = urllib.urlopen(url, data) except IOError: del pb errmsg = "Unable to open a connection to: %s.\rPlease check that your SSL proxy is working properly and that the URL starts with 'http'." okDialog(errmsg % url) return None except TypeError: del pb errmsg = "The provided URL is malformed: %s.\rPlease check that you have typed the URL correctly." okDialog(errmsg % url) return None dprint("reading http headers") dprint(handle.info()) dprint("reading http body") pb.label("Fetching data...") pb.inc() try: data = handle.read() except IOError: del pb errmsg = "The connection was closed by the remote server while Macstodon was reading data.\rPlease check that your SSL proxy is working properly." okDialog(errmsg) return None handle.close() pb.label("Parsing data...") pb.inc() dprint("parsing response json") try: decoded = json.parse(data) dprint(decoded) pb.label("Done.") pb.inc() time.sleep(0.3) del pb return decoded except: del pb dprint("ACK! JSON Parsing failure :(") dprint("This is what came back from the server:") dprint(data) okDialog("Error parsing JSON response from the server.") return None except KeyboardInterrupt: # the user pressed cancel in the progress bar window return None # ####### # Classes # ####### class ImageWidget(W.Widget): """ A widget that displays an image. The image should be passed in as a PixMapWrapper. """ def __init__(self, possize, pixmap=None): W.Widget.__init__(self, possize) # Set initial image self._imgloaded = 0 self._pixmap = None if pixmap: self.setImage(pixmap) def close(self): """ Destroys the widget and frees up its memory """ W.Widget.close(self) del self._imgloaded del self._pixmap def setImage(self, pixmap): """ Loads a new image into the widget. The image will be automatically scaled to the size of the widget. """ self._pixmap = pixmap self._imgloaded = 1 if self._parentwindow: self.draw() def clearImage(self): """ Unloads the image from the widget without destroying the widget. Use this to make the widget draw an empty square. """ self._imgloaded = 0 Qd.EraseRect(self._bounds) if self._parentwindow: self.draw() self._pixmap = None def draw(self, visRgn = None): """ Draw the image within the widget if it is loaded """ if self._visible: if self._imgloaded: self._pixmap.blit( x1=self._bounds[0], y1=self._bounds[1], x2=self._bounds[2], y2=self._bounds[3], port=self._parentwindow.wid.GetWindowPort() ) class TitledEditText(W.Group): """ A text edit field with a title and optional scrollbars attached to it. Shamelessly stolen from MacPython's PyEdit. Modified to also allow setting the title, and add scrollbars. """ def __init__(self, possize, title, text="", readonly=0, vscroll=0, hscroll=0): W.Group.__init__(self, possize) self.title = W.TextBox((0, 0, 0, 16), title) if vscroll and hscroll: editor = W.EditText((0, 16, -15, -15), text, readonly=readonly) self._barx = W.Scrollbar((0, -16, -15, 16), editor.hscroll, max=32767) self._bary = W.Scrollbar((-16, 16,0, -15), editor.vscroll, max=32767) elif vscroll: editor = W.EditText((0, 16, -15, 0), text, readonly=readonly) self._bary = W.Scrollbar((-16, 16, 0, 0), editor.vscroll, max=32767) elif hscroll: editor = W.EditText((0, 16, 0, -15), text, readonly=readonly) self._barx = W.Scrollbar((0, -16, 0, 16), editor.hscroll, max=32767) else: editor = W.EditText((0, 16, 0, 0), text, readonly=readonly) self.edit = editor def setTitle(self, value): self.title.set(value) def set(self, value): self.edit.set(value) def get(self): return self.edit.get() class TokenURLopener(urllib.FancyURLopener): """ Extends urllib.FancyURLopener to add the Authorization header with a bearer token. """ def __init__(self, token, *args): apply(urllib.FancyURLopener.__init__, (self,) + args) self.addheaders.append(("Authorization", "Bearer %s" % token)) class TwoLineListWithFlags(List): """ Modification of MacPython's TwoLineList to support flags. """ LDEF_ID = 468 def createlist(self): import List self._calcbounds() self.SetPort() rect = self._bounds rect = rect[0]+1, rect[1]+1, rect[2]-16, rect[3]-1 self._list = List.LNew(rect, (0, 0, 1, 0), (0, 28), self.LDEF_ID, self._parentwindow.wid, 0, 1, 0, 1) self._list.selFlags = self._flags self.set(self.items) class TimelineList(W.Group): """ A TwoLineListWithFlags that also has a title attached to it. Based on TitledEditText. """ def __init__(self, possize, title, items = None, btnCallback = None, callback = None, flags = 0, cols = 1, typingcasesens=0): W.Group.__init__(self, possize) self.title = W.TextBox((0, 2, 0, 16), title) self.btn = W.Button((-50, 0, 0, 16), "Refresh", btnCallback) self.list = TwoLineListWithFlags((0, 24, 0, 0), items, callback, flags, cols, typingcasesens) def setTitle(self, value): self.title.set(value) def set(self, items): self.list.set(items) def get(self): return self.list.items def getselection(self): return self.list.getselection() def setselection(self, selection): return self.list.setselection(selection) \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import EasyDialogs import Qd import string import time import urllib import W from Wlists import List # ########## # My Imports # ########## from MacstodonConstants import DEBUG, VERSION # ######### # Functions # ######### def cleanUpUnicode(content): """ Do the best we can to manually clean up unicode stuff """ content = string.replace(content, "\\u003e", ">") content = string.replace(content, "\\u003c", "<") content = string.replace(content, "\\u0026", "&") content = string.replace(content, "‚Ķ", "...") content = string.replace(content, "‚Äô", "'") content = string.replace(content, "‚ħ", ".") content = string.replace(content, "‚Äî", "-") content = string.replace(content, "‚Äú", '"') content = string.replace(content, "‚Äù", '"') content = string.replace(content, """, '"') content = string.replace(content, "√©", "é") content = string.replace(content, "√∂", "ö") content = string.replace(content, "'", "'") content = string.replace(content, "&", "&") content = string.replace(content, ">", ">") content = string.replace(content, "<", "<") return content def decodeJson(data): """ 'Decode' the JSON by taking the advantage of the fact that it is very similar to a Python dict. This is a terrible hack, and you should never do this anywhere because we're literally eval()ing untrusted data from the 'net. I'm only doing it because it's fast and there's not a lot of other options for parsing JSON data in Python 1.5. """ data = string.replace(data, "null", "None") data = string.replace(data, "false", "0") data = string.replace(data, "true", "1") data = eval(data) return data def dprint(text): """ Prints a string to stdout if and only if DEBUG is true """ if DEBUG: print text def okDialog(text, size=None): """ Draws a modal dialog box with the given text and an OK button to dismiss the dialog. """ if not size: size = (360, 120) window = W.ModalDialog(size, "Macstodon %s - Message" % VERSION) window.label = W.TextBox((10, 10, -10, -40), text) window.ok_btn = W.Button((-80, -30, -10, -10), "OK", window.close) window.setdefaultbutton(window.ok_btn) window.open() def okCancelDialog(text, size=None): """ Draws a modal dialog box with the given text and OK/Cancel buttons. The OK button will close the dialog. The Cancel button will raise an Exception, which the caller is expected to catch. """ if not size: size = (360, 120) global dialogWindow dialogWindow = W.ModalDialog(size, "Macstodon %s - Message" % VERSION) def dialogExceptionCallback(): dialogWindow.close() raise KeyboardInterrupt dialogWindow.label = W.TextBox((10, 10, -10, -40), text) dialogWindow.cancel_btn = W.Button((-160, -30, -90, -10), "Cancel", dialogExceptionCallback) dialogWindow.ok_btn = W.Button((-80, -30, -10, -10), "OK", dialogWindow.close) dialogWindow.setdefaultbutton(dialogWindow.ok_btn) dialogWindow.open() def handleRequest(app, path, data = None, use_token = 0): """ HTTP request wrapper """ try: pb = EasyDialogs.ProgressBar(maxval=3) if data == {}: data = "" elif data: data = urllib.urlencode(data) prefs = app.getprefs() url = "%s%s" % (prefs.server, path) dprint(url) dprint(data) dprint("connecting") pb.label("Connecting...") pb.inc() try: if use_token: urlopener = TokenURLopener(prefs.token) handle = urlopener.open(url, data) else: handle = urllib.urlopen(url, data) except IOError: del pb errmsg = "Unable to open a connection to: %s.\rPlease check that your SSL proxy is working properly and that the URL starts with 'http'." okDialog(errmsg % url) return None except TypeError: del pb errmsg = "The provided URL is malformed: %s.\rPlease check that you have typed the URL correctly." okDialog(errmsg % url) return None dprint("reading http headers") dprint(handle.info()) dprint("reading http body") pb.label("Fetching data...") pb.inc() try: data = handle.read() except IOError: del pb errmsg = "The connection was closed by the remote server while Macstodon was reading data.\rPlease check that your SSL proxy is working properly." okDialog(errmsg) return None handle.close() pb.label("Parsing data...") pb.inc() dprint("parsing response json") try: decoded = decodeJson(data) dprint(decoded) pb.label("Done.") pb.inc() time.sleep(0.5) del pb return decoded except: del pb dprint("ACK! JSON Parsing failure :(") dprint("This is what came back from the server:") dprint(data) okDialog("Error parsing JSON response from the server.") return None except KeyboardInterrupt: # the user pressed cancel in the progress bar window return None # ####### # Classes # ####### class ImageWidget(W.Widget): """ A widget that displays an image. The image should be passed in as a PixMapWrapper. """ def __init__(self, possize, pixmap=None): W.Widget.__init__(self, possize) # Set initial image self._imgloaded = 0 self._pixmap = None if pixmap: self.setImage(pixmap) def close(self): """ Destroys the widget and frees up its memory """ W.Widget.close(self) del self._imgloaded del self._pixmap def setImage(self, pixmap): """ Loads a new image into the widget. The image will be automatically scaled to the size of the widget. """ self._pixmap = pixmap self._imgloaded = 1 if self._parentwindow: self.draw() def clearImage(self): """ Unloads the image from the widget without destroying the widget. Use this to make the widget draw an empty square. """ self._imgloaded = 0 Qd.EraseRect(self._bounds) if self._parentwindow: self.draw() self._pixmap = None def draw(self, visRgn = None): """ Draw the image within the widget if it is loaded """ if self._visible: if self._imgloaded: self._pixmap.blit( x1=self._bounds[0], y1=self._bounds[1], x2=self._bounds[2], y2=self._bounds[3], port=self._parentwindow.wid.GetWindowPort() ) class TitledEditText(W.Group): """ A text edit field with a title and optional scrollbars attached to it. Shamelessly stolen from MacPython's PyEdit. Modified to also allow setting the title, and add scrollbars. """ def __init__(self, possize, title, text="", readonly=0, vscroll=0, hscroll=0): W.Group.__init__(self, possize) self.title = W.TextBox((0, 0, 0, 16), title) if vscroll and hscroll: editor = W.EditText((0, 16, -15, -15), text, readonly=readonly) self._barx = W.Scrollbar((0, -16, -15, 16), editor.hscroll, max=32767) self._bary = W.Scrollbar((-16, 16,0, -15), editor.vscroll, max=32767) elif vscroll: editor = W.EditText((0, 16, -15, 0), text, readonly=readonly) self._bary = W.Scrollbar((-16, 16, 0, 0), editor.vscroll, max=32767) elif hscroll: editor = W.EditText((0, 16, 0, -15), text, readonly=readonly) self._barx = W.Scrollbar((0, -16, 0, 16), editor.hscroll, max=32767) else: editor = W.EditText((0, 16, 0, 0), text, readonly=readonly) self.edit = editor def setTitle(self, value): self.title.set(value) def set(self, value): self.edit.set(value) def get(self): return self.edit.get() class TokenURLopener(urllib.FancyURLopener): """ Extends urllib.FancyURLopener to add the Authorization header with a bearer token. """ def __init__(self, token, *args): apply(urllib.FancyURLopener.__init__, (self,) + args) self.addheaders.append(("Authorization", "Bearer %s" % token)) class TwoLineListWithFlags(List): """ Modification of MacPython's TwoLineList to support flags. """ LDEF_ID = 468 def createlist(self): import List self._calcbounds() self.SetPort() rect = self._bounds rect = rect[0]+1, rect[1]+1, rect[2]-16, rect[3]-1 self._list = List.LNew(rect, (0, 0, 1, 0), (0, 28), self.LDEF_ID, self._parentwindow.wid, 0, 1, 0, 1) self._list.selFlags = self._flags self.set(self.items) class TimelineList(W.Group): """ A TwoLineListWithFlags that also has a title attached to it. Based on TitledEditText. """ def __init__(self, possize, title, items = None, btnCallback = None, callback = None, flags = 0, cols = 1, typingcasesens=0): W.Group.__init__(self, possize) self.title = W.TextBox((0, 2, 0, 16), title) self.btn = W.Button((-50, 0, 0, 16), "Refresh", btnCallback) self.list = TwoLineListWithFlags((0, 24, 0, 0), items, callback, flags, cols, typingcasesens) def setTitle(self, value): self.title.set(value) def set(self, items): self.list.set(items) def get(self): return self.list.items def getselection(self): return self.list.getselection() def setselection(self, selection): return self.list.setselection(selection) \ No newline at end of file diff --git a/README.md b/README.md index b6867d0..95d2988 100755 --- a/README.md +++ b/README.md @@ -6,22 +6,19 @@ Macstodon is an app written in MacPython 1.5.2 for Classic Mac OS that lets you System Requirements are: -* A 68k Macintosh with a 68020, 68030, or 68040 processor +* A 68k Macintosh with a 68020, 68030, or 68040 processor, or, any Power Macintosh * At least 1.5 MB of free memory (more if you want to be able to view avatars) -* System 7.1 to Mac OS 8.1 +* System 7.1 to Mac OS 9.2.2 * 32-bit addressing enabled +* Internet Config installed if you are running Mac OS 8.1 or earlier +* An SSL-stripping proxy server (such as [WebOne](https://github.com/atauenis/webone)) running on another computer on your network. The following extensions are required for System 7 users, and can be found in the "Required Extensions - System 7" folder distributed with Macstodon. System 7 users will need to copy them into the Extensions subfolder of their System Folder: -* CFM-68K Runtime Enabler +* CFM-68K Runtime Enabler (not required for Power Macintosh) * ObjectSupportLib * NuDragLib.slb -While not strictly _required_, installing Internet Config is strongly recommended as it will: - -* allow Macstodon to automatically open your web browser to the authentication URL during the initial login process -* allow you to globally set a HTTP proxy, so that you can use an SSL-stripping proxy. Without this you are restricted to pure HTTP instances (and I'm not sure if any such instances exist) - **No support is provided for this app, and I don't plan on maintaining it long-term. This is just a fun hack project, not a serious development effort.** ## Screenshots @@ -71,9 +68,9 @@ That's it for now. Maybe more features will be implemented in a later version. 5. Decompress the `Macstodon.rsrc.sit.hqx` file until you have `Macstodon.rsrc`. Keep this in the same directory as `Macstodon.py`. 6. Edit line 24 of the `Macstodon.py` file, which looks like this: ``` - # macfreeze: path Software:Programming:Python 1.5.2c1:Mac:Tools:IDE + # macfreeze: path SheepShaver:Python 1.5.2c1:Mac:Tools:IDE ``` - Change the *Software:Programming:Python 1.5.2c1:Mac:Tools:IDE* path to point to the **Mac:Tools:IDE** folder of the location where you installed MacPython. + Change the *SheepShaver:Python 1.5.2c1:Mac:Tools:IDE* path to point to the **Mac:Tools:IDE** folder of the location where you installed MacPython. 7. Edit line 81 of the `macgen_bin.py` file, which comes with MacPython and is located in the **Mac:Tools:macfreeze** directory. Comment out this line, it should look like this after your change: ``` #fss.SetCreatorType('Pyta', APPL) @@ -96,9 +93,7 @@ That's it for now. Maybe more features will be implemented in a later version. Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Safari/605.1.15 ``` * You will need to use **http** instead of **https** in the server URL for Macstodon. This is a limitation of the *urllib* library in MacPython 1.5.2. -* Performance of parsing large JSON responses is *terrible*. I'm sorry, I don't know how to fix this. If it looks like Macstodon has hung during the "Parsing Response" stage of a progress bar, it probably hasn't, it's just taking it's sweet time. Go make a coffee and come back :) * There is no support for Unicode whatsoever, and there never will be. Toots or usernames with emojis and special characters in them will look funny. -* There is no PowerPC build available because the MacPython 1.5.2 builder for PPC isn't respecting the runtime preferences that I have configured. This is *probably* a bug in this particular version of MacPython, and is *probably* fixed in later versions - which aren't 68K compatible. So if you really, really want a PPC version of this, you can try building it with MacPython 2.2 or 2.3 - I haven't tested this, though, and don't know if it will work without code changes. * If Macstodon actually crashes or unexpectedly quits while loading data from the server, try allocating more memory to it using the Get Info screen in the Finder. * If the `Timeline` window is closed, you can't get it back and will have to quit Macstodon from the File menu and relaunch it. * If images (avatars) fail to load, but the rest of the app seems to be working just fine, this means you need to give Macstodon more memory. Allocating more memory to it using the Get Info screen in the Finder will resolve this issue (you should also remove the image cache, see below) @@ -116,11 +111,6 @@ Copyright ©2004 Leonard Richardson License: Python -**JSON Decoding Algorithm** -Copyright ©2016 Henri Tuhola -License: MIT - - Extra special thanks to: [Dan](https://mastodon.lol/@billgoats) - for the inspiration to work on this project [Mingo](https://oldbytes.space/@mingo) - for [suggesting the name](https://oldbytes.space/@mingo/109316322622806248) diff --git a/TimelineHandler.py b/TimelineHandler.py index 31ce7ef..68b2433 100755 --- a/TimelineHandler.py +++ b/TimelineHandler.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import Lists import Qd import re import string import urllib import W # ################### # Third-Party Imports # ################### from third_party.BeautifulSoup import BeautifulSoup # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import cleanUpUnicode, dprint, handleRequest, ImageWidget, okDialog, \ okCancelDialog, TitledEditText, TimelineList # ########### # Application # ########### class TimelineHandler: def __init__(self, app): """ Initializes the TimelineHandler class. """ self.app = app self.defaulttext = "Click on a toot or notification in one of the above lists..." self.timelines = { "home": [], "local": [], "notifications": [] } # ######################### # Window Handling Functions # ######################### def getTimelineWindow(self): """ Defines the Timeline window """ # Set window size. Default to 600x400 which fits nicely in a 640x480 display. # However if you're on a compact Mac that is 512x342, we need to make it smaller. screenbounds = Qd.qd.screenBits.bounds if screenbounds[2] <= 600 and screenbounds[3] <= 400: bounds = (0, 20, 512, 342) else: bounds = (600, 400) w = W.Window(bounds, "Macstodon %s - Timeline" % VERSION, minsize=(512, 342)) w.panes = W.HorizontalPanes((8, 8, -8, -20), (0.6, 0.4)) w.panes.tlpanes = W.VerticalPanes(None, (0.34, 0.33, 0.33)) w.panes.tlpanes.home = TimelineList(None, "Home Timeline", self.timelines["home"], btnCallback=self.refreshHomeCallback, callback=self.homeClickCallback, flags=Lists.lOnlyOne) w.panes.tlpanes.local = TimelineList(None, "Local Timeline", self.timelines["local"], btnCallback=self.refreshLocalCallback, callback=self.localClickCallback, flags=Lists.lOnlyOne) w.panes.tlpanes.notifications = TimelineList(None, "Notifications", self.timelines["notifications"], btnCallback=self.refreshNotificationsCallback, callback=self.notificationClickCallback, flags=Lists.lOnlyOne) w.panes.tootgroup = W.Group(None) w.panes.tootgroup.toottxt = TitledEditText((56, 0, 0, -20), title="", text=self.defaulttext, readonly=1, vscroll=1) # Avatar, reply/boost/favourite/bookmark buttons w.panes.tootgroup.authorimg = ImageWidget((0, 0, 48, 48)) w.panes.tootgroup.boosterimg = ImageWidget((24, 24, 24, 24)) w.panes.tootgroup.reply = W.Button((4, 52, 16, 16), "R", self.replyCallback) w.panes.tootgroup.rpnum = W.TextBox((24, 55, 28, 16), "") w.panes.tootgroup.favrt = W.Button((4, 70, 16, 16), "F", self.favouriteCallback) w.panes.tootgroup.fvnum = W.TextBox((24, 73, 28, 16), "") w.panes.tootgroup.boost = W.Button((4, 88, 16, 16), "B", self.boostCallback) w.panes.tootgroup.bonum = W.TextBox((24, 91, 28, 16), "") w.panes.tootgroup.bmark = W.Button((4, 106, 16, 16), "M", self.bookmarkCallback) w.panes.tootgroup.logoutbutton = W.Button((56, -15, 80, 0), "Logout", self.timelineLogoutCallback) w.panes.tootgroup.tootbutton = W.Button((-80, -15, 80, 0), "Post Toot", self.tootCallback) return w # ################## # Callback Functions # ################## def timelineClickCallback(self, name): """ Run when the user clicks somewhere in the named timeline """ w = self.app.timelinewindow if name == "home": list = w.panes.tlpanes.home elif name == "local": list = w.panes.tlpanes.local selected = list.getselection() if len(selected) < 1: w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) return else: index = selected[0] toot = self.timelines[name][index] self.formatAndDisplayToot(toot) def homeClickCallback(self): """ Run when the user clicks somewhere in the home timeline """ w = self.app.timelinewindow w.panes.tlpanes.local.setselection([-1]) w.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("home") def localClickCallback(self): """ Run when the user clicks somewhere in the local timeline """ w = self.app.timelinewindow w.panes.tlpanes.home.setselection([-1]) w.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("local") def notificationClickCallback(self): """ Run when the user clicks somewhere in the notification timeline """ w = self.app.timelinewindow w.panes.tlpanes.home.setselection([-1]) w.panes.tlpanes.local.setselection([-1]) list = w.panes.tlpanes.notifications selected = list.getselection() if len(selected) < 1: w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) return else: index = selected[0] notification = self.timelines["notifications"][index] if notification["type"] in ["favourite", "reblog", "status", "mention", "poll", "update"]: toot = notification["status"] self.formatAndDisplayToot(toot) else: okDialog("Sorry, displaying the notification type '%s' is not supported yet" % notification["type"]) w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) def refreshHomeCallback(self, limit=None): """ Run when the user clicks the Refresh button above the home timeline """ self.updateTimeline("home", limit) self.app.timelinewindow.panes.tlpanes.home.set(self.formatTimelineForList("home")) def refreshLocalCallback(self, limit=None): """ Run when the user clicks the Refresh button above the local timeline """ self.updateTimeline("local", limit) self.app.timelinewindow.panes.tlpanes.local.set(self.formatTimelineForList("local")) def refreshNotificationsCallback(self, limit=None): """ Run when the user clicks the Refresh button above the notifications timeline """ self.updateTimeline("notifications", limit) listitems = self.formatNotificationsForList() self.app.timelinewindow.panes.tlpanes.notifications.set(listitems) def timelineLogoutCallback(self): """ Run when the user clicks the "Logout" button from the timeline window. Just closes the timeline window and reopens the login window. """ self.app.timelinewindow.close() self.app.loginwindow = self.app.authhandler.getLoginWindow() self.app.loginwindow.open() def tootCallback(self): """ Run when the user clicks the "Post Toot" button from the timeline window. It opens up the toot window. """ self.app.tootwindow = self.app.toothandler.getTootWindow() self.app.tootwindow.open() def replyCallback(self): """ Run when the user clicks the "Reply" button from the timeline window. It opens up the toot window, passing the currently selected toot as a parameter. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: self.app.tootwindow = self.app.toothandler.getTootWindow(replyTo=toot) self.app.tootwindow.open() else: okDialog("Please select a toot first.") def boostCallback(self): """ Boosts a toot. Removes the boost if the toot was already boosted. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["reblogged"]: # already boosted, undo it action = "unreblog" else: # not bookmarked yet action = "reblog" visibility = toot["visibility"] if visibility == "limited" or visibility == "direct": visibility = "public" req_data = { "visibility": visibility } path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, req_data, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data["reblog"] else: dprint("overwriting notification") timeline[index]["status"] = data["reblog"] else: dprint("overwriting normal toot") timeline[index] = data["reblog"] okDialog("Toot %sged successfully!" % action) else: okDialog("Please select a toot first.") def favouriteCallback(self): """ Favourites a toot. Removes the favourite if the toot was already favourited. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["favourited"]: # already favourited, undo it action = "unfavourite" else: # not favourited yet action = "favourite" path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, {}, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data else: dprint("overwriting notification") timeline[index]["status"] = data else: dprint("overwriting normal toot") timeline[index] = data okDialog("Toot %sd successfully!" % action) else: okDialog("Please select a toot first.") def bookmarkCallback(self): """ Bookmarks a toot. Removes the bookmark if the toot was already bookmarked. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["bookmarked"]: # already bookmarked, undo it action = "unbookmark" else: # not bookmarked yet action = "bookmark" path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, {}, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data else: dprint("overwriting notification") timeline[index]["status"] = data else: dprint("overwriting normal toot") timeline[index] = data okDialog("Toot %sed successfully!" % action) else: okDialog("Please select a toot first.") # #################### # Formatting Functions # #################### def formatAndDisplayToot(self, toot): """ Formats a toot for display and displays it in the bottom third """ w = self.app.timelinewindow # clear existing toot w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set("Loading toot...") display_name = toot["account"]["display_name"] or toot["account"]["username"] display_name = cleanUpUnicode(display_name) if toot["reblog"]: image = self.app.imagehandler.getImageFromURL(toot["reblog"]["account"]["avatar"], "account") bimage = self.app.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") reblog_display_name = toot["reblog"]["account"]["display_name"] or toot["reblog"]["account"]["username"] reblog_display_name = cleanUpUnicode(reblog_display_name) title = "%s boosted %s (@%s)" % (display_name, reblog_display_name, toot["reblog"]["account"]["acct"]) content = toot["reblog"]["content"] sensitive = toot["reblog"]["sensitive"] spoiler_text = toot["reblog"]["spoiler_text"] favourites_count = toot["reblog"]["favourites_count"] reblogs_count = toot["reblog"]["reblogs_count"] replies_count = toot["reblog"]["replies_count"] else: image = self.app.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") bimage = None title = "%s (@%s)" % (display_name, toot["account"]["acct"]) content = toot["content"] sensitive = toot["sensitive"] spoiler_text = toot["spoiler_text"] favourites_count = toot["favourites_count"] reblogs_count = toot["reblogs_count"] replies_count = toot["replies_count"] # Check for CW if sensitive: cwText = "This toot has a content warning. " \ "Press OK to view or Cancel to not view.\r\r%s" try: okCancelDialog(cwText % spoiler_text) except KeyboardInterrupt: w.panes.tootgroup.toottxt.set(self.defaulttext) return # Replace HTML linebreak tags with actual linebreaks content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "

", "") content = string.replace(content, "

", "\r\r") content = cleanUpUnicode(content) # Extract links #soup = BeautifulSoup(content) #links = soup("a") #dprint("** ANCHORS **") #dprint(links) # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) # Render content into UI if image: w.panes.tootgroup.authorimg.setImage(image) if bimage: w.panes.tootgroup.boosterimg.setImage(bimage) w.panes.tootgroup.toottxt.setTitle(title) w.panes.tootgroup.toottxt.set(content) w.panes.tootgroup.fvnum.set(str(favourites_count)) w.panes.tootgroup.bonum.set(str(reblogs_count)) w.panes.tootgroup.rpnum.set(str(replies_count)) def formatTimelineForList(self, name): """ Formats toots for display in a timeline list """ listitems = [] for toot in self.timelines[name]: if toot["reblog"]: if toot["reblog"]["sensitive"]: content = toot["reblog"]["spoiler_text"] else: content = toot["reblog"]["content"] else: if toot["sensitive"]: content = toot["spoiler_text"] else: content = toot["content"] content = cleanUpUnicode(content) # Replace linebreaks with spaces content = string.replace(content, "
", " ") content = string.replace(content, "
", " ") content = string.replace(content, "
", " ") content = string.replace(content, "

", "") content = string.replace(content, "

", " ") # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) display_name = toot["account"]["display_name"] or toot["account"]["username"] display_name = cleanUpUnicode(display_name) if toot["reblog"]: reblog_display_name = toot["reblog"]["account"]["display_name"] or toot["reblog"]["account"]["username"] reblog_display_name = cleanUpUnicode(reblog_display_name) listitem = "%s boosted %s\r%s" % (display_name, reblog_display_name, content) else: listitem = "%s\r%s" % (display_name, content) listitems.append(listitem) return listitems def formatNotificationsForList(self): """ Formats notifications for display in a list """ listitems = [] for notification in self.timelines["notifications"]: display_name = notification["account"]["display_name"] or notification["account"]["username"] if notification["type"] == "mention": listitem = "%s mentioned you in their toot" % display_name elif notification["type"] == "status": listitem = "%s posted a toot" % display_name elif notification["type"] == "reblog": listitem = "%s boosted your toot" % display_name elif notification["type"] == "follow": listitem = "%s followed you" % display_name elif notification["type"] == "follow_request": listitem = "%s requested to follow you" % display_name elif notification["type"] == "favourite": listitem = "%s favourited your toot" % display_name elif notification["type"] == "poll": listitem = "%s's poll has ended" % display_name elif notification["type"] == "update": listitem = "%s updated their toot" % display_name elif notification["type"] == "admin.sign_up": listitem = "%s signed up" % display_name elif notification["type"] == "admin.report": listitem = "%s filed a report" % display_name else: # unknown type, ignore it, but print to console if debugging dprint("Unknown notification type: %s" % notification["type"]) listitems.append(listitem) return listitems # ################ # Helper Functions # ################ def getSelectedToot(self, resolve_boosts=0): """ Returns the selected toot, the containing toot (if boost or notification), the timeline to which the toot belongs, and the index of the toot in the timeline. """ w = self.app.timelinewindow homeTimeline = w.panes.tlpanes.home localTimeline = w.panes.tlpanes.local notificationsTimeline = w.panes.tlpanes.notifications homeSelected = homeTimeline.getselection() localSelected = localTimeline.getselection() notificationsSelected = notificationsTimeline.getselection() if len(homeSelected) > 0: index = homeSelected[0] toot = self.timelines["home"][index] timeline = self.timelines["home"] elif len(localSelected) > 0: index = localSelected[0] toot = self.timelines["local"][index] timeline = self.timelines["local"] elif len(notificationsSelected) > 0: index = notificationsSelected[0] toot = self.timelines["notifications"][index] timeline = self.timelines["notifications"] else: return None, None, None, None if toot.get("reblog") and resolve_boosts: return toot["reblog"], toot, timeline, index elif toot.get("status"): return toot["status"], toot, timeline, index else: return toot, None, timeline, index def updateTimeline(self, name, limit = None): """ Pulls a timeline from the server and updates the global dicts TODO: hashtags and lists """ params = {} if limit: params["limit"] = limit if len(self.timelines[name]) > 0: params["min_id"] = self.timelines[name][0]["id"] if name == "home": path = "/api/v1/timelines/home" elif name == "local": path = "/api/v1/timelines/public" params["local"] = "true" elif name == "public": # not currently used anywhere path = "/api/v1/timelines/public" elif name == "notifications": path = "/api/v1/notifications" else: dprint("Unknown timeline name: %s" % name) return encoded_params = urllib.urlencode(params) data = handleRequest(self.app, path + "?" + encoded_params, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return # if data is a list, it worked if type(data) == type([]): for i in range(len(data)-1, -1, -1): self.timelines[name].insert(0, data[i]) # if data is a dict, it failed elif type(data) == type({}) and data.get("error") is not None: okDialog("Server error when refreshing %s timeline:\r\r %s" % (name, data['error'])) # i don't think this is reachable, but just in case... else: okDialog("Server error when refreshing %s timeline. Unable to determine data type." % name) \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import Lists import Qd import re import string import urllib import W # ################### # Third-Party Imports # ################### from third_party.BeautifulSoup import BeautifulSoup # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import cleanUpUnicode, dprint, handleRequest, ImageWidget, okDialog, \ okCancelDialog, TitledEditText, TimelineList # ########### # Application # ########### class TimelineHandler: def __init__(self, app): """ Initializes the TimelineHandler class. """ self.app = app self.defaulttext = "Click on a toot or notification in one of the above lists..." self.timelines = { "home": [], "local": [], "notifications": [] } # ######################### # Window Handling Functions # ######################### def getTimelineWindow(self): """ Defines the Timeline window """ # Set window size. Default to 600x400 which fits nicely in a 640x480 display. # However if you're on a compact Mac that is 512x342, we need to make it smaller. screenbounds = Qd.qd.screenBits.bounds if screenbounds[2] <= 600 and screenbounds[3] <= 400: bounds = (0, 20, 512, 342) else: bounds = (600, 400) w = W.Window(bounds, "Macstodon %s - Timeline" % VERSION, minsize=(512, 342)) w.panes = W.HorizontalPanes((8, 8, -8, -20), (0.6, 0.4)) w.panes.tlpanes = W.VerticalPanes(None, (0.34, 0.33, 0.33)) w.panes.tlpanes.home = TimelineList(None, "Home Timeline", self.timelines["home"], btnCallback=self.refreshHomeCallback, callback=self.homeClickCallback, flags=Lists.lOnlyOne) w.panes.tlpanes.local = TimelineList(None, "Local Timeline", self.timelines["local"], btnCallback=self.refreshLocalCallback, callback=self.localClickCallback, flags=Lists.lOnlyOne) w.panes.tlpanes.notifications = TimelineList(None, "Notifications", self.timelines["notifications"], btnCallback=self.refreshNotificationsCallback, callback=self.notificationClickCallback, flags=Lists.lOnlyOne) w.panes.tootgroup = W.Group(None) w.panes.tootgroup.toottxt = TitledEditText((56, 0, 0, -20), title="", text=self.defaulttext, readonly=1, vscroll=1) # Avatar, reply/boost/favourite/bookmark buttons w.panes.tootgroup.authorimg = ImageWidget((0, 0, 48, 48)) w.panes.tootgroup.boosterimg = ImageWidget((24, 24, 24, 24)) w.panes.tootgroup.reply = W.Button((4, 52, 16, 16), "R", self.replyCallback) w.panes.tootgroup.rpnum = W.TextBox((24, 55, 28, 16), "") w.panes.tootgroup.favrt = W.Button((4, 70, 16, 16), "F", self.favouriteCallback) w.panes.tootgroup.fvnum = W.TextBox((24, 73, 28, 16), "") w.panes.tootgroup.boost = W.Button((4, 88, 16, 16), "B", self.boostCallback) w.panes.tootgroup.bonum = W.TextBox((24, 91, 28, 16), "") w.panes.tootgroup.bmark = W.Button((4, 106, 16, 16), "M", self.bookmarkCallback) w.panes.tootgroup.logoutbutton = W.Button((56, -15, 80, 0), "Logout", self.timelineLogoutCallback) w.panes.tootgroup.tootbutton = W.Button((-80, -15, 80, 0), "Post Toot", self.tootCallback) return w # ################## # Callback Functions # ################## def timelineClickCallback(self, name): """ Run when the user clicks somewhere in the named timeline """ w = self.app.timelinewindow if name == "home": list = w.panes.tlpanes.home elif name == "local": list = w.panes.tlpanes.local selected = list.getselection() if len(selected) < 1: w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) return else: index = selected[0] toot = self.timelines[name][index] self.formatAndDisplayToot(toot) def homeClickCallback(self): """ Run when the user clicks somewhere in the home timeline """ w = self.app.timelinewindow w.panes.tlpanes.local.setselection([-1]) w.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("home") def localClickCallback(self): """ Run when the user clicks somewhere in the local timeline """ w = self.app.timelinewindow w.panes.tlpanes.home.setselection([-1]) w.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("local") def notificationClickCallback(self): """ Run when the user clicks somewhere in the notification timeline """ w = self.app.timelinewindow w.panes.tlpanes.home.setselection([-1]) w.panes.tlpanes.local.setselection([-1]) list = w.panes.tlpanes.notifications selected = list.getselection() if len(selected) < 1: w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) return else: index = selected[0] notification = self.timelines["notifications"][index] if notification["type"] in ["favourite", "reblog", "status", "mention", "poll", "update"]: toot = notification["status"] self.formatAndDisplayToot(toot) else: okDialog("Sorry, displaying the notification type '%s' is not supported yet" % notification["type"]) w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set(self.defaulttext) def refreshHomeCallback(self, limit=None): """ Run when the user clicks the Refresh button above the home timeline """ self.updateTimeline("home", limit) self.app.timelinewindow.panes.tlpanes.home.set(self.formatTimelineForList("home")) def refreshLocalCallback(self, limit=None): """ Run when the user clicks the Refresh button above the local timeline """ self.updateTimeline("local", limit) self.app.timelinewindow.panes.tlpanes.local.set(self.formatTimelineForList("local")) def refreshNotificationsCallback(self, limit=None): """ Run when the user clicks the Refresh button above the notifications timeline """ self.updateTimeline("notifications", limit) listitems = self.formatNotificationsForList() self.app.timelinewindow.panes.tlpanes.notifications.set(listitems) def timelineLogoutCallback(self): """ Run when the user clicks the "Logout" button from the timeline window. Just closes the timeline window and reopens the login window. """ self.app.timelinewindow.close() self.app.loginwindow = self.app.authhandler.getLoginWindow() self.app.loginwindow.open() def tootCallback(self): """ Run when the user clicks the "Post Toot" button from the timeline window. It opens up the toot window. """ self.app.tootwindow = self.app.toothandler.getTootWindow() self.app.tootwindow.open() def replyCallback(self): """ Run when the user clicks the "Reply" button from the timeline window. It opens up the toot window, passing the currently selected toot as a parameter. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: self.app.tootwindow = self.app.toothandler.getTootWindow(replyTo=toot) self.app.tootwindow.open() else: okDialog("Please select a toot first.") def boostCallback(self): """ Boosts a toot. Removes the boost if the toot was already boosted. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["reblogged"]: # already boosted, undo it action = "unreblog" else: # not bookmarked yet action = "reblog" visibility = toot["visibility"] if visibility == "limited" or visibility == "direct": visibility = "public" req_data = { "visibility": visibility } path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, req_data, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data["reblog"] else: dprint("overwriting notification") timeline[index]["status"] = data["reblog"] else: dprint("overwriting normal toot") timeline[index] = data["reblog"] okDialog("Toot %sged successfully!" % action) else: okDialog("Please select a toot first.") def favouriteCallback(self): """ Favourites a toot. Removes the favourite if the toot was already favourited. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["favourited"]: # already favourited, undo it action = "unfavourite" else: # not favourited yet action = "favourite" path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, {}, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data else: dprint("overwriting notification") timeline[index]["status"] = data else: dprint("overwriting normal toot") timeline[index] = data okDialog("Toot %sd successfully!" % action) else: okDialog("Please select a toot first.") def bookmarkCallback(self): """ Bookmarks a toot. Removes the bookmark if the toot was already bookmarked. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: if toot["bookmarked"]: # already bookmarked, undo it action = "unbookmark" else: # not bookmarked yet action = "bookmark" path = "/api/v1/statuses/%s/%s" % (toot["id"], action) data = handleRequest(self.app, path, {}, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing toot:\r\r %s" % (action, data['error'])) else: if origToot: if origToot.get("reblog"): dprint("overwriting boosted toot") timeline[index]["reblog"] = data else: dprint("overwriting notification") timeline[index]["status"] = data else: dprint("overwriting normal toot") timeline[index] = data okDialog("Toot %sed successfully!" % action) else: okDialog("Please select a toot first.") # #################### # Formatting Functions # #################### def formatAndDisplayToot(self, toot): """ Formats a toot for display and displays it in the bottom third """ w = self.app.timelinewindow # clear existing toot w.panes.tootgroup.authorimg.clearImage() w.panes.tootgroup.boosterimg.clearImage() w.panes.tootgroup.fvnum.set("") w.panes.tootgroup.bonum.set("") w.panes.tootgroup.rpnum.set("") w.panes.tootgroup.toottxt.setTitle("") w.panes.tootgroup.toottxt.set("Loading toot...") display_name = toot["account"]["display_name"] or toot["account"]["username"] display_name = cleanUpUnicode(display_name) if toot["reblog"]: image = self.app.imagehandler.getImageFromURL(toot["reblog"]["account"]["avatar"], "account") bimage = self.app.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") reblog_display_name = toot["reblog"]["account"]["display_name"] or toot["reblog"]["account"]["username"] reblog_display_name = cleanUpUnicode(reblog_display_name) title = "%s boosted %s (@%s)" % (display_name, reblog_display_name, toot["reblog"]["account"]["acct"]) content = toot["reblog"]["content"] sensitive = toot["reblog"]["sensitive"] spoiler_text = toot["reblog"]["spoiler_text"] favourites_count = toot["reblog"]["favourites_count"] reblogs_count = toot["reblog"]["reblogs_count"] replies_count = toot["reblog"]["replies_count"] else: image = self.app.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") bimage = None title = "%s (@%s)" % (display_name, toot["account"]["acct"]) content = toot["content"] sensitive = toot["sensitive"] spoiler_text = toot["spoiler_text"] favourites_count = toot["favourites_count"] reblogs_count = toot["reblogs_count"] replies_count = toot["replies_count"] # Check for CW if sensitive: cwText = "This toot has a content warning. " \ "Press OK to view or Cancel to not view.\r\r%s" try: okCancelDialog(cwText % spoiler_text) except KeyboardInterrupt: w.panes.tootgroup.toottxt.set(self.defaulttext) return # Replace HTML linebreak tags with actual linebreaks content = cleanUpUnicode(content) content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "

", "") content = string.replace(content, "

", "\r\r") # Extract links #soup = BeautifulSoup(content) #links = soup("a") #dprint("** ANCHORS **") #dprint(links) # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) # Render content into UI if image: w.panes.tootgroup.authorimg.setImage(image) if bimage: w.panes.tootgroup.boosterimg.setImage(bimage) w.panes.tootgroup.toottxt.setTitle(title) w.panes.tootgroup.toottxt.set(content) w.panes.tootgroup.fvnum.set(str(favourites_count)) w.panes.tootgroup.bonum.set(str(reblogs_count)) w.panes.tootgroup.rpnum.set(str(replies_count)) def formatTimelineForList(self, name): """ Formats toots for display in a timeline list """ listitems = [] for toot in self.timelines[name]: if toot["reblog"]: if toot["reblog"]["sensitive"]: content = toot["reblog"]["spoiler_text"] else: content = toot["reblog"]["content"] else: if toot["sensitive"]: content = toot["spoiler_text"] else: content = toot["content"] content = cleanUpUnicode(content) # Replace linebreaks with spaces content = string.replace(content, "
", " ") content = string.replace(content, "
", " ") content = string.replace(content, "
", " ") content = string.replace(content, "

", "") content = string.replace(content, "

", " ") # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) display_name = toot["account"]["display_name"] or toot["account"]["username"] display_name = cleanUpUnicode(display_name) if toot["reblog"]: reblog_display_name = toot["reblog"]["account"]["display_name"] or toot["reblog"]["account"]["username"] reblog_display_name = cleanUpUnicode(reblog_display_name) listitem = "%s boosted %s\r%s" % (display_name, reblog_display_name, content) else: listitem = "%s\r%s" % (display_name, content) listitems.append(listitem) return listitems def formatNotificationsForList(self): """ Formats notifications for display in a list """ listitems = [] for notification in self.timelines["notifications"]: display_name = notification["account"]["display_name"] or notification["account"]["username"] if notification["type"] == "mention": listitem = "%s mentioned you in their toot" % display_name elif notification["type"] == "status": listitem = "%s posted a toot" % display_name elif notification["type"] == "reblog": listitem = "%s boosted your toot" % display_name elif notification["type"] == "follow": listitem = "%s followed you" % display_name elif notification["type"] == "follow_request": listitem = "%s requested to follow you" % display_name elif notification["type"] == "favourite": listitem = "%s favourited your toot" % display_name elif notification["type"] == "poll": listitem = "%s's poll has ended" % display_name elif notification["type"] == "update": listitem = "%s updated their toot" % display_name elif notification["type"] == "admin.sign_up": listitem = "%s signed up" % display_name elif notification["type"] == "admin.report": listitem = "%s filed a report" % display_name else: # unknown type, ignore it, but print to console if debugging dprint("Unknown notification type: %s" % notification["type"]) listitems.append(listitem) return listitems # ################ # Helper Functions # ################ def getSelectedToot(self, resolve_boosts=0): """ Returns the selected toot, the containing toot (if boost or notification), the timeline to which the toot belongs, and the index of the toot in the timeline. """ w = self.app.timelinewindow homeTimeline = w.panes.tlpanes.home localTimeline = w.panes.tlpanes.local notificationsTimeline = w.panes.tlpanes.notifications homeSelected = homeTimeline.getselection() localSelected = localTimeline.getselection() notificationsSelected = notificationsTimeline.getselection() if len(homeSelected) > 0: index = homeSelected[0] toot = self.timelines["home"][index] timeline = self.timelines["home"] elif len(localSelected) > 0: index = localSelected[0] toot = self.timelines["local"][index] timeline = self.timelines["local"] elif len(notificationsSelected) > 0: index = notificationsSelected[0] toot = self.timelines["notifications"][index] timeline = self.timelines["notifications"] else: return None, None, None, None if toot.get("reblog") and resolve_boosts: return toot["reblog"], toot, timeline, index elif toot.get("status"): return toot["status"], toot, timeline, index else: return toot, None, timeline, index def updateTimeline(self, name, limit = None): """ Pulls a timeline from the server and updates the global dicts TODO: hashtags and lists """ params = {} if limit: params["limit"] = limit if len(self.timelines[name]) > 0: params["min_id"] = self.timelines[name][0]["id"] if name == "home": path = "/api/v1/timelines/home" elif name == "local": path = "/api/v1/timelines/public" params["local"] = "true" elif name == "public": # not currently used anywhere path = "/api/v1/timelines/public" elif name == "notifications": path = "/api/v1/notifications" else: dprint("Unknown timeline name: %s" % name) return encoded_params = urllib.urlencode(params) data = handleRequest(self.app, path + "?" + encoded_params, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return # if data is a list, it worked if type(data) == type([]): for i in range(len(data)-1, -1, -1): self.timelines[name].insert(0, data[i]) # if data is a dict, it failed elif type(data) == type({}) and data.get("error") is not None: okDialog("Server error when refreshing %s timeline:\r\r %s" % (name, data['error'])) # i don't think this is reachable, but just in case... else: okDialog("Server error when refreshing %s timeline. Unable to determine data type." % name) \ No newline at end of file diff --git a/TootHandler.py b/TootHandler.py index 144fecc..d0d2e05 100755 --- a/TootHandler.py +++ b/TootHandler.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import re import string import W # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import cleanUpUnicode, dprint, handleRequest, okDialog, TitledEditText # ########### # Application # ########### class TootHandler: def __init__(self, app): """ Initializes the TootHandler class. """ self.app = app self.replyToID = None self.visibility = "public" # ######################### # Window Handling Functions # ######################### def getTootWindow(self, replyTo=None): """ Defines the Toot window. """ prefs = self.app.getprefs() if not prefs.max_toot_chars: prefs.max_toot_chars = self.getMaxTootChars() prefs.save() tootwindow = W.Dialog((320, 210), "Macstodon %s - Toot" % VERSION) heading = "Type your toot below (max %s characters):" % prefs.max_toot_chars if replyTo: self.replyToID = replyTo["id"] title = "Replying to %s:" % replyTo["account"]["acct"] text = "@%s " % replyTo["account"]["acct"] content = replyTo["content"] # Replace HTML linebreak tags with actual linebreaks content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "

", "") content = string.replace(content, "

", "\r\r") content = cleanUpUnicode(content) # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) tootwindow.reply = TitledEditText((10, 6, -10, -140), title=title, text=content, readonly=1, vscroll=1) tootwindow.toot = TitledEditText((10, 76, -10, -70), title=heading, text=text, vscroll=1) else: self.replyToID = None tootwindow.toot = TitledEditText((10, 6, -10, -70), title=heading, vscroll=1) # Visibility radio buttons visButtons = [] tootwindow.vis_public = W.RadioButton((10, 145, 55, 16), "Public", visButtons, self.visPublicCallback) tootwindow.vis_unlisted = W.RadioButton((75, 145, 65, 16), "Unlisted", visButtons, self.visUnlistedCallback) tootwindow.vis_followers = W.RadioButton((150, 145, 75, 16), "Followers", visButtons, self.visFollowersCallback) tootwindow.vis_mentioned = W.RadioButton((230, 145, 75, 16), "Mentioned", visButtons, self.visMentionedCallback) # If replying to an existing toot, default to that toot's visibility. # Default to public for new toots that are not replies. if replyTo: if replyTo["visibility"] == "unlisted": self.visibility = "unlisted" tootwindow.vis_unlisted.set(1) elif replyTo["visibility"] == "private": self.visibility = "private" tootwindow.vis_followers.set(1) elif replyTo["visibility"] == "direct": self.visibility = "direct" tootwindow.vis_mentioned.set(1) else: self.visibility = "public" tootwindow.vis_public.set(1) else: tootwindow.vis_public.set(1) # Content warning checkbox and text field tootwindow.cw = W.CheckBox((10, -45, 30, 16), "CW", self.cwCallback) tootwindow.cw_text = W.EditText((50, -45, -10, 16)) # If replying to a toot with a CW, apply the CW to the reply. # For new toots, default to the CW off. if replyTo: if replyTo["sensitive"]: tootwindow.cw.set(1) tootwindow.cw_text.set(replyTo["spoiler_text"]) else: tootwindow.cw_text.show(0) else: tootwindow.cw_text.show(0) # Close button tootwindow.close_btn = W.Button((10, -22, 60, 16), "Close", tootwindow.close) # Toot button # This button is intentionally not made a default, so that if you press Return # to make a multi-line toot it won't accidentally send. tootwindow.toot_btn = W.Button((-69, -22, 60, 16), "Toot!", self.tootCallback) return tootwindow # ################## # Callback Functions # ################## def cwCallback(self): """ Called when the CW checkbox is ticked or unticked. Used to show/hide the CW text entry field. """ self.app.tootwindow.cw_text.show(not self.app.tootwindow.cw_text._visible) def visPublicCallback(self): """ Sets visibility to public when the Public radio button is clicked. """ self.visibility = "public" def visUnlistedCallback(self): """ Sets visibility to unlisted when the Unlisted radio button is clicked. """ self.visibility = "unlisted" def visFollowersCallback(self): """ Sets visibility to private when the Followers radio button is clicked. """ self.visibility = "private" def visMentionedCallback(self): """ Sets visibility to direct when the Mentioned radio button is clicked. """ self.visibility = "direct" def tootCallback(self): """ Called when the user presses the Toot button, and posts their toot. """ req_data = { "status": self.app.tootwindow.toot.get(), "visibility": self.visibility } if self.replyToID: req_data["in_reply_to_id"] = self.replyToID if self.app.tootwindow.cw.get() == 1: req_data["sensitive"] = "true" req_data["spoiler_text"] = self.app.tootwindow.cw_text.get() path = "/api/v1/statuses" data = handleRequest(self.app, path, req_data, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when posting toot:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when posting toot:\r\r %s" % data['error']) else: okDialog("Tooted successfully!") self.app.tootwindow.close() # ################ # Helper Functions # ################ def getMaxTootChars(self): """ Gets the maximum allowed number of characters in a toot. Not all instances support this, the default is 500 characters if not present in the response. """ path = "/api/v1/instance" data = handleRequest(self.app, path) if not data: return 500 if data.get("error_description") is not None: dprint("Server error when getting max toot chars: %s" % data["error_description"]) return 500 elif data.get("error") is not None: dprint("Server error when getting max toot chars: %s" % data["error"]) return 500 else: max_toot_chars = data.get("max_toot_chars", 500) dprint("max toot chars: %s" % max_toot_chars) return max_toot_chars \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022 Scott Small and Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ # ############## # Python Imports # ############## import re import string import W # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import cleanUpUnicode, dprint, handleRequest, okDialog, TitledEditText # ########### # Application # ########### class TootHandler: def __init__(self, app): """ Initializes the TootHandler class. """ self.app = app self.replyToID = None self.visibility = "public" # ######################### # Window Handling Functions # ######################### def getTootWindow(self, replyTo=None): """ Defines the Toot window. """ prefs = self.app.getprefs() if not prefs.max_toot_chars: prefs.max_toot_chars = self.getMaxTootChars() prefs.save() tootwindow = W.Dialog((320, 210), "Macstodon %s - Toot" % VERSION) heading = "Type your toot below (max %s characters):" % prefs.max_toot_chars if replyTo: self.replyToID = replyTo["id"] title = "Replying to %s:" % replyTo["account"]["acct"] text = "@%s " % replyTo["account"]["acct"] content = replyTo["content"] # Replace HTML linebreak tags with actual linebreaks content = cleanUpUnicode(content) content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "
", "\r") content = string.replace(content, "

", "") content = string.replace(content, "

", "\r\r") # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) tootwindow.reply = TitledEditText((10, 6, -10, -140), title=title, text=content, readonly=1, vscroll=1) tootwindow.toot = TitledEditText((10, 76, -10, -70), title=heading, text=text, vscroll=1) else: self.replyToID = None tootwindow.toot = TitledEditText((10, 6, -10, -70), title=heading, vscroll=1) # Visibility radio buttons visButtons = [] tootwindow.vis_public = W.RadioButton((10, 145, 55, 16), "Public", visButtons, self.visPublicCallback) tootwindow.vis_unlisted = W.RadioButton((75, 145, 65, 16), "Unlisted", visButtons, self.visUnlistedCallback) tootwindow.vis_followers = W.RadioButton((150, 145, 75, 16), "Followers", visButtons, self.visFollowersCallback) tootwindow.vis_mentioned = W.RadioButton((230, 145, 75, 16), "Mentioned", visButtons, self.visMentionedCallback) # If replying to an existing toot, default to that toot's visibility. # Default to public for new toots that are not replies. if replyTo: if replyTo["visibility"] == "unlisted": self.visibility = "unlisted" tootwindow.vis_unlisted.set(1) elif replyTo["visibility"] == "private": self.visibility = "private" tootwindow.vis_followers.set(1) elif replyTo["visibility"] == "direct": self.visibility = "direct" tootwindow.vis_mentioned.set(1) else: self.visibility = "public" tootwindow.vis_public.set(1) else: tootwindow.vis_public.set(1) # Content warning checkbox and text field tootwindow.cw = W.CheckBox((10, -45, 30, 16), "CW", self.cwCallback) tootwindow.cw_text = W.EditText((50, -45, -10, 16)) # If replying to a toot with a CW, apply the CW to the reply. # For new toots, default to the CW off. if replyTo: if replyTo["sensitive"]: tootwindow.cw.set(1) tootwindow.cw_text.set(replyTo["spoiler_text"]) else: tootwindow.cw_text.show(0) else: tootwindow.cw_text.show(0) # Close button tootwindow.close_btn = W.Button((10, -22, 60, 16), "Close", tootwindow.close) # Toot button # This button is intentionally not made a default, so that if you press Return # to make a multi-line toot it won't accidentally send. tootwindow.toot_btn = W.Button((-69, -22, 60, 16), "Toot!", self.tootCallback) return tootwindow # ################## # Callback Functions # ################## def cwCallback(self): """ Called when the CW checkbox is ticked or unticked. Used to show/hide the CW text entry field. """ self.app.tootwindow.cw_text.show(not self.app.tootwindow.cw_text._visible) def visPublicCallback(self): """ Sets visibility to public when the Public radio button is clicked. """ self.visibility = "public" def visUnlistedCallback(self): """ Sets visibility to unlisted when the Unlisted radio button is clicked. """ self.visibility = "unlisted" def visFollowersCallback(self): """ Sets visibility to private when the Followers radio button is clicked. """ self.visibility = "private" def visMentionedCallback(self): """ Sets visibility to direct when the Mentioned radio button is clicked. """ self.visibility = "direct" def tootCallback(self): """ Called when the user presses the Toot button, and posts their toot. """ req_data = { "status": self.app.tootwindow.toot.get(), "visibility": self.visibility } if self.replyToID: req_data["in_reply_to_id"] = self.replyToID if self.app.tootwindow.cw.get() == 1: req_data["sensitive"] = "true" req_data["spoiler_text"] = self.app.tootwindow.cw_text.get() path = "/api/v1/statuses" data = handleRequest(self.app, path, req_data, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return if data.get("error_description") is not None: okDialog("Server error when posting toot:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when posting toot:\r\r %s" % data['error']) else: okDialog("Tooted successfully!") self.app.tootwindow.close() # ################ # Helper Functions # ################ def getMaxTootChars(self): """ Gets the maximum allowed number of characters in a toot. Not all instances support this, the default is 500 characters if not present in the response. """ path = "/api/v1/instance" data = handleRequest(self.app, path) if not data: return 500 if data.get("error_description") is not None: dprint("Server error when getting max toot chars: %s" % data["error_description"]) return 500 elif data.get("error") is not None: dprint("Server error when getting max toot chars: %s" % data["error"]) return 500 else: max_toot_chars = data.get("max_toot_chars", 500) dprint("max toot chars: %s" % max_toot_chars) return max_toot_chars \ No newline at end of file diff --git a/third_party/json.py b/third_party/json.py deleted file mode 100755 index d2a1d1b..0000000 --- a/third_party/json.py +++ /dev/null @@ -1 +0,0 @@ -""" JSON Algorithm from: https://github.com/cheery/json-algorithm with modifications by Scott Small to work on MacPython 1.5.2 (mostly removing Unicode support) Original license follows MIT License Copyright (c) 2016 Henri Tuhola Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ import string # generated by build_tables.py program: http://github.com/cheery/json_algorithm states = [ [ 0xffff, 0x0000, 0x801a, 0xffff, 0xffff, 0x8b29, 0xffff, 0xffff, 0x8b28, 0x8b22, 0xffff, 0xffff, 0xffff, 0x810e, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x8009, 0xffff, 0x8001, 0xffff, 0xffff, 0x8005, 0xffff, 0x8212, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0002, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0003, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0004, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, 0x05ff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0006, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0007, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0008, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, 0x06ff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x000a, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x000b, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x000c, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x000d, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, 0x07ff, ], [ 0xffff, 0x000e, 0x801a, 0xffff, 0xffff, 0x8b29, 0xffff, 0xffff, 0x8b28, 0x8b22, 0xffff, 0xffff, 0xffff, 0x810e, 0xffff, 0x0011, 0xffff, 0xffff, 0xffff, 0x8009, 0xffff, 0x8001, 0xffff, 0xffff, 0x8005, 0xffff, 0x8212, 0xffff, ], [ 0xffff, 0x000f, 0xffff, 0xffff, 0x0310, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0311, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0x0010, 0x801a, 0xffff, 0xffff, 0x8b29, 0xffff, 0xffff, 0x8b28, 0x8b22, 0xffff, 0xffff, 0xffff, 0x810e, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x8009, 0xffff, 0x8001, 0xffff, 0xffff, 0x8005, 0xffff, 0x8212, 0xffff, ], [ 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, ], [ 0xffff, 0x0012, 0x801a, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0019, ], [ 0xffff, 0x0013, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0014, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0x0014, 0x801a, 0xffff, 0xffff, 0x8b29, 0xffff, 0xffff, 0x8b28, 0x8b22, 0xffff, 0xffff, 0xffff, 0x810e, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x8009, 0xffff, 0x8001, 0xffff, 0xffff, 0x8005, 0xffff, 0x8212, 0xffff, ], [ 0xffff, 0x0015, 0xffff, 0xffff, 0x0416, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0419, ], [ 0xffff, 0x0016, 0x801a, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0x0017, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0018, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0x0018, 0x801a, 0xffff, 0xffff, 0x8b29, 0xffff, 0xffff, 0x8b28, 0x8b22, 0xffff, 0xffff, 0xffff, 0x810e, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x8009, 0xffff, 0x8001, 0xffff, 0xffff, 0x8005, 0xffff, 0x8212, 0xffff, ], [ 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, 0x00ff, ], [ 0x0b1a, 0x0b1a, 0x0021, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x001b, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, 0x0b1a, ], [ 0xffff, 0xffff, 0x0b1a, 0xffff, 0xffff, 0xffff, 0xffff, 0x0b1a, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0b1a, 0xffff, 0xffff, 0x0d1a, 0xffff, 0x0d1a, 0xffff, 0x0d1a, 0x0d1a, 0xffff, 0x0d1a, 0x801c, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0c1d, 0x0c1d, 0xffff, 0x0c1d, 0x0c1d, 0xffff, 0xffff, 0xffff, 0x0c1d, 0x0c1d, 0x0c1d, 0x0c1d, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0c1e, 0x0c1e, 0xffff, 0x0c1e, 0x0c1e, 0xffff, 0xffff, 0xffff, 0x0c1e, 0x0c1e, 0x0c1e, 0x0c1e, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0c1f, 0x0c1f, 0xffff, 0x0c1f, 0x0c1f, 0xffff, 0xffff, 0xffff, 0x0c1f, 0x0c1f, 0x0c1f, 0x0c1f, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0c20, 0x0c20, 0xffff, 0x0c20, 0x0c20, 0xffff, 0xffff, 0xffff, 0x0c20, 0x0c20, 0x0c20, 0x0c20, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, 0x0eff, ], [ 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, 0x08ff, ], [ 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x0b23, 0x09ff, 0x0b22, 0x0b22, 0x09ff, 0x09ff, 0x0b25, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x0b25, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0b24, 0x0b24, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0b24, 0x0b24, 0x0aff, 0x0aff, 0x0b25, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0b25, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, ], [ 0xffff, 0xffff, 0xffff, 0x0b26, 0xffff, 0x0b26, 0xffff, 0xffff, 0x0b27, 0x0b27, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0b27, 0x0b27, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], [ 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0b27, 0x0b27, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, 0x0aff, ], [ 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x0b23, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x0b25, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x0b25, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, 0x09ff, ], [ 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0x0b28, 0x0b22, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, ], ] gotos = [0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 15, 255, 15, 255, 19, 255, 21, 255, 23, 255, 21, 255, 255, 26, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] catcode = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 3, 4, 5, 6, 7, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 10, 0, 0, 0, 0, 0, 0, 11, 11, 11, 11, 12, 11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 13, 14, 15, 0, 0, 0, 16, 17, 11, 11, 18, 19, 0, 0, 0, 0, 0, 20, 0, 21, 0, 0, 0, 22, 23, 24, 25, 0, 0, 0, 0, 0, 26, 0, 27, 0] def parse(input): stack = [] state = 0x00 ds = [] # data stack ss = [] # string stack es = [] # escape stack for ch in input: cat = catcode[min(ord(ch), 0x7E)] state = parse_ch(cat, ch, stack, state, ds, ss, es) #state = parse_ch(catcode[32], u'', stack, state, ds, ss, es) state = parse_ch(catcode[32], '', stack, state, ds, ss, es) if state != 0x00: raise Exception("JSON decode error: truncated") if len(ds) != 1: raise Exception("JSON decode error: too many objects") return ds.pop() def parse_ch(cat, ch, stack, state, ds, ss, es): while 1: code = states[state][cat] action = code >> 8 & 0xFF code = code & 0xFF if action == 0xFF and code == 0xFF: raise Exception("JSON decode error: syntax") elif action >= 0x80: # shift stack.append(gotos[state]) #action -= 0x80 action = action - 0x80 if action > 0: do_action(action, ch, ds, ss, es) if code == 0xFF: state = stack.pop() else: state = code return state # This action table is unique for every language. # It also depends on which structures you want to # generate. def do_action(action, ch, ds, ss, es): if action == 0x1: # push list ds.append([]) # Push object to ds elif action == 0x2: # push object ds.append({}) elif action == 0x3: # pop & append val = ds.pop() ds[len(ds)-1].append(val) elif action == 0x4: # pop pop & setitem val = ds.pop() key = ds.pop() ds[len(ds)-1][key] = val elif action == 0x5: # push null ds.append(None) elif action == 0x6: # push true ds.append(1) elif action == 0x7: # push false ds.append(0) elif action == 0x8: # push string #val = u"".join(ss) val = string.join(ss, "") ds.append(val) ss[:] = [] # clear ss and es stacks. es[:] = [] elif action == 0x9: #val = int(u"".join(ss)) # push int val = int(string.join(ss, "")) # push int ds.append(val) ss[:] = [] # clear ss stack. elif action == 0xA: #val = float(u"".join(ss)) # push float val = float(string.join(ss, "")) # push float ds.append(val) ss[:] = [] elif action == 0xB: # push ch to ss ss.append(ch) elif action == 0xC: # push ch to es es.append(ch) elif action == 0xD: # push escape #ss.append(unichr(escape_characters[ch])) ss.append(chr(escape_characters[ch])) elif action == 0xE: # push unicode point #ss.append(unichr(int(u"".join(es), 16))) # This is an awful security hole, but we can't # use the `base=` parameter of int() in Python 1.5.2 # so I'm not sure how to solve this without using # the eval() function. ev_str = "int(0x" + string.join(es, "") + ")" try: ss.append(chr(eval(ev_str))) except ValueError: # ignore unicode characters that aren't valid ascii pass es[:] = [] else: # This is very unlikely to happen. But make # a crashpoint here if possible. # Also if you write it in parts, let this line # be the first one you write into this routine. assert 0, "JSON decoder bug" # Non-trivial escape characters. At worst you can # 'switch' or 'if/else' them into do_action -function. escape_characters = {'b': 8, 't': 9, 'n': 10, 'f': 12, 'r': 13} \ No newline at end of file