diff --git a/AuthHandler.py b/AuthHandler.py deleted file mode 100644 index b5e45a2..0000000 --- a/AuthHandler.py +++ /dev/null @@ -1 +0,0 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 ic import string import urllib import W # ########## # My Imports # ########## from MacstodonConstants import INITIAL_TOOTS, VERSION from MacstodonHelpers import dprint, handleRequest, okDialog # ########### # Application # ########### class AuthHandler: def __init__(self, app): """ Initializes the AuthHandler class. """ self.app = app # ######################### # Window Handling Functions # ######################### def getAuthWindow(self, url): """ Defines the OAuth code window """ authwindow = W.Dialog((320, 260), "Macstodon %s - Auth Code" % VERSION) auth_text_1 = "Please log in to Mastodon using your web browser, it should have " \ "been automatically launched.\r\rIf your web browser did not automatically " \ "open, or if you closed it, you can manually copy the auth URL below:" authwindow.auth_label_1 = W.TextBox((10, 8, -10, 96), auth_text_1) authwindow.url = W.EditText((10, 82, -10, 60), url, readonly=1) auth_text_2 = "After logging in through the web, you will be presented with a " \ "code, please copy that code and paste it below, then press OK." authwindow.auth_label_2 = W.TextBox((10, 150, -10, 96), auth_text_2) authwindow.code = W.EditText((10, 192, -10, 30)) authwindow.logout_btn = W.Button((10, -22, 60, 16), "Logout", self.authLogoutCallback) authwindow.ok_btn = W.Button((-69, -22, 60, 16), "OK", self.authTokenCallback) authwindow.setdefaultbutton(authwindow.ok_btn) return authwindow def getLoginWindow(self): """ Defines the login window (prompts for a server URL) """ prefs = self.app.getprefs() loginwindow = W.Dialog((220, 60), "Macstodon %s - Server" % VERSION) loginwindow.sv_label = W.TextBox((0, 8, 110, 16), "Server URL:") loginwindow.server = W.EditText((70, 6, -10, 16), prefs.server or "") loginwindow.login_btn = W.Button((10, -22, 60, 16), "Login", self.loginCallback) loginwindow.quit_btn = W.Button((-69, -22, 60, 16), "Quit", self.app._quit) loginwindow.setdefaultbutton(loginwindow.login_btn) return loginwindow # ################## # Callback Functions # ################## def authLogoutCallback(self): """ Run when the user clicks the "Logout" button from the auth window. Just closes the auth window and reopens the login window. """ self.app.authwindow.close() self.app.loginwindow = self.getLoginWindow() self.app.loginwindow.open() def authTokenCallback(self): """ Run when the user clicks the "OK" button from the auth window. Uses the user's auth code to make a request for a token. """ authcode = self.app.authwindow.code.get() # Raise an error if authcode is blank if string.strip(authcode) == "": okDialog("Please enter the code that was shown in your web browser after you logged in.") return self.app.authwindow.close() prefs = self.app.getprefs() req_data = { "client_id": prefs.client_id, "client_secret": prefs.client_secret, "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "grant_type": "authorization_code", "code": authcode, "scope": "read write follow" } path = "/oauth/token" data = handleRequest(self.app, path, req_data) if not data: # handleRequest failed (and should have popped an error dialog already) self.app.loginwindow = self.getLoginWindow() self.app.loginwindow.open() return token = data.get('access_token') if token: prefs.token = token prefs.save() dprint("token: %s" % prefs.token) self.app.timelinewindow = self.app.timelinehandler.getTimelineWindow() self.app.timelinewindow.open() self.app.timelinehandler.refreshHomeCallback(INITIAL_TOOTS) self.app.timelinehandler.refreshLocalCallback(INITIAL_TOOTS) self.app.timelinehandler.refreshNotificationsCallback(INITIAL_TOOTS) else: # we got a JSON response, but it didn't have the auth token dprint("ACK! Token is missing from the response :(") dprint("This is what came back from the server:") dprint(data) if data.get("error_description") is not None: okDialog("Server error when getting auth token:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when getting auth token:\r\r %s" % data['error']) else: okDialog("Server error when getting auth token.") self.app.loginwindow = self.getLoginWindow() self.app.loginwindow.open() return def loginCallback(self): """ Run when the user clicks the "Login" button from the login window. """ # Raise an error if URL is blank if string.strip(self.app.loginwindow.server.get()) == "": okDialog("Please enter the URL to your Mastodon server, using HTTP instead of HTTPS. This value cannot be blank.") return # Save server to prefs and clear tokens if it has changed prefs = self.app.getprefs() if prefs.server and (self.app.loginwindow.server.get() != prefs.server): dprint("Server has changed, clearing tokens") prefs.token = None prefs.client_id = None prefs.client_secret = None prefs.max_toot_chars = None prefs.server = self.app.loginwindow.server.get() prefs.save() # Close login window self.app.loginwindow.close() # If it's our first time using this server we need to # register our app with it. if prefs.client_id and prefs.client_secret: dprint("Using existing app credentials for this server") else: dprint("First time using this server, creating new app credentials") result = self.createAppCredentials() if not result: self.app.loginwindow = self.getLoginWindow() self.app.loginwindow.open() return dprint("client id: %s" % prefs.client_id) dprint("client secret: %s" % prefs.client_secret) # Do we have a token (is the user logged in?) If not, we need to # log the user in and generate a token if not prefs.token: dprint("Need to generate a user token") self.getAuthCode() else: dprint("Using existing user token for this server") dprint("token: %s" % prefs.token) self.app.timelinewindow = self.app.timelinehandler.getTimelineWindow() self.app.timelinewindow.open() self.app.timelinehandler.refreshHomeCallback(INITIAL_TOOTS) self.app.timelinehandler.refreshLocalCallback(INITIAL_TOOTS) self.app.timelinehandler.refreshNotificationsCallback(INITIAL_TOOTS) # ####################### # OAuth Support Functions # ####################### def createAppCredentials(self): """ Creates credentials on the user's Mastodon instance for this application """ req_data = { "client_name": "Macstodon", "website": "https://github.com/smallsco/macstodon", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow" } path = "/api/v1/apps" data = handleRequest(self.app, path, req_data) if not data: # handleRequest failed and should have popped an error dialog return 0 client_secret = data.get('client_secret') client_id = data.get('client_id') if client_secret and client_id: prefs = self.app.getprefs() prefs.client_id = client_id prefs.client_secret = client_secret prefs.save() return 1 else: # we got a JSON response but it didn't have the client ID or secret dprint("ACK! Client ID and/or Client Secret are missing :(") dprint("This is what came back from the server:") dprint(data) if data.get("error_description") is not None: okDialog("Server error when creating application credentials:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when creating application credentials:\r\r %s" % data['error']) else: okDialog("Server error when creating application credentials.") return 0 def getAuthCode(self): """ Launches the user's web browser with the OAuth token generation URL for their instance. """ prefs = self.app.getprefs() req_data = { "client_id": prefs.client_id, "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "scope": "read write follow", "response_type": "code" } url = "%s/oauth/authorize?%s" % (prefs.server, urllib.urlencode(req_data)) try: ic.launchurl(url) except: # Internet Config is not installed, or, if it is installed, # then a default web browser has not been set. dprint("Missing Internet Config, or no default browser configured") self.app.authwindow = self.getAuthWindow(url) self.app.authwindow.open() \ No newline at end of file diff --git a/AuthWindows.py b/AuthWindows.py new file mode 100755 index 0000000..071c294 --- /dev/null +++ b/AuthWindows.py @@ -0,0 +1 @@ +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 ic import string import urllib import W # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import dprint, handleRequest, okDialog # ########## # AuthWindow # ########## class AuthWindow(W.Dialog): def __init__(self, url): """ Initializes the AuthWindow class. """ W.Dialog.__init__(self, (320, 260), "Macstodon %s - Auth Code" % VERSION) self.setupwidgets(url) # ######################### # Window Handling Functions # ######################### def setupwidgets(self, url): """ Defines the OAuth code window """ auth_text_1 = "Please log in to Mastodon using your web browser, it should have " \ "been automatically launched.\r\rIf your web browser did not automatically " \ "open, or if you closed it, you can manually copy the auth URL below:" self.auth_label_1 = W.TextBox((10, 8, -10, 96), auth_text_1) self.url = W.EditText((10, 82, -10, 60), url, readonly=1) auth_text_2 = "After logging in through the web, you will be presented with a " \ "code, please copy that code and paste it below, then press OK." self.auth_label_2 = W.TextBox((10, 150, -10, 96), auth_text_2) self.code = W.EditText((10, 192, -10, 30)) self.logout_btn = W.Button((10, -22, 60, 16), "Logout", self.authLogoutCallback) self.ok_btn = W.Button((-69, -22, 60, 16), "OK", self.authTokenCallback) self.setdefaultbutton(self.ok_btn) # ################## # Callback Functions # ################## def authLogoutCallback(self): """ Run when the user clicks the "Logout" button from the auth window. Just closes the auth window and reopens the login window. """ self.parent.loginwindow = LoginWindow() self.parent.loginwindow.open() self.close() def authTokenCallback(self): """ Run when the user clicks the "OK" button from the auth window. Uses the user's auth code to make a request for a token. """ authcode = self.code.get() # Raise an error if authcode is blank if string.strip(authcode) == "": okDialog("Please enter the code that was shown in your web browser after you logged in.") return app = self.parent self.close() prefs = app.getprefs() req_data = { "client_id": prefs.client_id, "client_secret": prefs.client_secret, "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "grant_type": "authorization_code", "code": authcode, "scope": "read write follow" } path = "/oauth/token" data = handleRequest(app, path, req_data) if not data: # handleRequest failed (and should have popped an error dialog already) app.loginwindow = LoginWindow() app.loginwindow.open() return token = data.get('access_token') if token: prefs.token = token prefs.save() dprint("token: %s" % prefs.token) app.timelinewindow = app.TimelineWindow() app.timelinewindow.open() else: # we got a JSON response, but it didn't have the auth token dprint("ACK! Token is missing from the response :(") dprint("This is what came back from the server:") dprint(data) if data.get("error_description") is not None: okDialog("Server error when getting auth token:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when getting auth token:\r\r %s" % data['error']) else: okDialog("Server error when getting auth token.") app.loginwindow = LoginWindow() app.loginwindow.open() return # ########### # LoginWindow # ########### class LoginWindow(W.Dialog): def __init__(self): """ Initializes the LoginWindow class. """ W.Dialog.__init__(self, (220, 60), "Macstodon %s - Server" % VERSION) self.setupwidgets() # ######################### # Window Handling Functions # ######################### def setupwidgets(self): """ Defines the login window (prompts for a server URL) """ prefs = self.parent.getprefs() self.sv_label = W.TextBox((0, 8, 110, 16), "Server URL:") self.server = W.EditText((70, 6, -10, 16), prefs.server or "") self.login_btn = W.Button((10, -22, 60, 16), "Login", self.loginCallback) self.quit_btn = W.Button((-69, -22, 60, 16), "Quit", self.parent._quit) self.setdefaultbutton(self.login_btn) # ################## # Callback Functions # ################## def loginCallback(self): """ Run when the user clicks the "Login" button from the login window. """ # Raise an error if URL is blank if string.strip(self.server.get()) == "": okDialog( "Please enter the URL to your Mastodon server, using HTTP instead of HTTPS. This value cannot be blank." ) return # Save server to prefs and clear tokens if it has changed prefs = self.parent.getprefs() if prefs.server and (self.server.get() != prefs.server): dprint("Server has changed, clearing tokens") prefs.token = None prefs.client_id = None prefs.client_secret = None prefs.max_toot_chars = None prefs.server = self.server.get() # Set default prefs if they do not already exist if not prefs.toots_to_load_startup: prefs.toots_to_load_startup = "5" if not prefs.toots_to_load_refresh: prefs.toots_to_load_refresh = "5" if not prefs.toots_per_timeline: prefs.toots_per_timeline = "20" if prefs.show_avatars not in [1, 0]: prefs.show_avatars = 1 if prefs.show_banners not in [1, 0]: prefs.show_banners = 0 # Close login window prefs.save() app = self.parent self.close() # If it's our first time using this server we need to # register our app with it. if prefs.client_id and prefs.client_secret: dprint("Using existing app credentials for this server") else: dprint("First time using this server, creating new app credentials") result = self.createAppCredentials(app) if not result: app.loginwindow = LoginWindow() app.loginwindow.open() return dprint("client id: %s" % prefs.client_id) dprint("client secret: %s" % prefs.client_secret) # Do we have a token (is the user logged in?) If not, we need to # log the user in and generate a token if not prefs.token: dprint("Need to generate a user token") self.getAuthCode(app) else: dprint("Using existing user token for this server") dprint("token: %s" % prefs.token) app.timelinewindow = app.TimelineWindow() app.timelinewindow.open() # ####################### # OAuth Support Functions # ####################### def createAppCredentials(self, app): """ Creates credentials on the user's Mastodon instance for this application """ req_data = { "client_name": "Macstodon", "website": "https://github.com/smallsco/macstodon", "redirect_uris": "urn:ietf:wg:oauth:2.0:oob", "scopes": "read write follow" } path = "/api/v1/apps" data = handleRequest(app, path, req_data) if not data: # handleRequest failed and should have popped an error dialog return 0 client_secret = data.get('client_secret') client_id = data.get('client_id') if client_secret and client_id: prefs = app.getprefs() prefs.client_id = client_id prefs.client_secret = client_secret prefs.save() return 1 else: # we got a JSON response but it didn't have the client ID or secret dprint("ACK! Client ID and/or Client Secret are missing :(") dprint("This is what came back from the server:") dprint(data) if data.get("error_description") is not None: okDialog("Server error when creating application credentials:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when creating application credentials:\r\r %s" % data['error']) else: okDialog("Server error when creating application credentials.") return 0 def getAuthCode(self, app): """ Launches the user's web browser with the OAuth token generation URL for their instance. """ prefs = app.getprefs() req_data = { "client_id": prefs.client_id, "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", "scope": "read write follow", "response_type": "code" } url = "%s/oauth/authorize?%s" % (prefs.server, urllib.urlencode(req_data)) try: ic.launchurl(url) except: # Internet Config is not installed, or, if it is installed, # then a default web browser has not been set. dprint("Missing Internet Config, or no default browser configured") app.authwindow = AuthWindow(url) app.authwindow.open() \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md old mode 100644 new mode 100755 index 815b09e..87dfe08 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ ## CHANGELOG +### v1.0 (2023-03-20) + +* Implemented support for profiles! Click on a user's profile picture to bring up their profile in a new window. +* Implemented support for user interactions, including follow/unfollow, mute/unmute, and block/unblock. +* Implemented support for viewing and modifying user notes. +* Added a "Find User" button to the Timeline window that allows you to search for a user by name and bring up their profile. +* Clicking on "User" notifications that are not connected to a toot (i.e. "User foobar followed you") will bring up that user's profile. +* Refactored the way that Macstodon does window management, this should result in the app using less memory and running faster. +* Added a Preferences window that allows you to customize the number of toots to load into a timeline, enable/disable images and clear the cache. +* More improvements to unicode character conversion, should see much less "junk" characters now. +* Fixed a bug causing unicode character conversion to not take place in the notification list. +* Removed BeautifulSoup and replaced it with a homebrewed solution for link extraction. +* Added a button to the timeline window for displaying the links attached to a toot, and automatically opening them in your browser. +* Added a button to the timeline window for displaying the attachments on a toot, viewing their alt text, and downloading them to your computer. +* Added new icons for the Boost, Favourite, Bookmark, and Reply buttons, courtesy of [C.M. Harrington](https://mastodon.online/@octothorpe)! +* Fixed a bug where the Toot window would remain open if you logged out with it open. +* Added a menu item for logging out. +* Fixed a JSON parsing bug introduced in 0.4.2, Macstodon will no longer rewrite "false", "true", or "null" if used in a toot. +* Improved reliability of getting the toot character limit from the Mastodon instance. +* The cursor will change to a watch when a progress bar window is active. +* Fixed a bug where the CW field when posting a toot was still usable even when it was invisible. +* Replying to a CW'ed toot will add "re: " to the CW (unless it already starts with that). + ### v0.4.4 (2023-02-23) * Fixed an edge case where the app could crash after authenticating. @@ -46,7 +69,7 @@ ### v0.2.1 (2022-11-20) -* Added a beautiful new application icon by [MhzModels](https://mastodon.art/@mhzmodels) +* Added a beautiful new application icon by [MhzModels](https://artsio.com/@mhzmodels) * Replaced a crash-to-console with a friendly, recoverable error message if the connection is lost during an HTTP request. ### v0.2 (2022-11-19) diff --git a/ImageHandler.py b/ImageHandler.py old mode 100644 new mode 100755 index 23dbf20..905ee7e --- a/ImageHandler.py +++ b/ImageHandler.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 Image import GifImagePlugin import JpegImagePlugin import PngImagePlugin import os import string import sys import urllib import urlparse # ################### # Third-Party Imports # ################### from third_party.PixMapWrapper import PixMapWrapper # ########## # My Imports # ########## from MacstodonHelpers import dprint # ########### # Application # ########### class ImageHandler: def __init__(self, app): """ Initializes the ImageHandler class. """ self.app = app def getImageFromURL(self, url, cache=None): """ Wrapper function - given an image URL, either downloads and caches the image, or loads it from the cache if it already exists there. """ try: if cache: file_name = self.getFilenameFromURL(url) if not self.isCached(file_name, cache): self.downloadImage(url, file_name, cache) img = self.resizeAndGetImage(file_name, cache) else: img = self.getImageFromCache(file_name, cache) else: temp_path = self.downloadImage(url) img = self.resizeAndGetImage(temp_path) urllib.urlcleanup() return img except: etype, evalue = sys.exc_info()[:2] dprint("Error loading image: %s: %s" % (etype, evalue)) return None def getFilenameFromURL(self, url): """ Returns the file name, including extension, from a URL. i.e. http://www.google.ca/foo/bar/baz/asdf.jpg returns "asdf.jpg" """ parsed_url = urlparse.urlparse(url) path = parsed_url[2] file_name = os.path.basename(string.replace(path, "/", ":")) return file_name def isCached(self, file_name, cache): """ Checks if an image exists in cache or not and returns true/false. """ if cache == "account": cachepath = self.app.cacheacctfolderpath elif cache == "media": cachepath = self.app.cachemediafolderpath try: os.stat(os.path.join(cachepath, file_name)) return 1 except: exc_info = sys.exc_info() if str(exc_info[0]) == "mac.error" and exc_info[1][0] == 2: return 0 raise def getImageFromCache(self, file_name, cache): """ Loads an image file from the cache. """ pm = PixMapWrapper() if cache == "account": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) pil_image = Image.open(file_path) pm.fromImage(pil_image) del pil_image return pm def downloadImage(self, url, file_name=None, cache=None): """ Downloads an image from the given URL and writes it to the cache. """ http_url = string.replace(url, "https://", "http://") if cache == "account": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) urllib.urlretrieve(http_url, file_path) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) urllib.urlretrieve(http_url, file_path) else: file_path = None dest_path, headers = urllib.urlretrieve(http_url) return dest_path def resizeAndGetImage(self, file_name, cache=None): """ Resizes an image to 48x48 pixels and overwrites the existing cached image. """ if cache == "account": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) else: file_path = file_name pil_image = Image.open(file_path) pil_image_small = pil_image.resize((48, 48)) del pil_image pil_image_small.save(file_path) pm = PixMapWrapper() pm.fromImage(pil_image_small) del pil_image_small return pm \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 Image import GifImagePlugin import JpegImagePlugin import PngImagePlugin import os import string import sys import urllib # ################### # Third-Party Imports # ################### from third_party.PixMapWrapper import PixMapWrapper # ########## # My Imports # ########## from MacstodonHelpers import dprint, getFilenameFromURL # ########### # Application # ########### class ImageHandler: def __init__(self, app): """ Initializes the ImageHandler class. """ self.app = app def getImageFromURL(self, url, cache=None): """ Wrapper function - given an image URL, either downloads and caches the image, or loads it from the cache if it already exists there. """ try: if cache: dprint("Want to cache image: %s" % cache) file_name = getFilenameFromURL(url) if not self.isCached(file_name, cache): dprint("Image is not cached, downloading: %s" % cache) self.downloadImage(url, file_name, cache) dprint("Resizing image: %s" % cache) img = self.resizeAndGetImage(file_name, cache) else: dprint("Image is already cached: %s" % cache) img = self.getImageFromCache(file_name, cache) else: dprint("Do not want to cache image") temp_path = self.downloadImage(url) img = self.resizeAndGetImage(temp_path) urllib.urlcleanup() return img except: etype, evalue = sys.exc_info()[:2] dprint("Error loading image: %s: %s" % (etype, evalue)) return None def isCached(self, file_name, cache): """ Checks if an image exists in cache or not and returns true/false. """ if cache == "account" or cache == "banner": cachepath = self.app.cacheacctfolderpath elif cache == "media": cachepath = self.app.cachemediafolderpath try: os.stat(os.path.join(cachepath, file_name)) return 1 except: exc_info = sys.exc_info() if str(exc_info[0]) == "mac.error" and exc_info[1][0] == 2: return 0 raise def getImageFromCache(self, file_name, cache): """ Loads an image file from the cache. """ pm = PixMapWrapper() if cache == "account" or cache == "banner": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) pil_image = Image.open(file_path) pm.fromImage(pil_image) del pil_image return pm def downloadImage(self, url, file_name=None, cache=None): """ Downloads an image from the given URL and writes it to the cache. """ http_url = string.replace(url, "https://", "http://") if cache == "account" or cache == "banner": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) urllib.urlretrieve(http_url, file_path) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) urllib.urlretrieve(http_url, file_path) else: file_path = None dest_path, headers = urllib.urlretrieve(http_url) return dest_path def resizeAndGetImage(self, file_name, cache=None): """ Resizes an image to an appropriate size and overwrites the existing cached image. """ if cache == "account" or cache == "banner": file_path = os.path.join(self.app.cacheacctfolderpath, file_name) elif cache == "media": file_path = os.path.join(self.app.cachemediafolderpath, file_name) else: file_path = file_name pil_image = Image.open(file_path) pm = PixMapWrapper() if cache == "account": pil_image_small = pil_image.resize((48, 48)) del pil_image pil_image_small.save(file_path) pm.fromImage(pil_image_small) del pil_image_small elif cache == "banner": pil_image_small = pil_image.resize((384, 128)) del pil_image pil_image_small.save(file_path) pm.fromImage(pil_image_small) del pil_image_small else: pm.fromImage(pil_image) del pil_image return pm \ No newline at end of file diff --git a/Macstodon.py b/Macstodon.py old mode 100644 new mode 100755 index cf19fa3..07ef53d --- a/Macstodon.py +++ b/Macstodon.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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: 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 +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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: 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 Qd import Res import W, Wapplication from MacPrefs import kOnSystemDisk # ########## # My Imports # ########## from MacstodonConstants import DEBUG from ImageHandler import ImageHandler from AuthWindows import AuthWindow, LoginWindow from PrefsWindow import PrefsWindow from ProfileWindow import ProfileWindow from TimelineWindow import TimelineWindow from TootWindow import TootWindow # ########### # 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 ) # Preload Image Resources self.pctBkmDis = Qd.GetPicture(128) # bookmark disabled self.pctBkmBnW = Qd.GetPicture(129) # bookmark b&w self.pctBkmClr = Qd.GetPicture(130) # bookmark color self.pctBstDis = Qd.GetPicture(131) # boost disabled self.pctBstBnW = Qd.GetPicture(132) # boost b&w self.pctBstClr = Qd.GetPicture(133) # boost color self.pctFvtDis = Qd.GetPicture(134) # favourite disabled self.pctFvtBnW = Qd.GetPicture(135) # favourite b&w self.pctFvtClr = Qd.GetPicture(136) # favourite color self.pctRplDis = Qd.GetPicture(137) # reply disabled self.pctRplBnW = Qd.GetPicture(138) # reply b&w self.pctRplClr = Qd.GetPicture(139) # reply color self.pctLnkDis = Qd.GetPicture(140) # links disabled self.pctLnkBnW = Qd.GetPicture(141) # links b&w self.pctAtcDis = Qd.GetPicture(142) # attachments disabled self.pctAtcBnW = Qd.GetPicture(143) # attachments b&w # 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.imagehandler = ImageHandler(self) # Prevent circular imports by window classes self.AuthWindow = AuthWindow self.LoginWindow = LoginWindow self.PrefsWindow = PrefsWindow self.ProfileWindow = ProfileWindow self.TimelineWindow = TimelineWindow self.TootWindow = TootWindow # Variable to hold active profile windows, # so we can auto close them when logging out self.profilewindows = {} # Open login window self.loginwindow = LoginWindow() 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") logoutitem = FrameWork.MenuItem(m, "Logout", "L", "logout") 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") FrameWork.Separator(m) prefsitem = FrameWork.MenuItem(m, "Preferences...", "P", "prefs") # 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, prefsitem, logoutitem ] # 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 # If we're in the Python IDE, need to open the RSRC file try: dummy = Res.GetResource('PICT', 128) except Res.Error: resref = Res.OpenResFile("Macstodon.rsrc") Res.UseResFile(resref) Macstodon() \ No newline at end of file diff --git a/Macstodon.rsrc.sit.hqx b/Macstodon.rsrc.sit.hqx old mode 100644 new mode 100755 index f1219af..120ae46 --- 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"$%'!*!%mh06G(9QCNPd)#K M+6%j16FY-6Nj1#""E'&NC'PZ)&0jFh4PEA-X)%PZBbiX)'KdG(!k,bphN!-ZB@a KC'4TER0jFbjMEfd[8h4eCQC*G#m0#KS!"4!!!$%'!*!$FJ!"!*!$FYEV$D@P8Q9 cCA*fC@5PT3#PN!3"!!!q!!$Ibb-2i"e*kJ#3$3jY,`#3$NeKBh0dEf4[ELjbFh* M!!(dR(*cFQ058d9%!3!!3!%"!*!+J!#3#A6k!!!`*!#3"!m!3X(8ba(Lr3-CV!d BSJc[)DSHdMJfajA4qM$EIEHA04+TL$!eZ[MpjCVYLbfM'iHTr"*%ZFH4N5hFfl! +TLP#&[5[LT*Pa+ha`*(SHb%9+jQ[Q[94h&r**AqjCNVDmXq'0U1l8cQ!3b$SFqV #9ke('28'r-(3B@+dE6)`pFS'bm#dI-@!M+@1HJa!D6GZS8)#-lCRl$cdE6rCTd@ j,MJClT,QYCRJC5F%,5#VkN9Zd[kre+EmJrb#NI4c@,Y4le&Hl54'J-@)hqke+`9 imD''YaU03eKI@afNb-jk`5j6+'kKkR8'`J#[%!,U3aTN1UR1D(r[5E*ScF4YpSl f-jEA-UKM&eYALY"mF0!Hr+HJZ2E[2l'p)`AE68q&9YN9Jjrf0M*5TDG!'VR6+,N lE(0hZG9QfT1F0LSjJrJ"A)bTB5jPKQbDK1"KPe!f$`M2ekCC'P%299AC[qqY[84 )Z$L(,4'iTC[#8b6hh8`Yd"VK)#bHeeYG"TTkI05%d,%S@Q31Vff%,HI!4AhefFA E*HJEPJPiA`Y-K$RZ%L*fDp)b#hIS2$N"cF8qI,)!i#`400Db%INBa-,%iaqTk*Y 8Jk%"Y$Bi2mXHSN`Kdh3ZBHpmUkf@PFcbU00APh"b6ZQ0fI`BhUipYP'LrP(i[%G 8J![hI6V0bQ9q84+!(qAQ1QYSN[amS41dPk%lA6l*V9!0r1ap+FQpkirMGP59[+f EVpk2mj@f%p1lr""Ear&*5fjk%Q+@ZJ)*B%qJUJ2B!Q6&'hfPZ6#ZqiJ-@YA`0"c L06LG8N&RThCmdF"S4"Alqe9cSZKG3H45d'h'k3Vqf"MVP&cG'rHa1-%%rSYN&3A Z(D5+I-61a'F9lDY3(XIp&N)3lq6U)6f6GBT$`kiRrV-6QZ3*PmlZIi%,iQk,5[2 cq1qZ[(9!,cirSZNS,a1e+`MAH&QKD!J+I1r$$B)-aV$`Fd9mdIA`SCpFq"k,MSj 1`@kZ2dRk%3Br9#6PY0Ikp0!DIP#5LdJ`K8@AF[`2aV5C&a06*,'arNBpSlGLj,3 C!l)iFK5X)#4q8eI3VM&LNMl6Lb$rcYCSP0kiiL#l"RV@EC[ClRJTMqB!J-J#H,' $#p#LIUIjNG&[`IZ8*S6R(0(8[$$("`LBrSc56TmApQ@#FH5qP"f*Q[Ja!qV3Kjb f3#ZYNI(E4XcGeErL5c`dZR,YCF@R5+MpaAGEcU!QEiB(khP`'E6ma0eFJ1hI!)2 L9CEeC6DQ*L'P)(Ae9jTDXbSL,BVjkcmmB%d&9I0Zp@hTi+,&Tl9l0$GmZfBU%[D KpbTc3+(Y'qJJh1X(`9YAMeameQcX[q[D)"XYDQ3-S&Qa`*,UCk#!kE)S28j"dAB G'J5GMZ"[9K@1661`SHi2e$3jl@P!jI&bl8kdQj*cU0d9FP%YcJ,!#!!1jJ#Dh'J 4i&$CpR$af)bX'1Za[$$-Lf`i0&')hH36ZGGUP$@iMLT83jk(6l,IGJ13!$frTq( YB+Ld[QA`jK`eN9j*[#dB6(rD,1jSN@-'kb0QHc)ASj3S$Y1Tb-jYe'A$H(V9B[N GfYPLNG-CX[i9VK(ahT1X8paHUS*d#mZCq2GcQEUAB(5bMXl53dHMJY@B*)6McM9 dUMm11,`cPP)L4Zp16QLTm3PTHTTrk,0K8j!!DeXD8UC[-CVI26X%F-rIX+@pm2$ mDT35TbE9&0(TihU-SXc2YIFmd,DZE2liAQQV,I&13FV'8A!BGXbCD&Nq5'P69+6 iHEa!1D%DEeR!c135aqJAfCp6k@#cYr9`94-Qjf"UphNY&*`LLaaA9Ai&IJLhq)T q6"`hB6(LK&c'9AdVbh`1pKCAP"+hGGf4`,9'4-*`SGMTh@I39,(5b15KIGr-l2$ 0q)0rHa34ZB+fEc1%`-Z-9F4)lMqJjqRL#B6jV-MBB15#T@lM%&hV5dH,Bi+ZP-m Pr*-0e+K"ZBbTfrf3!-l%!"kBl)F4G1SM`bi-b`CM9)6UNUIUq6pk4R[Jf)'hCr! Nipp0VHL[d'G4f56`H-+pNRalFlaeT+D2qr#Q1N24Fk3h#UBS!G!BHf&'[M2TE)R hJ#M@Q6`&,k4S"Z4+TJ)c!)5aM0r1j6q9'4#Sq%ppa4D5,QQM+-ar-f@9If++@!& b3EiF1ieh8(e$'Z8TXX%hp62F8c!FEd4fZX8e5E&Sb[K`ZTQ@b14T#RUCdJYKM9J baN(A,I'b'NA%!!(,0EfNV*5U)q[N[Q`1EaJVFU!)+846B)frci9Z[2kNalpE`N( ZBGc1cQLS(miJ@JiU4)D8UYA60E"L'AQ'ijAZQb*KkY5ZiQN-PEIBD3ped56bQic ad!$[R1i5XLr6rLC*H-FH$X8NY8Zpb@*XCAbpGb5cKeq3!%mPQQFCF5T1q1G@hm, q(Xke!GhP6,(,ZDe9IMP#G@X*kRlpjM)!M[kH[Ql6+YI0SZR"mEK3J#%#I@%!rlh K([X,mhRAq`B5aAK)bA%ATF%'bY"69"59K2FJ*B",C1ZJ5BGlT29VeQfccpEPJ3* 38F1ZbAZF4)*I1ah6FJlLEAeTTr-JKAfMR'-SRFAbbSQMN!#i9Km'kN86rbHVY#N I%@"3+`j1V+Z2-F@FZIaic`C'b`cCIJ(T`lGa1ekm(K[0+L!(KP&)ZMk"9c5Ek94 +Hc-ZqlrUfElXIicYH1#(P'fj0Eh0&9UL%jE3$0r6#6dCk@24J)@3!*m9(,iD"A+ E1fVNq2E#$CqBb9BkU5*4EIfl9iIFXeY'jTNGcUA%Xe2#@rc`**Z2e2hMaIqSIIe &pdZ)jJDZ#3ThC2Ji3f'A1,Z#a(8KdVY35SVm8q6#hY39$lEb,'qU&Km`(qYm,F5 HEK4&p"AfTS'r2qcRTBG1L(qJJeGi!8e28ZPq`E6Cfpmq1a+Z&AJGAh6))*T0IJb rqkc+hPa'iC18aa8kjH8AFQl0)RrjE9`A*0Bj@r4hZ)ES'TaemJRlDp0j5MTJ(N& 8mi,PmP1L[b$IIcBd'06UKCb!%[L5PkY)YP"G[J#3!0")Q*aXr+(rf0"'+1K0eeZ S&MjL#D2I%UR,$-TcXLFQYXF&fE,%38S8BX%rT4[qX0)DkLpldjrLUl`rmlZZGYk DRCZSR&KB`d5-#!A&Bh3lY4FpmZLip"P1PAG9KJiCkKHN%mef,-kiMh'V1dc$#!M %TdKeC+hcVIii@!b,1Jj!"6-D$Jr#60k9rB2@P+f6J28bPAVH@F)SGCrm%QlYa[f l0JeGfmAGIa*JbeiUj6hAPmmRKIfRKR4[BZQeakUV*b8,IhaTF*hFJ,@P(26M*hV !1JXT20Z`)I[*fAK2IY+j$,SfY,5$pc4i60&Urpm[-4j*MbGHh4iB-)A"[B6"08) 4V8feM&H`PkPM9UJD0!!hZ3cBrrQAZK(YC#A+#f*[2#dGb*i0m"qZ2TkPff!N"-I X"PAlGNH$r&1E*6G'c'hE@N4Gm")LkMR#KEkkT(AE,GFP(aQCQXNGILmR!+LUlPM F(Re$*lYB(ILpK`)4Q+&JMRDIV83NYTjY'ICY16Y$@Y4#aD1bjc"C'`cDdbS`P(f T,9,rpTq3!(Hqe6Cr1'9N%54"YqDYUqMTpaf,KXZ%HXma1hpaR'(c+E%ZPVBU'PH I5YmeZ&(P%V"SJm3Ip1U'eE!Gj44c431d&qj8UJS3ZefiXT@EG,#$ePK+1P8U@ed iBB"Ub6,T4e%fd-IHi3DCd1KRQ5"K"4GDL5ST#Pd'-2UH8Z!A*Ic@PJRV,L"AHjX Z!#9DA6$'e-p,()rU0hhMDEAp9HaaVPKQ!NYZ%HZCJ6Ke@+ZXYTPmXrS`AhmcADV 1@[8'p5(*"E!0#Qe"TP6E&4JKqbZG,T!!AGrq*5+*)@[$bblKac38U&f!Yq9NEB0 e,)($T&XZb[HIF3ILP1#)p"-RBL8JjrB0U'qLGHZ1J[S+`pTl@XpDNPSj%1`AQP$ ,@K"9H)G5$3#(KZ!#M(F@N!"m8NV1HiTC3"*q,"4dh56cBdpR5G6D3GYdkK[BjX+ (Q(T6NU(-Za8(T*NbVElHFi&@+dhSQlZR&9Z-rb@mD4cGA@ML6ma30DH+e(hC`Nl [lEHmaSe)9a@@2kqS4F!-(i8Kd2TT5U+Xm6dkUR(4L"B*p4bUVFTk+A@&cM*qT*F ,pi0DICm11SfhRJ"XP)l@e%8!Khc4jN&@q[*,p)2U'&)e9&0"(kq8E0Z,1IE&6Im 2DU(Z4bKb'pQ'f!NJD6VfTmK#6b8fPUVP4%`'J1CfZK)a)%p2hp&#!Th`,KcY2EF H6b%e"2@5#l+MI!JN218SbNPHUR&%0iiRf5@+N!$qe5p&0ImLdC`3&-,0I((md'` VENQp"&ihM)c!Q!IbSV99a-3[3pP+,[rljqq(L0N4C-#4%(Z4PM+e&)[-jRU3!%# N3!)lYZlX5Z'[Vr(I,M*bN!$0mrajEPRP0[-qp8IXl2c)`+#`cQm*VG8SZBc#,5- rbaT8Cj)h,kkFqBY*3Niq%D-2K,6Ue0FjATk(L55raPq`"**"0RFPrHM0*3DR*%C lEF#F%095Nl,GB&KMV-e3"McV*0mTl$Ae9q[-22JdqJ1a8Hm@bS5GiYUS`E"r(Gd dr5E2(-eE"Rl`VqEA!'*%ZmrZjXClYLRVPFCaqME!@lFFSp,TQYj4K#&Sc*`JA@' KHrQEmHB6$6"8alkJrj8Ur["A%hl'3Gcd@+fTE"HL28EP+`-Vp+qT-TqT2Y3V+H% ,0[pRbQ5)N!#PP[R6mhBBIhN+pZ"@kMQC"#ZUQHKIACL"A16f`9[Ah@2VHRTrFb1 0!BfdII+2ab-8i54X"Yk92#Xafq'49)Pj9l(ekVJkpN6B'j(ajm`BGh6DI$ViLiX A3c1V[10kHL1KfNh864+1bI(+L,b+,SDQ"('8jPI"G@T4SXA*-3P2RF8!Y9B$%5$ aUUeGjXaaNq-T0#3lXCjEH6F[M$'G'bP6-R-jGX['3IlXXK%#*$AeJ-USIM!$Z'2 mbII'9&I4rc`JI-'GIB3l6rGSl#ap-i!VUPcG15,lD&DNLUd,5XT4e@fQp"1-Q+$ LMjQKcY-Hb*9J8PR"YrPAmZU)HTcJhdq!iVC$1MM*L[4ZYdL!hbChN!$[qHKHrPU j(15EmlQQ'H5'Jlar"N!ie2Dj)fYj,Ni`JbaIakRpq@r8fX@Rdq%$kea@PBrCI+8 bS)Z'iP#,I&pbM4Ka%T6'R,-@IPV&e+,l[E1CFed%&SmbYJp,$%f4VLI"D9S[5$M i+L"4Glk'YIX1dQX'F8M4(d!+ASaiHCe-KYUHEbJ8D@+#1mA&EMXaaM%@PafRX", 3@H`h[YX0aVbd*S1[p&(l["hM*QKAYJ$[m$pPAa+A"fHNHfb&45fL*1-`CKhKLY- #T,kd*aKUC-X0,V,hi0VB4J-[K'M%#F6flI*m`Bm@qJ%NF$HULHkk42RV@,RKhCq *2ifY#d3LDbdraqXbZ!eDFr+[XI8(V1`lAqPQ$3pIDZ'QNb%m*qA5662lM@i9,%h lB21$`!GK0Xc`UKdUk&"46S,-'d!(3fa0cIr843$RIbEK9fBSNL#``#'d%j!!IdK Yk+9Mrk8Ubk[i1*jD!0i68)acCcrHmpLa$f#H9lN,X5ER3"eV%D%f[IS93NUdIMI pXFrD3E[41(EC!-Ec#1$Z[@)Pj(pq3LY6QUm(mIcB&GC0U+#%HBkRZPD[P(a*H3` Gi'aP4A"`p4T(cP[Xkf0`jHXpZMRQdSe0&lk+#d,KecieqlcAUGieVL&KG2U#PS' ,)l6@kUkH&YDm&UFFiX'$84EbbV#4!DNPreRQ!YqICB4-hJ-(Y`30@e#j2h)&"&M Hhl)b!+K6j%H2cd$18iUk1HMT*2GC,)A$S1TXf9!cel"9,&flJ$02bm4R94cc2JT RTQX3aR!k$p)+e4hJ15NcH66aJ'#XjE@$A0)Zq2-bq,Ga&aJ4'4hVZN93@4FT`$Q SC-3[*"904QX4k98MhXM1hijp&N5XS5d$IZa,ImFPF##h&(L,#eb4S"*ZEbi",`k 4&LiH`(l)(JHBNP$'GMhkp'A,153"bDSUb2bP*1NC$Le(8,)Yc5CRUQXEXhR,cJX B-B2P&ZJE,l5BLMMe+`Em6ajrX+E6e'#)k)%k)apNN`l8*[P,U#1P&pS4K02SkC6 T$iQr[`l[rhB2RM&@Bj-$B,CL@+`Apd)ED-VLE$JkNPLkqcF4VrI6R+JV`b8T,DR @ph'Y-bCAR2GIpF!kGhJbFqUKYLr2%&XU%qik"JGehM5BZ'K2m48!ldIR1rl6D55 RQ#+q6KP3'EfG,G!L'lXTh3[%Gh6TGE!DcDV(H5fdAC[5dNS`&C*V#T-DRC@Fd&8 p"b5ir5'HV!JND0'96KkRhlVC(l((BB*6A&b!)ALi1B"Al0LL8,h8k,%0elmYfJJ rk1YChm&G9*Q"+QQ0RU2l2!c#NI'b3e[c&bZ"#9i32N[6@jEp%&-Yjl(EB"S&lEN @`BfZd!65C+3%PK,XG8ik3&BT-CM,'9h[ja@V'I'p*H%[[,&+diKM8`,CHA"4c`K F+966VKhh(Xpm,(GeqPGL0ije*+cS)e`8N!#M6CA56Je2rE6@k!kl+$IG2efH)$p +j"CHdqjPK[!cLPZHr1lc2X'qpRSj3S@`1XHhAFDdKVGd#bAj`-VaX5Ak*Lm!I5i E'[Lre,LFMaTL[l4emjr+-K%UDJbcb%EDpURl4kN2,K+aAh8Sl%e*+PKb**2YEp, Z3GV-T''#K0'EQJk`P1PJ,F#q9GICcjRihh4q$3#G(UT1eR+Z4@+MSl`GpS*XiK! k0IDTMiBEAND-E05a5(ZSd(A[N!$a'3m(LX`!k#fJGP1CRZla8qNrm*q-3hLC6)l Bb$*8F%X(DU@2H@)8ABdpB-5p4c5+"E,SCD1eL1$D0PeA3DUP&q(%%#01JQATU(5 m&@XQICZee#A*(2`*PS`c45PLBE9AjeXjX#pBcNYUAS14NR`Hd$U2CrhTT!8Db`r @bB%G*URCG-LEhY,A215"(UqE*3)YJ+HT8mG9%hVZqb#rP9LkHr!1r[0@[9j8*%C "erBh[+kMdAA5XFM)bFKib!dRGjBEjQ3A@'3"hS-@YE,P+PkGkmBGkd22#[L%IkV C0TlSUTaYe&mDSXiEEq1*&YMYM4[,EB%d%Up+*)a)`[K$peqbrU!r5I,)Yhe*#JP @'-l@U@&IISq1+a,0P*DCG653!(3Z@SL$Q-hMiK,G5-Al`(dqc)6qVl4fh0'%dR1 #afe(qT8+*V+j(#QI19"%!G)CFSX48iFSfm+VNGj&4P(mQT,e!j!!`P*2L@8I[d! I[#N[pkkk-FJ"q0k64alANHP(iGFJ+)I(Z41M%D!%c[13!"kGLhlQ&mXGTQM0RUM R#qem(BGV!fbmLb5XdeErDQmB-DPLJ36H48!@f**J9LHV$V5kH[k6++aJ[8kKE"% *F@92$$E6l1K,aa,E3rVlULK-)B5H&,+LCp0A-K4ZPMQ$*l2m1mF8B)UFU6G#mch h$Xcf`FUZ%9TMjN["r2kD&rj&P9LQM"C$REj`!Hi*5MiZhDBll9Q`hRDffrpV"NK %L[CkIjH&[[U#I*L!m`$@`q'Pj9LEUY9BiEAfma18+qHS["31'V5mT#8+8f!H2p- X+V,V)(*C*N#"8&rhfb5'4P#PPh&l%F[(aBKq9-(!XjHeYRKJM1l-hIC'qTh-c+0 CkN9NX"UhJ4f8@$IlqVafVdf1#DqL1l+e856e3Gk6i2*mej6'4!qFH8`KTVe)Y9H H@qD2FqUd@AUaYL*Rmqk++$BIT#NN2@N$l3PGKcpF-kl!"Tf'*@ff$Sp5Z[GPEei ET$KjQFLpE"!QlfK&E5"399fjq1qr9-4Pq1iCEb)iX(2)'dAcQ%$)m(EY'jBN*2d ([k9%PqZ-rChlUifX43crf8ak6QhiEFl8&Z5#ccRP4$IdH'(r2jC&e*!!DSGp62K "5R$ffK"[K5&b2Z@QL1Kp`ZeiT!R[h1P,c%,GI1MiXD1JLVqTDa24rII@J)YY-!- SHljGR*Jpe[h`YRAT-2KHN8qechqP5b(K1)I,qN6YJhMiCj-I$c`NDd5l'j`F#a! JApb&+G5Zq'$2@qMRJ6)e!XA'3DMCqI$r*rR`&h-!BH5qrGpGMpq%p4AXhNd$mQj fj!GKTJm$B+S5$Gk@!*U#pcp&K+MVUEK0GcRR'UKKPj%2e2KDmi,iXMBeAKH4@p@ e3K'NaBN0EN*eq!eR0I(qUlK$EaYY59P0JdbDQEFj0E%UUjU2XI-'-LK%kIp,M8q k!ZELF"R`hM"iTh('@(1L@d9deC33$8"(@#6L[k'LGDPB[Z,8Kq,J[Z!Q)"MbfZk @A0[LA&Ei48@8+j52E)N2&@A`UrmShi`QadjIfeTL[Nm-34E1NkH8B&cAe4Y)X!0 c2!($4EDPeQPkrE5B&`Q$6F2NQLprD,Hp#'mT$m9S%,(i&iR9[bkckmL5Y2c)G3r #'PimrHJJHapJjM)GZIP+`@-dfmPadPc)Z'm-@@ZeGJZ"DH`'kqrbhc8I6Uq3!%d 5%XRe*PlJXMjpHY'@([[H18EFE&3)8"HF(GBkTBD89C(-eJESdlLUc6`*2L(!0[T XX!LAjFcDrAie@+V"[1J"h&B69eQm[ZHZm%[e4QHI$%"JSC!!VhaS1T+hR"$8BEC m3Q)kAY'iBl+HZ'GcmLkj-UF2pRPp"(NBM@6!0R0B#l@iSVekf24dAJ"NIl[93Ql +0j!!0mG3E"-(i2(IM-AZdfh39r,#+fT8'l)Be`&+N!#C&ZA#ar-+"[E)PY&$j`, dN!!3VYRfCamdVLQUk$%Jr8Fp%KUej@GIec5[#DTfJe'lP*!!$b69JPIm0-8V0DU cIZ581je)IMLBLJ)9pFZXQk3-ei*Bf`d0IFEZN!#&(I@dq%DPV`$1!fZAl%Hjlp* #&BC,eZi4AS23,!S'Yd!DL+dqZc,R@alCT6Y"cZDKrhISGS+VD#Dp%f(LJmem06* ka"jQiB9ak`jG)pL,mPLkJV+ZpV2R"KBAI3%Mqa#LPh3GlCmTmRrmIcY1@3+Cl4H Uih"4FTQa2DI%DE1f84X'pbT"NMLm$5-!@`FZUZq0)'I++pXMB2a!"A@fG@pXT,+ NFGF9i)"NY`6e3e1'1C!!qLBqVc9T1+&I43%FSRj,BVEE+KI9Z"IRVdk0F`$`b$1 JN!!%J2qKDc')I)b#(Z@iK!!r3BYk4q@iHIX04ZGa[)D0YDXp+ZeaCpcQd5KfG!j jk6ESdf$BCYUQG5@8rVIp$4F21a'l@[Eq%BEpRPh'9DikEDhEL8FIk3-(HZ3c%MC 8-r@Xb8haB5rL4)p'+daqiYH)J"DNffRrm-F*JhbN8Gb5I'14$3"5Qb'JU-Kd3!B FJ&Bdp*dJJF4AZBEYRI8)P)jk+#38hf3`-IDSVB!MHXY)ZjdPX,G'Tl!T(iDRHJ% TFGY#(!!'QmAJ!HA@0IE6G+UIMd'Fh4J8CJS%IE6Fl@#3!*J8'*bAhriX"F#h*GZ LTq1XVIVjk1ZLfL@+Fk(rJ'ZU')TMVJ6S33VCZkIHN!#NEXTM'Zaec-,hR-M@ECd BGK+5%"eDrKE9fA&lL4(qXMMkj6biaiBBKb5,PMHYXC!!T14jfN9hKY-fjk-T3l) rl,"#ekDdd,ll%8#hD+L-c4Lbf8*2'4[QI%!N6SArl4Kk[j0CkA5eNVX9**mI`I, h0&Fqkmc`(-J4XB+rVRJT'p6ZL$+1(@S&r35e5f@B-q86AjmapK8'`kakLEMQB8j +JNrQlM(64,+h`*HJP3-C22i(4)5#`&)Md58HS$hGZk96h5EVZ1bRQFP)"5abS+R irEYq!BL5Y#XQldZ66eFM%8DK[Pr!kPE'f$QZV!ff$Ha#Z8GAqKCR)b[cic"XlAL ZE11UL@Z1HXpb(K3iS,(EdIV@M9!F0N$KUMaLBbB`1([[V(6'I+iF%8+-jGdU&$p %qmd8VGVdK8Rc5PqHLVK%)iJ+3HqPGdEKS210Af$f&T!!AD2P1&R("jRbSir`ch" F@hZ5E9Z@Nmr[b(DpYH+XPL6aG)HJme0iM1(J3"aBFi%qMBmK91V(6N44Ir-c2bQ VfFe$jXJTCpRa%5E$ES"fMBpGP"!pfBk&C0pIirXbKV[PAeJDIKBpipaQeYI'&r! $i#5TRjh83'G`Mpb%"i4XfMT8Gaf4dUc210pB+'E@4$KmI*DLK!%*2U01Ph&X+B9 IN!"lV'0QdQ`2eYd1RjaPh40DG6X*k3IJdFk,5q!0baGC4%Q[[c8iLEh8aR1V"+$ XM5Z@Z0bN06UNFdAFGI+$EBU&"YICqV0hRqFG)&mhJmmj-1Pk2ma+4(`l2HadNH4 ,36'2,14U,3aS&!+e"6SYBX6P6DI16Y8i0$+6A6YPHZ(SQ&bH,RIP`+CM3B+H"3R Jlp!-Pj2PLE`ZTr9%MDk63IUXD&&i@qjR!el&Z+M#p[UmX!bcXckJ5FSLj9TQchi ,-PmK6ZlDEdCR5@Kj&Gl*eePTGajGeA$T'`B6Fl)JlRdF@D'MErI,lq2r6k#Li5c 4#cV(mAH16b01kL(l0mVKd--dS)dp)+#aX!I2#5QPdEXaEYV'S$%MD02K(m(AbZ1 F8&)#4`,ka9pcL[qdd!MbYL)NSe8e*4e(#e'*2j6e6qaYMhmac2eD%1M)e@rD!8X h5M)B-RkHd'fpF5mH[km6R%m8FEhS9XTTjEr45)%rXN+8NTFYi[Zk!f`M,r1EPdX 3b0JJ`a@2UPe%E!EkL8pd#GUk5bN%8UVha$dX8C6r6,cC16VNb-@Ul-%aM5UQYEk r'KN,0mm,-21#,LHMJBlMVPSLD!-dZY!)Y)T6@[B%LMI-9mc)VUCpm3ZEl[#@d[* bNUB4h83GQUB#6,jVR9k'6hSB+I4'*5NTrNKjZZT@k)XLZSl80eC`4*F2G)$L3mA 1l'9L)plZR$*leh*(PJ[4hjSI1[k@I,@aljGmF62c6ISiKq0kd$Jd$Ba@R4#'dck XMHTY,DC5`l19@%9R@JhCQUZ$Kj53!#B5+pN[1lGXH*),6T8lSEZ%8qcRSq#MSa& LK4CFH#QX1h0ZeJcX&Gp$)h%$3M4fc9!@6"[2h9Xe3I9YqPp)P2i4Yp4E6()C*T, pSZ[!V@C(XB$a06!#B*2EUEr-ShZC@E)Y5iI-8LhdK"frC(GNRFDLNHD4+hrB43% cUpeCp9fLlpcV0!V%R5i#3V&j2XS'Zi"5H*R)#k2Prle)hR-Le#lN9Vh1KEH4r%h d#55TdhQMK`[@22NaSD'f%eVJ(Y$k*!#kP@qL!03UZ0iU''UDEphEjb'aIqPi5#1 ,+'e#bJIErkXXKDa0$DE1Ai,idi%6U+BG,,rV5m)US$FrHNQHGQ,0Tlir%&kcI-$ p@a2hPXN-Jb%%ri2NQCr#CR!)K@3iP%!mp4aD('FrDda5-@*'4)VK4f#0(jd2Q*b QGZ*5L*!!0&`9HZ49#1cL`4-kFe)bhe%ID[IIXXiV#!H![Il"eX(rRVXr`Tjj9Q1 &#rj43Uf3!#J,m[(!km[5a2&'Z3`QXHF+B9-8mT@V66@HCm3Q$"fXjhG+PMqKh-$ "Dm0faem,#3f"C@jGYpe9Vj'"A#a"c8a,-`qk"EU@cZTp2SSN`f-`3'Eql&fQ6G* ,E*aA`C&eiX2R13#88YcaS'65S'L8eaTI49NEm$D5La1E2M08`h1N83C#p@)3P-c M)*(EM1'Jf0j(li3P9)QrfHD'mYTqU#IXi9cH'P($mab`UK3aIh[Y940F)H,E-'H BE4fTR3KHLq-(0aRX,"(Nl#B&fk0AHZ)VB928pcb6hj)[pX*'eX86hRAH62UjL)N LDq'BQhZ5fmP8!50&!!!cjHekS%-V$UM"Idf-'+QA%-$12)4!F%,mRJ0R)RN!C`M fA42eE!6NlmIA-lUrA(#!lKA&-GaJGbEYAH$#@%-PXqE6SpXS5f1!h6SMmT(kTj8 q2IBmDhM`NP*cCdAPmPEF8Qe2+(P2I51!KPYSXJilVY#C$YZS1"ihU`6AZ0HEDq% F9d8rR&b3!#b+JTM1ACM`q#`IH8UJ1[ar)l!QS0"EX(U*bj0a[$b"&5I3dKKRXV5 &@I+N"PX`X1lcblC*$V$RIfHkZSN$iHrkJ-kU5%[e8Pq)k%*XDH(0Ark8S[FEjMe e*l+U!Zm,JBH"Afprb566fIcL`X4aAT+3!(PeXdqi`qcDa*&bqAbAcS3q1e9VQV2 +[2U`ZASEIXE+4C%TE3U2ZId(Ak8HBd!!ECjE%L(@L4X`T`KLmdcUbZ##-Vhe`'" PK!C!9r(+Y&"+PFP[XfjBm0[I-(0[XL*Hj"a@PBXj"KDfH(fcma'JANINcDA@9MG ,hhZp5Ge+C%RKbTFM,Lc([56`Ka`lb6KM`!YeMpLh2c6[%eB12fb9E[rR8M5j-@I RYI-MXJ69GpqHi9-[PK#Q%H,[SLGB"[UZ(BCZ,bq2YI2%`AaBHl$@3(@[FYTFc@R T5A,T5)I5Y68P"f#4-Ea'dY-kj+'5r[)MEbE4aIS2+P1mj1krS&+!CFVPE`qI3e4 ccfDM(ZCYbR4Ue333I8@HAmP*CFIIhDE#L*,6k"#6l9jS@!R@r4D&j-q1UrG@JT( *[@G`mjm[qjAeZ&bmAGbj&8L4KHi9IlZ3!'@Z8T0ZkXZ+jqhNDe"dY'9XQh8F9Mr a+9RY16kec(-Z#dhC8pFd9FF8%2kmrLVb-la`PAFqKI',4%hA[(#mmbmrrSC`l$T m$Y!`#cYU#f-1!8(6BCYqNb2E*001NkherEMBBMH5b0LSfR(h02!"%G!FM-0eNGK "m'ha)9lJV%N`e1d#IBN,ci63Nrl*6U8K-dpED4Lb)Td5jlR,CmdTNk&PQ[h'Z+3 N1'AahC0rr"Y*U"QK@q+Zq[fl1$P*%&JF%QGdpliMp5rqM'CI&%8#S9mR9hS9f(q +MZCbDa*9pT!!R[PVC0-b`KVjRkf"62NTfG1'Ee45J0iSPcp1HP"I'8eH*(dDPEF 28-XUA421MG9-4V+)CeQG!VVDCjaqeXC0Cc-mp-I46I`XBc6qmB"D4$p+1eYL+mr J*,$maU&X#ci2"LrGFiU!,i1$FF&+QA(BPkR`Q'fX0U**9-"pPbmrU'AHEc8CRd9 #UE4`NRmA(kJYmhVUSr$%1MU)&CH!X64rY*jE1XQkQS%Z0S4r%ad!-m&8&SEcHUb h@f5Pf-`$ZG9H&((#mbR8JqqN6amlPjP#bSpS9e0a!J4C&'`dTPFcBFI+2Qp`'(X l5)J[EPL0[d1df,6k3&0I9c@F$INUf*fcQ55HRcqY@0DZIKXK16`$pCBML#qL[kb *H,brGbLpqGHM8*iR8+1l[8)qAKSP[&*hQ@5AT9C*JS3H4fK9P+&r'fS"&ClXI4b pi#S1lDTN[+cY'PSU0Z[p-YY[T-9EhMY0I"FX-eq2U%-)&6i'!(8"5#m+bSDKbEP !QSH`r4QhZ[Jj@CbYK4pF[,Xf1lVaY48mB&4CQI[*A2G&KLf#49HU!9ISGrf$C5, 8UV+3!)ca[(EPZM5aZ2mfR`(q,&"9Ppr8ahJ4kVC00"AmcTZjV55Jar9VH(YcqQm 3bjS2iQM"+3dfkVlL,kV&QV`h#[r++i&Z$3iVc54TTCmAFS6el"V6`[1$N!$A##Z XdQMTZ[9I,@@IV-SLLmUK91ZDc!peXd(3JkfM5[22#)9"0@A[V9pm3KVIX`k9S*P iqa$j(ZqT#"K(3Kdc%)pq`Ac&HqAr,rQD#F#VpLrFT90mdHlbP%h@pH0q`0DLj0R FaB)1+hS-e)&+)*,B-+Aae'#ZrJHBrA,lY-JFjGaM'QAAMEMr4kc-@`h6crNBE0+ @Z"JC$5!-b,G#4h+[V(9q`K6pVMPlqU9%Q@i#6R4qf23c1F6,84PJ,B*)BZHhdrd XCihd,ZF#FJ4+QVKNVI@KfJDJC&e82pbqTfG8R%[+kKZV$bKT+2jU2,6m%c[1IiP +pN2FL!VUf!XNA-VTL@E*BiApL3Ia@'Z[),(BQD''liN`*UGP!Gh*AkdmL-2&+IT E"%aEd(DS`5kh810(k+3`EBr(lU(i3'c$dqrch$l9UhMk9Cm[e$,`!Ub'd+Y@Hrp (qeYCD$dJKXi$HUX[mdJLb)CrT%QI0AVMQHCNT1P`N!#RLeUXc@GZ")A)q&BK9#i BS1jSfq#a(,G9#YcSERHP@#'mj"5FST3kipDN6#YTlaf-0`[`l&XaRH4+3FN&L,# !K#(khfQ*0eYMfb181QlT,!M,c2$2Ue24RU#YAT(kU"MURQlbcGD#0X#Tce0mFA, *kh33r@63V%VblH1+kN16K6U@[fD5E%8N%MqhUp-a,kKAJ`q6KE,1A(JAG`(5R)h 'IHjEL$-iTXc9M+Njb*C9,Kj&1#E'jJi"ar84C4#66GIS+d!p&D#Q$lSAp'B#&CC r'S`85qUm,@(+h3dX5%p0D'PI8H+*c)H)(a%$k[CMpjeb!Paa$P$N[ZAF,`5%T[5 $K-J5NpdQEGXZaCa`Kh5BRSLE'1@8+Ef'92T19Fr!BTe+r8f&AXi%mlB,9Y*,0bM LSR"3r*M@)*Rl`!YTG#UKPI8,4c'XmIA-HU,LEMZR4'QRJ`dI)D52Q'XC94CMS*e NC[[&%AUMN9#(5RCJ%IVqrZ6cU+%5kV0m0iBGaiXZU!*6U)V3FPTEK)'rV)bpb'5 ZHQF,M+4TTF9[#X*%mjE5pRPF1MfKIb0Z[mF&X`PFMePrfAhN3cFh'P))M3U('05 H6jE+@S+IHQFX+(%cTr!,eDG5ClBU+C!!6D$iE5I9pFUCp*P0TZ[3ZA+N$f4IJBa H'E"J(*0VfVp+jESBK*fCC[C(U8KPaK,m(eDj(!V"#&pYN5I0a32LQ,4T"[b@)L1 HBe'@FfheXpVq$2##4a@I@D'%BREkbCa$4[[M![KHB3@595ib8ZX`*+cc&%$l6j8 T9-T'+IFbTP@"eDqiiZk9iJhheX@J`FIMBqL(QB!cDVqMiq,'HBh+,E)ZP!3#j(- cr+8+Y*qrjL@6GLp*GBVV8M(!V[kLc`9&qe423)Sr`Ba@D88hL1-i'%@(jq@1rDl lH-+"@'*LidLKafiD+*R$K)EE3m@0-r,(9#De0rb4PJLS'Nc12L[q4YjNh@96f5Q Qd5HAiTY&N6il"'URV"BY2r5M)@5lj'YE8aS3-f'd9ik4@h1b)PVM(5jT6F-3ka[ C#(9&')V+CQVMf,e[+-+!ePCi0TIHM&60'*[h1NNm-QjZ!!YDH3r$DAl%[fAL0@b 8HfQ#aGi3T,82PqHGf"%P(YILIqJU-)e!-P985RX3!*R%6RH-Fq2A!EKpM+X%C)! bHDSZ8cZB5V1!&LFfZkUN44)D"!SMiIU+6`%!G8Q%m-*B*jJA[1`cN3TEe$1C`[0 RXKiGX!J,YT!!kq'hUCf%Lq4)p(HTc!T,,d51(KN#8[PeP"akf6#%+qdF54V8AGF a)0&R#8ie'!4dfDjG$)4bRF'c`4'iCJ3cJ81YS!SAB(I)f-ZD,GR@[QQ(KeaX4J+ ke8C'F-m$R#Acd!bT"f4(L6Aahf*-pJ$#D3`ThLFkGc*6K+RV#aQj,(bc%h!L$P5 [qAbTIH,p1MX0D"05"f-0UHMJK)2-'NE+%PIYkh5QN!$@rRIe2,$2lZjXd5SX+"9 DBc5QUpfUL@2IhQh4*411T`P"*kb66eY0Q[M'US#`5Qh[5l23PQJh+V-'M5b+lj@ Njh"!M2Q)!jDAe%DkXRF@9l0','q&[[rp+&Her@K#ZbbreICkSFh1f0aD(j!!,j6 LI&"BUhKXQA8m0`jA&e%mhESdhjbG[d2HHZVQr5iG3am'fS(`!8cC5LfbVe3j'NT SV20YM$Lc1d*3*6JZ)(LX&r"PJm($G2N#[@Y)EXSA(cEA5p@#iR1"0-imU`rSQF' hZ&G"0I!9[rV@J`fk'Dp"kj,Ub)#VLlJKk3E5(99f0iGR,jS5NBlR4Yf-qm0VFAB m5d"5BF,VA$pH!m(c3f8A*!*lQ"(!l6k2M#3QR9ccBb`cqC1N"dTB-%AEpckLMJT L!'VD0-(['E[c[*eS!&GY$ffM32T[Yj@6-FZ-05&NbZl8&#)9%'1*5AcchTek1)S ,06QK!1RMp2)eQf22@83qm%daaHE,cjq5icACl0HP4VhXNF'1R2$-K$%6r6S,LXM 1j+mSlZSUN!#pRU`XiG&4%[T2@Xk+mADlcbR%%YGSe2qM48cH9Af!EK&lIECAYq+ ZhmF[&S6@+',jU@)rf6P5GmbbLaZ2A,@4'Xl[,4dM(K2E1PR,QmUaPH1+8CLJ"3k !3a$!S&ShFM-#'#F`ML)+`%V0K[RFC8Z3!$K%&'JPIR&ZAR(RSImc!U")"bXEV$S "QHeElrrj-F%F*%SFNL@$3M6qLi,MMbL-08ZaJM`KI,8#lkm-@Eq8H+),Ar8HT8q [LeT(jm9Z3-EYQ#XBe(&![P&,B&*p81[P2M(3H4HR136r)i+dBfN63YGSY"2Ei1F @jcLR#!HcV$i&kPf$`)*@jYF1k'L2D'fHpj[6BBV3q`+*(-EUVM)e4YZmc`ab@KY kPb"hFfSC&Gqk"'YXE,%[Yikb$`N,kAZrZL0G2QNc-e9m&8`IR$'&DDcF6PTf4Yk %PV[1a)`-M+iF`h2rk(Q(,Ka()fJN(iaVc@0(!+`H!!!: \ No newline at end of file +(This file must be converted with BinHex 4.0) :%NeKBh0dEf4[ELjbFh*M,R0TG!"6593e8dP8)3#3"$Sl!*!%(896G(9QCNPd)#K M+6%j16FY-6Nj1#""E'&NC'PZ)&0jFh4PEA-X)%PZBbiX)'KdG(!k,bphN!-ZB@a KC'4TER0jFbjMEfd[8h4eCQC*G#m0#KS!"4!!!$Sl!*!$FJ!"!*!$FVJ%$D@P8Q9 cCA*fC@5PT3#PN!3"!!!q!!$J2JrCi$iVM!#3$3i'FJ#3$NeKBh0dEf4[ELjbFh* M!!((CA*cFQ058d9%!3$rN!3!N!U!!*!*U[3!!$PCGGd!!!d!$J$VG6Rf@*jGCNI GCQbcc5bDr,hb[#jk[GPJkb!rZ[eH"lDbPjrRErTpel%G1lPE@TrAfGH1pqbfCTG (0pZdFZ%Gp2M#5f1hVq2R&R+ifA@jF+r,-mVp*ZjNfEYH%mm$Z[VkVZXJAjadh6i #lFJYAH4"M`cJ5hj*`ME%*r$)3IL8mLQPlMQXe$I`6Xr*4RC&E9eiD@eiaC)QCZI 900D[@,&i8@06*V)&-aI90$AA,kQ[+feXDUbCk8F0%h2R6DRd+GIYcc*riZRi81% c1V2Y@2fe69P)e0BYV@m1Ap[FP)(NT&A0cI9e6@NFE&@GV!$aBPf9CkV#+m)ecE9 p"DC$1JS@epBYN86$UUEP6IP)V!cAV9U`V,kjRVZ9QCU@ebjYAK&'jUr`ij1,Ap$ [jLRA3ADDHGY+2Mk9iq9[@mm"%l9m%Mhpk%9S**kTIc4IkJ39`)9[%al%U5jf&Kf [ahX,hVIMhBVh&e$`QB8kCTjeV'FIiZbN#AYFYq(rhf2bfEDY6Ce9Z9"P"pFVIjA CB5MCJ([m-(kQHp#AA09YCXB6ddQFTI*8J5T8KHX,Gq#0A"jUrRNFXif1G6[+eaG M')d4MM-G2cij+LFa$T*f(+6HL30UU"UZcP$&q*f"c&"8r2-`CMFleUd[ac!N30Q -rl'&"8TG8MQP5KJ"cm3HpmLX6mck"$M$2kYDU6'j)qT89PQT'VDXi*UV2P+aE1l 9PmqYZ(Vd`T`aZE-H8SX[rrc#D@0bbdY9(Yi&BdT9!1p"aD9U++U[R2fk'VK`j+r VIAZN2SZ6q%GIr[N4#fCG&9U!QN'cpRaQjD,D1Zb$1jKeeDa90mqmMQZDAPIE,&A Z`p0H+5ll8+NDL2hNR&@RLLE9ZBq[9BJHQeERaYUD3h2(M&4CE8f6lQhE0kTic)# f6il*EIX8DJqhhGlfGH6Z3ZlTYR%M5pfR'`)GGiG+h@FE!`HfR&hKrZ6-8RGrFq$ 3R@G2G*rFmjNp,DKj*$4Mb`GQe+P"bj4km2c-K30$Fd2,eCc3R,`bP9qQPZA0R3b 8$0ScpIl*GBI+dQkEHR'&qrK&U(KZkUE*mkSDI8p1[RLLqeKcqP2A+(qjIq`T6e@ @qpZqT+FB%bM2,#pe(fqlB8C9r9N0kA[B3j@GXUGb6jflUe`Pqj5kMj9RY0f!h6d mlC0UEGY5CYSfPjFSIeQTZfY2aCjjFb[B%48CI49I`!C(c5K9@FZ'MFN"'JH9C`+ ,"@0bJFBXS(85d6LQ&0KT!PUZ`rXC3FYA,RNKVbZ*`FV'4@Z8'KJijrK$9"iHNf[ 3"d5'jL331')"%2G*c*Z6`'$j4HVFYNe5XpA-RBZjFqhFj)0CUck[Tjj@Zfcj#V` "ImX*Rp@9NeI80e'6+9AT@cqXBF5d%6-'cK`iIARflEiEr4AqN!!rP"j)cmT+amq hrV5Mj9hP2F9ZX9Y2+H+XFmUGmPKaV,!V%cq9$3'!16K-r[U"$3-['6KMC-j)rl6 MFqMkiU2&X@,faa5rRX1hA[q#0jjcbcVRR&Z#0pS+cT!!YN2r6YXhiEPeMZZZFbB mGpSq@i8'QIJTp5Cl'Fd9`1qrD#jGPHVBRj@#0*@f(T0fT"h&H`GbI81`hVpH9Kr !Zm*r[,jIdhd&$f*Um04$V9&)Z'#GH3f)Kb1HDQ,Q8FGkV8P5cdN,YUkImUlLSi8 GQGJj[BahR[+Z3#!VP)kGriIkrlcej&2SZM[5+V(a*l$aX@EM[qhhQ'cpRDdPYZk 6hdN*X2i2p6+hYrAre[HN"G[m*`crIIhIEI-rp4h`Ni@3!0+8(,kp8#!p!kTQVB, QHB4+&!SdDca8lJ939&#NJEa+j9Z40@PHJfqKmYeS0C%E+aUTULkE25CRdV,b!@f YCF8AU4P9bp,rXV)Zkr`E'K4kIDa""G*8K3T"XGhBhmPdi86Rje8PjaYjF4@f8+I Ij4H&#Vljmd3f%2MQcbqZG2FJ+fq[hQ5PISVl,,,bpZT0P[8QH`MC4'pQdle*QFf 3!1`K[)piIBrJrDEA&aP9T$-k1a*GcLpeMedfHf+2@MeY6YXYCF8I8M-Qek@[9JY RQ2fRFIplTPkNG,iX6I5Xe)`R+[)HR&+ApH#8TF2Q6PjBp*H9pIjjNmd%@3p9VRJ !9IHG[k("ChAXCj[KrX'4E+bY@kEm[Km$`mkd9iDAdHNi(aJ'PJXZ'+8bEVpDqD& M"iAQ3(YR$30bbdBTrhc-AMC`6jdD5*@0,6TPk4p"6ANDIBcid4N+1MT[@@K-,MC AkMTY0r`CHY4RP&rNrVKY!p!pJ-LQkb'E[3&EZB"EZB@kHMiFcFRK&5[!0YM+I@3 @E)@X8M3+RJQa"KkCBTAe&E2EpS8@R$h023,pI-4c1,j,G8fP[e6jcc"1al0Yij) 1ajXMi(!FZ'YdUA[J`$fPD[q'rDeJV)b9bPI(kFk'eq!q"EjkDXX(cN*deYbRMUK "&mpeRl`BQ(!I3*6Zl[[Bc4q[2(Z'Hpp&kD8jm&fL-qVF*jGGHF9XE2h*E@H%&Sc )QV&KfBc3C*NR`qe#P12'X)XZZKAPTl5e-3Fm3r[IKJ`R2hHN#RbaSXcA-Hh)Y$d 6$a$RdQ9-fVCLi2Q-[(09GTPD1ZLLY,Q63c2fr+JdUbcpYXR`@U),-qC1rV2+PIY R9+f%amPC'J*lPLXr(*3(2PJ"*+1LE+6Npb'I)APi*2GM0r8jc(i3'AJQf-hpekL -&89M"Q#6aq"HC3"Gp`(Eqi$X`2%jC+lIG#jeSf-bdIRVYV2Z*-JDYI#-'CA`BQb [Le3D12#aDA23VilpcTi(c&`$T-95-mqV`,CljM*b@*Fe%Q34A*D993T&dlfX!'K j$(1dJNZA,X`cfCZCCDjYmk4PECZ)U,DY`K%j5c2%%cScJCMqV4UA*PFmQNIJdC! !DDk6L$l0GhrP$idX,bpAJD9,bmT5EaAScbpG+Qi1fAFBq!eF0iJ+bV#`pKdRcDY BPS0h#1rc&ik&Gj9@RLj1BQlaJ%R[Rr93l'PkKJ[TDL8V)CKmU(f)YCTM6hL,H1M 33,pQlY!bG&eXA8aRV2V6eB8GK4eTkee(rp,@-fmkD)HQXP#9lDJ)9B9D!hIFGXG YVB'U8%@Sl'MK"6"Z#p$"AkM+GkcV@0IK(Y8rjXU6p84DBSk+d(UCBhf!ZImlKrl plcN#mkGF2RrQ@8YQUEDbFbii4c#6"dY'S+(9-D1$bHGi'JApkB-(Fh-pYa`THCL 40j0$KJa4+[(QmarcqrEY8bVajR-LRhV8$Ad2YR!mVAE`55Pr+8J9RFL[9cI+Sl* d[0l%,8&k&VjJ-"M`i4F-RJNEN6m6RfRMmmV5q$[2Lr96GTPqe!!*jkJ--+Bm5r( !T'H"a%T9P-IS3biXlLKmHCRpJDpLklV@U[+1iJjG[dlE([C*eSXCFpPP&IjYfpE RA(EC`J&EEjifkSV`hB(ViASUp88B0U'!f',TCf@&3S(jJ`EPM"`jmUcjJmiD&4S C'(4*B0$)jY'Mcamjk)*4(lYJd2#aJdD2qZJ[RRjUjCAIIl$kp@X@A[cXdeY+Yhp rr@RBef"FS46XJ4'fFTrb2rYGjDYIVY5cHe3Dh,*-eqf!DhDdf)95G0dBhYcDrhR (m1l#qbMQk#Krh9eIMNecfi9(3GpZ$$2(P-rYJRa#J6RE1%&L(VICjphbC)0IPRX 0IPeZ'[bfA$F!Rrcf-H9[1ekrVlF0N[A(iprAfm,Iejr1KrPhip-M[k[rIIq6!G6 [jF2mZc%5*dQSrr@$0VIqrF-aIPIqp`d-%b6bleDRkRre2'kHApIrp`EHmrX'[j[ JGb2f9iVKV"@FU$Sq4Y'*ZZX[P`E*FQQ3!%Uc36p("b!*lE"D(5B*fFZE0h*pHEc IcN0*+T9i8eRq5ai+9+R%QiVdIq46Mp'[pTPc2)qPpjIVr*a%@ZH6%%hSA[03%+I bE#"2UN%UEaSNKZJ[9m2&p`!qUqEL2Sl2qZQ6ClfAL4rm"HZBbC`iCmiP'XCA$jj rA8-i9,md90-BAY3FAK*D@VXLh$4reX5C3bHcSVlaliT2RELUZAlPSZEDQN8V9P` ADQUSV3[9V'TXUQqXR(6&r1#XqZE3ULCBeD'Dj8YU'dGqB0+NkI-,*pDJ"HB)068 [DYBeqCAK&BZZ#dh'3@[pLM!VXZD'Qm+0Um0,Q-bG[DBZ96bXXVlZrFfK@Pc8e5j D8I[*F1M59E8eep#pBhP`GN1i6LmTe,4m8D-H)RrZSYUQF'MHp+PcTXqC`SULkR" M%kjU3b0ahiYjDTB[UPX@rJ!AQcYVeFTjUjBZVDd*0mfH2'[qJ&&m,TNhIh+'P&h ,*S8#QUA!4T0A-p$#TEr-kcm&D"XeX+%a[$6F'+kV#BG@kad3,d1Rec@('aIKdRG e1,4S+A+KTTV'fSCQ&KGJ@BYa,4DUAGP3hbJP@E-EQQYAB[e-"Ll$dI"56V8%`&T 5@mqL[-V`iPA,3JfiCJi,*)Gp1"aZ#+h"`A$pQK!fAeIIZ(,4LP$ifPUCk06MjH( '4Uc49JqI93r-*9C8Mr(4C!N`@,q-$BV3B&(MXTSa#&D(`LYAV3!ek0d1QEf#HeP 8Yf44ia*-9"1@jNffE90YFrMXKZZDPf1JTP80GX8JTk%q1I"-1kHd,0qlaJiKLk+ F14e52&4Sl"qb3p9(e'VIcIkPe[X$$Tp5US`(TJ0ehRh&l96IpRA[m2[H`KeihMa pelA,lrr9I6%VqGcTHe-9A6"TqXa%hG6'QaU@Ih[aeGlY-A[GIVdfD'aI2SPjXY" ,V[1mCmE$-!PjkfU[I4K"lrNCIq8V0*aF&kbIcTK#a9bJCl!,-M#M4"pQ-8DrE-E SPm-BrA*P'VLLR!EpmKQMSS!aqX()qXTAd#r!'2f#-MA``UR4la6'k$H)-5S(-dD r)XES0d5f!qaa1qKh+Q2d'mBBr8jMM!l$'D2IkE*&TFlJ&[''L8DPUd+-dHmpM0( [6-ESp&jcEhm@YihFqaLMh`M'k2GqaZJhNM(kI8"3SG3SSJ+C%U1I4c0#[l-CSem TBr3EBp4e'D[4lac'U$LA-ITKPi+2XBc4Eja-SG6jR!,p2XJBr5jJM-S,'D2IH-E S0d'Q9HSL6SYq&c0'[`ma4VpbaZK3`4Mp*XT@m-N!EJAp*M2'Zj)aqNeKM(j9M0& TU[%'TR&lk$HG-@TQ-%Dr$c0'[dXBSpp-f6,1GlKPC'Bc4VmjM0([8XES0jFaqXd 60#Jehe`5AXBB&G@-dHmMM0$[FXESGi98+A8PUp$[Sic4lf1-8IPaaZKh&@2d@b$ GP9V)lZLhL$(k,@D-IM@-d@%*Br3,bj5!!+G%[f@-d@mjBlaV'D2IeBc4k4VC"Mi !`@fJhdV'k&I(',AeM0'[J6(kI8+fKJm`F'[)0$&'[fE'k!Ij*IKBc4MpeXKfPEU @fd@rkaLMiT1-dHp6M0&[,@2dql5J3+Nr)JV3EaeMp&[2'*8Y4Z9Ic`Mp0KM0raP QdHm'aZMAbKMp0M*'KaXCSpp0dP@TQpN9r6l,'2eZBBaqYc,'qh1-dHRcaP'iMG1 KhaFBSpmA'D2IPaLMaamc4Vpr)eY3kX[F!M*r`KMpEQH-IS*Hp2XUBr6lQQa,U8h F&[TYCSb+Vc0'[cXBSpm@aZKhTfa9UDhF+[VGa4MplQD-bQm`4Vpl'+2I[E*pTGU iII6l*Q2dfmBBrEl&'"hDMG'cA5+PlQ1%IYpQM(lh-dDr2f@-IJmBXqJldNfTlfU &T[iYBr6l-mESpqH-dHp"aZJ&jdA`m4#R3ZClfXG6ImNBrEl2'2dHCSaq2j!!kA' a`HR4EbGM92b3!$(krBJaqZ%l')+2[jBY+I8)Yi4qMc*'[ef-8IPMic6KXPA`mHp NQaL5fd5rImmBrAl#'2ef-dB(h$S)-Tl3&TPk8Y5bUhkUR8J&(5f6riefSY66M0& Tlj%MJSppccm[m62I`mEa2,YYQm3r-rENFfY`G)ARHA(6P0TI+9IXkJ8DHRJkM-( iSP)bd3'diN5i*j!!L3la5aZ)Ak*eLIKPT@5L9fLp)Ai94d@Fk$9Y%5Tmq88QqMQ (`N4r+m-rVhl"'"2*eM$4kpSV96bYi%4[D$02AhGM)VJ%-Y%alAbUb"&j924jH96 Rpq44AG[N8Gh'81eC)irU03jLV&)H&6p6(Z@BBiA8ef5XCk$M["CArd6`Rk`a'rU HkcrX((DFp0IL6m4IFaaQpk8k92cB,A1HD,rhRLhhV0f#hqUeH*hiXA$,fR[Z[1H HHq2EAh-1(dj0-1-RlRKRqch5qUYV2leklCfV%ApelCE9@fcdDBPPPNrV8+)Ypfa riYMVU9PQ2q'@1rHLV4j"ppR#SEkf&Ur9RdB"NJJicjeQ$*P(clAfh[JEUB(Qrp5 Yh)l9b%['B$rdiNCN5X`LXl*)$m0kVQRYkM[e!PG[fAli@'UUMrb01c8Z3mKJHMU Z4cDR#f6c!JQp*k##fd2CRHb"0#IFXMf5'ZbMHpd21l)Bh9Ff*""!#"5a!L(V[JC BF@'b8J(,9l&If55`qM9CT01C'ZkUIHkXHe(0GA"6H'%H$5+ZbUaX#`BNp-c8()6 $DiKa3)`T9@YIlHK1cAIpXqkP6V+RM'T`KCeM8+i-mjRp#[EdN!"h-UpaM*BDcV+ 'HireB,aIZ2Qp,6f*AhbEQf%+-Nc&MAS(66pcjcNDaaV%J)RH,NP)"K58D13,*Q9 6h!1aaF9J4i`XrC!!EcTkC3ZU-k*8E"[LAY@V9+mCHX0clR`((@8f1bplJl3-dqL )ih#rh+bXefaC1-&3'#N8E#Y8BDH1+a@"XFdhTqq++eq%bP-+c")qqEcl%8GM1B% LM%83D'`4J-)AKM+`I!-*Md3&#f3pD3Q@3-Zepak1pfdrV9-Q6`+JGEpl1C!!cf9 DX-Rm-LDR&FUcM+YVG#XX3aL&T'!C6e!MZ'$q(Mfm'CT[Ii4Em(9'BPlDl1'krHj X3rCXEEKFVj8l0p5XCdX"PEm%GJeN0*+FY4PD($cKr!-(I2B&pq-J9a%S!J!K6F' hCRe0JfBm9"[LZ91[b,#QU#20aBCa$9%IGXh`MXV'r2Ve'bjBfq&H&6HL`#SG)8, 0ESB-0"imr@,PP-JhB3D$KUq+QJ!4Q)e"C&%0r3B+0f*k,Af%"D`HXTMME&D&k%U $'8XB)T1dFY#YG'!@KqaV59VXN3eNF"0i'6k`QeMhSTZq24JFPqX%dmEP"XI&JiU TA*3i`8a8"(2("A2M`3aQT1L8-8&85*f%I)hV$HES2!-d84M%Yb""5h%e3-C23-% #mIS$EN'($$3Z*jJE3'+-%b`DTkIK,c!QL+YY4$RM!Ni`C`b,d#E!ABa!fl'jJA& "[()$ZF&5YX)5T%83mf!C1F'Jd`I&VPiFeXXf,!KZ11!1hil@TE+'B1lBB#!ABc( #C!&Za*ml$LJBK1fJ$)9B$eI#L9(p(PN94TEeBb&X`PLh3l4"Vi"mc186MjUMNUb !0CbhJ#X)F+RBI%k@,q,2dK"$@Zm$!k1H*B3#0T%E#lkA6HfbX"AXR%ML6YLF'`J #eQUFC8JLdSSPN3N*42V111LqV`1$%!f#Kjb)S-8Rm`'CHKpBEcal80EC@GRbPYp Sj,+cXhUc"f4PSqKX*cYV01[C)TlY"Y)&iqV`cpf"6Va2VlEdaTbAh$bRh9EeYMZ (h&bR[8@b6-IGYebMC8Xk99C%G8ZZph9hB$c@2da,,(l),Bc(qST+%VK(brEqBJl iZMZmVkE[KrVR0%`I2ZMkYa01Q3!*&TS$KJhi,86"9U#MB%kAbSKJ92bk"H9TR$b Q6Q&*mZ82!-TUqqYZHRYeCc6bZ[*hpX32ZrlfkQJ82&dG1qbQ1GfG5+C(ZRTG3%" &SZL,hPe[ZRkRTr1)mTGdXf@mZq3A9$TN&*4LJYFaG9H,mkEl%NETkYf[phIr36G [!A!-DK#!CfQ4@*L0GC*8`#3X(-XbH-+4jj3r+cIJfp$aTZYVli6,M'Dq5-rIZUV RU2*e1dHiKJK5RA&ZakbDr)+$rXkhd$4+`MP'b-V1ZjeIZ,lH#(aU6'*e'ITd4`i VAdR[@biFEScKkiThD%Ck(cF'G*(RbBfLFZ-U%-N'$C%@*4`Ec,(%"XPMT+rX`U` %`,!9N!#%d4cIJJj-hBXe*33M@U0"LA28pF8K9*+&@(T*1mTDSMf%YLA[L+r&LEK 30mcV2Tb`+q+Q1e`$5-FdLf,Qq&'8!4CB3&iI$fKj'#(#ZYmZ"FJL`-(![V,%8,% A0E"h(h6c"F-'f+5H!+",VYD+)6HB(HR8#-!e!2&*h*i(kdke@`H&N!$A1-+k!2P H3G+EK,aNMi+*1,YIG6P4`dEX"jVVM")ej$Y1eSQ@l@JCLa)QZ%B`d)LK5kp!P$Y !Df!&0``*D+VU!hUK,R"253AK39V0cHa9Q@L46E%(qL8M3'2PF$miBV$qMqb,#l! jdK@Z%SNZTd66-0IXkh51!@mHB@#4[5L)Hqc44A,UjSi0J@MJULk8G0NKHe@1KmB 5$*3H*b!Sl[6S!L4TfpYTTf9I@`eXp%4jNbN9I!-T23FemIN1ZIP1-&G60I9>F k*BI58S5R9VQ@E5hj(G(,dV*Ej,k+%URG&KmTPX3U@J3Ah&SmZFY)($$9C9UdBKf 4@+IVcGC0'k'cde@pGKB#(rfk`5DJ6MdMHdG&AD*6*kGK*F!ALAHk60QYarA@(%1 ANIC$'J+A(R,9GLHB,a3'[!G,FIL+EZR8a@#",0(,i)&!*`5"mJ9bc!q[E'rG'#Y 6&cQ"0)6ChF4f0GDTPj6H#B(A(SqVGQbch6!4H#-+&'4`VhUG[Ef1[lFD!X$A"H0 BGAY,8pfaH'ClV+I,pR@kA#HHKSP%mXI&FR(LIK4dJ@+Lh564pRKl0dX-8B%YSkk raq,6ha0[Mf%CmELe#[38ADjUdEM'#&ea[E#ikSPES3&-JR!%RK%p!'D-BBB5$5R P3KY[$i"8Y&DJZq+,3eeN3894eX0I%'SC%p$)cmHR$X$#ii!b[#'*#V+3!%31c4" N!IaBQ5`'@U@Nr3eAaEZe#X)UBZ!BAk5h'fc5CH$B!J`i8HU$RLih,3C@)1QrUDd L-&,X64!XHi&9S(c),FL@#(qL"3R,H"BF+miDD!K#Kh6@j5E0i%K%3lbAF1KjbHM *3fl46E4`)#JK,1LMM-[1bXV1)D'S3$bBV5f6B)lPTS*-X9,S+#(-0X4+`2P%Lf5 J(k4)YS!5)%c$Za1!k"CB(i@'&hld4F!,eU`Q3!!DRp-0e%5FEPI&0"'`X+F6#Mk L"QMT#9dYXXS)jEL3!#LQL'1l@L*LZf!@HTf'*`!5XV$KK"LQ`jKD[U#bac1'X59 4VX#XTQ+XiQ80NrFFFM-AF0pBHc`iM'+,3TYU%T!!JI0"SU$m$QJ5KAM`d4)"&-B k`I4FI&48J%,iN!!Xd&!BR)6"jAA6""0GpEUCRMS"QiSE23R&P1k8'-j!(HK&!!R "j1Y45-!Vd'i@QJ%BUX8M@p#'&L@kYFraNLJ%Yk#&i3qS0Npq!"TT4XP%ZdL9RSB N2P+fQBB)UE3!ASU3!#0iNiDE5"*b+S$4$453!$'Mj&$LU*3UVM4iGK61!TP$4$N mef`p&j8U&ah&b0f[DpB`3V'(qqGZ+%1`rhB@GK,@4Rj98ha$eA#2hF5&YM[Sr5- VG)UeJM&-[fldYY,0N!#&eUQa(KI`d6aK*`C@Zl6@)4!39aZ11)8F`Ff#)jcJDB3 rV91K#"M&a!65Y($&R0&[0q,#Li`&&@M#Td9DMmSA86%@+Um89Qqf'4aVaj*JcDQ i!ApeYqZ[0[S'NPk`%Y8e'Z*DQl'XqUf%T%K5U(#$dCe%8!rfU6@&1!9DCJ%%lBD 5K$!Y*A@,9VG8B'R6Q$i*iKa+9X#[L-B,$F(J"2SB48'ULh&$8$*N,1$4*BSl8k4 Y!)DfIfb`X-FBCCQSk3fQSaN*-je-@Ad%VQB[h-M%bk"(P@#4BNTS(bN0hr56A@1 2e8310'XR&8*2V&Ve%!`'B!#'2bU+(V!`*L9$3%T9kj5K6A0IBPL4!1P0-U)4B,e D!)&Fc#kX!$2[%N1F-(F(KbF-'6pN3Y'%)4*,J*mcS@$mK2&)mSHLJ,6ZKBCJ0Aj &D$@K%*`k1*00*l!ELUJfj'@dC"bZkDZ%-MFTp'Hj'k#&c5c3%%r%d%Dd[FYcarA L54T4[6LQ`Cfb@`fZ(UZbF%p!c"%2RDcap"@U3&BJ94K*PMHMKL,mKp`4#iU`6Ub IDbl5bdDk#$QpD&eGT*RR&0m3334@M3MhbaUUkC&-9-5'T,2pHGfbI##k*5,BXqD +PJ+#"&j"p,i-E'ZGiiZ#d9+A#9bjD5dmQA$PX(K0*5J9`@"EF[-*TK$CS-8#YTp 8$9SN%T!!"J0$$VR["`F)!4M`bi)Yc,PSc3i"S#h)16*44*L`eB4#cHIj@4Sk!U- *S%q&D6&QYELTG[e*MdN,KM4V%HTlQ*GGIlIRb+%6K&L2!%-`$9LNdE`5d)-(L'P N"")*$H@(R)80Bii-HY`dMS)aVD`dFJk`-+LbbY*`X5A((L%(BS#V(JFN#$+%(64 IFZ&"C-EUe4D!"0K+NfDK995N"S1P)8@&QJ+eI`PAKZ$K'fIJ59l`1G8X-QcGhCZ k+m1fK"l-YX566KLCK)*'qh&501J6@[3i!"M3[#$L!"LaT$$iN!#E(HEQ"IM1N!$ K@&j3&Mk1!IB22!K6i,Z1D"b)BZ0SS*Y3"265"e4C%e!Pq%&R)395)bbN1*@KC85 *48Tk+N'ri9Lp4'Y'%%T*'4Fb5*J`@KKJ,Q%$,9(HS8KY00JE"k,"3`$`i1ZKA`S SX)%95If5dP##Ei"(#41+X'P`(H![B#$N08#d2KK[@&(!VdN8HN%$!)T*NmQ%3%S aDEUN&581lCXHj3Xa@&&j8*1B43iSXM2Z'-9Nf8b!NC,CR)HJ-2,C)dRT*[4JR)B HckX#(M`Eji4XbS(IiV'$PNl1N!""XRf2)L9C**i8MMQL5$Y$4$X'S4Vej!986B) #D3XK39UNAaIhVP6LlCd`F!dj'EQJl`"k$d$Ki,BQfZjkGf9'(aLDa1SeMHLV0d& !MdNDG[4F"[#M@6aJ-!!5fYJjfQJJPUchC$5X93RGS)5`L"l$L!*YLhQY%BD-*5$ 'p[$ZL*E#K!!%Xfi!Q3"E#YT*0+@`![5M6%R9+UiU4hG-N48*Z$E"q8E+BZ8UBpk jS4CC3J3LRB3IFDc9Tj6B5TmU#5QD6BYdP+CD0eZU)3#Nc0*!qL(h!`Y%eQL0C0K !rlM4XAVr%iU+"-"qRbB"kJ6`RNBTEhZdEN*Y'VUL6VZh9!VdKA3I5d1'"d8S'#@ *4VdG&&1iSSQd'+PNh#BjHM)`H9XM'*ANH@j#"%DN'$Dd8M&Uc)2%b"B&"5#!Qc` p$&dJ1TLk!*%'J"!#H-iBqCQHM%5Yd36j@938&N@D#VPpm3pMd0,L3QVQ)`pba5+ $Heq`TehfD%0%36I0ZU5l*%iF&`p50&3L%1MhPV3B6j!!BNSQLH``-M0P+9MTD!N 4CZ`)l5G`bG4%@KPUDD#A6N#-+i*'NQ-UE5FB'dU,!k*%pk6Z$P)ED1Z0YUb&-2& S%#TdB2D+#cYrHc6bKYa'(6A55ABU10!@8X+`pi54C0qf9,3LJ%E8@2"&4#KkPfc FJD9Q3`J$$lPRhS308bQ$$&+Z!6F+P,1@E%IYV0NYS#Q3!*A`R5J*Ze84j*%&$'9 "bPQ$diV0#3')1Y"@Jbr5*a@IKhi3@pdl-q$j8!Nq2*'bkch&4#dJ&'RpUJ4&bU& (bR,X0jTSh@)PKQe50'f&Xb**DT9JD0)`JPE(@P%c(MmHKbh@6*!!c4GL6l35K!q dpc#K+-8(iKc'3C&q"!Q26FL-aakpce&5kdXa+SXScZ(k2360C3P6rPF'`MXkbIK *#FPXAED%HjPNK60ZmZ`elC*T1)KY3,I4'C*1#)KQjPdPKG%id`c[3Nplj'G"@ZJ k+JJHVPLpi1NEXN+5()f18,dri`@3!&kqG-8j@Emr$beKIB68qBCRXZNY'2eXI6[ #3U0)e+-pK[#SdXaNI!3V&5+`(JdV*,@M8+5RKiN5@Sp4dS+f&841L@i'*l$K@(3 ,%"pD[D5!D$EKq8SDP,h2b+@#CNfp)Tc)(Mr@q+95X*V4+ZHhc6@VR+eL6&#PTB) LN!$KGR#EeAY&p!1d4d$P5)dJ4KX3RK$hXQ[+!GCUmG`M$SV(!m++9TkCQBhcBH5 Mk!9K!bX-pm&Xd8ejKA(mMNH%PYN!U)",lK8()@Q$("F(EjYV[%E3qXYk+BQMM42 +NBEV68C(3`D*6f#%SEC8V,8d!9H'KI!$[E-&Y!3,&QNDKZGS9!+")VX3Dkf,p!J `'#m,Gdp8#%BVlE85caaViL5Vrpa,C")D'MXqS4-%liR$$%X*eP$6I#bb%5D$FA0 %YXU858YYa2la&DH2VaKI-A`LSZ%9&89m64`rXDKL!Rl$*e5-RcKmr2$a@PS9C&B 8S3pVLbV'$bBrCTVd4$3C6ae0dI+Z,M$Sp`cRTh&2cIY!@DcXhAka`YiKb+j%(*k 3!!2fQ*!!HL"KQ(SkbD"G9%'#R4+0qNMDB#([N!!li$X9&9899I%+2b+RSULUSQ+ U9-M2"K8*FbFr8iSQkZ"#MajY*m49&bB@N!$B)p`qb%C2eiK3d%U'&bSY[5A+HFh epdB-R$40#YDe'@19J[(KY53rS4rl$#C2*afr4V##`@c*5ZG6$lRqla!%SAfbBN3 6%CLp5X*N,ib#&D-D"4SLJr@h$VMI%YZ-dH#89%S+(*+$i8BHeaKZY0!fei[YZ!H -DF43"(9Ci@5-Ch%F$$#0iqB*)%kJ"C5j1E5D'KHbePHcKJ+1Pl5SX+l@1eF+-#$ IkfK)*,FZSF@+,0bTd2[*ckc56D6m3NdlqCP6,Dj3QM4I9BPQ@QYR#!()#Zd"B1, Z6Da#(!4LQ8EX%"AfD!ARRE*HkmAM5Nb`RcTH5YblHBj6iL)5eiLTmp$qSi3c$VP C,eJkF#V5Q(%U"ZMY1aAj"JmXpeJVdkR)UDSi8hFG$&NbJ&6*GPQD1")'JEBKVH) a5Y)S*@jHReYapc`-46-r)-2H[0R(m4k'%28Jjfj*EJ!ZNL3$TV!L'M-PkF$`Tle 85!-rDG`)6eJGqrBY6p*j'3TTeC'NKVdH2D3)Jlpb$IK!9+FT*U!P[(9R3'0-V+J LaY!IDX)iF8EL@SrF%#JAC3K8lPhd95e1B18B'EH41(!h`VZGd%LHIQP)f8Y!I65 0X`KmIZ3%G4+D#AieHYXHXZ1B1ARipFlY!UpmmMZ%'iJ*MAm),Lbim%a@BF[$M@+ 3!,0qDLa0Ne#RJf%6m1-UQFqBYSL8%4DH0aIA"eV@SM5#%9Hpq[+0dKS(,qdP`Ri i,6FLfPI##fe`U3a5NVa*apde%55)%h9KEl$efBMP4N1UeV)N33-mSN)CBfE2PRM RVZ&dh0J3$d+'&!a!Ke14868Fk'FQck*JX-%GT3'`%8F2)Bd,VG&[Q%ABaKMp[2$ 4qTV+JTDP*KGiYh+2RcM+m6XYiKb!+h$E3%-P#KdJac"b30D5XM'4%eY'N`'S`qK ph2llRDMj`)6p))6q%%$F(J"jPl9LfZ!BhYUJ[cai5$rS+U$J$'jeEi@)#%fUN!$ B#@N1%S'6QkQPPUCEkSc-L#%4%5T9Jlec1-+#RdH)(+2&&J89D0)RdrG@+c#%pV* kX"4B9[jSCh@X@kYX-KmmEE%MG%0Fer,V"[a1`6'j5$CI2,(I9d'2cNLeFEl*d2J !!T&%%b*5EAe!IX2K0fF1cN&h8,KI3d!Eb0kKZ)3mGDf)#NIXA'K`$ai9iP(Sp@H I)l*dZ&#[-X)#U1qb*JNq(ZYa*lCBc6afe)N2RFKP[[jDM$(i)c$ideT3JCY#E&$ 6S[l-MjDfad3P[+U9Q5"$Q)G,Ca[aK$ZpZhYFXlplj6$LS$[JDV2`dce[34EX38+ JIb%8Eb&"EeJ!p%LJLEdYHGRp[ST"KQQd9iIYkkr%k+9jekp#P2*0&'Ee&3bq$m, ['a&)L3qRJ#*3Lbm&bBQqYA[-4f1XB*,h@r`'M6@pl,'6*bG6Aih4F)$cf2re(mc @%Zd6k)R,"aA&GlbZeZL`+2(F"QDKT$3M$%r`!B5Q%HrTj%M$dpQJ6L-m@-KlN!# B-%88RlXK#q!6aP%B"h(Mba-$@')Ak-$A'mAhKZ8E10e(a-#`"bci@&#k3aPZcJh e%R"6L%m'mC-BqT0"P*Vi+NSX'RQCR`ZaGh8*rI61[82Z36F30VZefprV9!5Y4+$ T)X+cA,aFS3,$%4-[M+Mmd5RY0&K6)TEVb,Fe8Yr3XGqi)6HpjDE61M)A)9K(T1H )f@qL&C!!LL-a--JaEF6KDd1p)$r2CZ*AZR!pK5mQbGI$l&GRm$BIKh+-dNLmHEH 1VaDpI6"TKABK51"c54r'Ji44#TjZ'Zr*V3)DHDE(ANLRA[2K+3'+lMkmiN)jrG" UQar4+Z'hR"*H&6m9CN`X(Vk8Y2qF@lA18eImjl"6))q5RbhLKm,LFLLKbB"Y1f0 rkfBjmNdG3lPbqf#N!aV'j,BXm3NKIk5EK*a`ZpmjJI!GG!IIG!)14Nd8bY)TYBe P"4M5H4&e40%SbUPEh$c$+pE0djEQ'fjqH`ZqiqB400H#A86a"CKBG3Q`lNEm*G@ a9pd"$[0B'Fj0Sp'ZPVMV[JEKf99#P`kD-LhHdbR,eI")Md3lZpYIe9med0pC-[, J6E"-,dE4)1$RXD)Pq1EE'mQEU"-#X[ZJ1qb"Y`&JZ)!8RdPP$B'Y(3GECddDF+N !aAZ*BH%AUi,b(2jI448r@iFrIh2B$HLrE*4maAATcqR-i,dI@'!K2+1AGD9Tm#U `Eh[q6*VjNBmM%f0,2B2TV@X2ScBZ'3jfc(D*)DQrr+2U$lU,pR[Fa[8C*p*U!S[ rK$f(PeB-9-R*)L1m+5$l!24mhkHI-A!D,8#R!V*#pbGe$1G)#GhKd6'cSNSeP%C BkDE,V*T*mILq*IK)AR,J19$((6@e06@eX4T9'kraepBXk8)QLRGh66S+Pp3k0HT UTfE!%QR6)fd8Lq6G@k2fmcZZhN3V$lJIhlLiYR9ac5fe$'YE%5a[ABkBZF9)km3 YLjPE[R'j9+#4lVBF99i6VjRZc2"E(AhMAR2!(AYVN!-0Tp`SVpEDMDdeNN)0#R9 LmFD0$"C,`H+0bcFkVENX4bqTGPS,X!08YTSjDTe@IfhVaYMaB6mD@ejc+r[)HM! -ZqXA"M3*[BaD6,Pmiq*EN9[1UERSj9,,YGCZ"!*U%1%&X'!A'c[kKVhkJ([*!l) 0019UEX929XU*d&-RPM0TPmm4CA)fi+"FbZ)DQ3),cEQ&$BQ(MI)"CQr@fJ2Z&A& C(rGaLd%!Keh-l()%NLC@Z%9*DK`YEVd&Hq&f125Y'V0SJUQP9fe-[MMYMEVXJ$[ JQ+$8DCe&[!XZC)$@'X`U'p2BPCI'+e'8Q,m@-a-"qXe055@qb*)F02bLHpP'F!' )43!M2aNF5q0#08ijS*!!K*R-rM4UZ&$d)[5)Y&ZaX)dGI4m"VhR4RBIjL"RGQ(J K"fP-D-!6l%!V4ZF-8U[*LY9BRF"CpUkKK5(l2Q1qk%AhdRB2G'Bdr$#V)&-S3%E 6L'!TUS3[K*-%bS*X`5a(H!"H8R,%"5qkXahG'`d%dm3Qdf4*p0EiN!!L"1bJ-50 )%J!5-M8XApak+qEVk2Ybr-GIG'GfN!$,@(p,M3aQPiE84VIM1qdhDZibZ#5)#%h %`$!PJYi!CR[J"FIYk2[5rC`1pc500HQV19,f!i68h(+iehf2%hrJeSdEEp8mc8Q j1J!D!hhZ@`qdBf%[TbDk#"-pJ0k#0lB8j!SIF@F3cBk6pS,c&[UQZmI`Ak@H6h8 IJqi1'JP%0@i0QFL#il#hMMfCDMMU"AHBjK"$*4VCQK4EEhhi4+m($&L&TV5ii9V !-a[lHReJ[hXUTmB,#"AH-k61pIH2YGmG&VrVdFeimEIVVNIaqcT5Gpf&h,GqN!$ U9E,I2HhBVXdl0qqmDbGk)HE25m4hp$9l(Q"!-FUhSYXQC(EHY@RRShLa`YRCeq` jpl6GQhCZhX("%1rF[(RRcVX`Tal@q@'UfHLIZF0hBaVdB2(@R9[e-MBaYrR42FI q+YAYJcpcch"fEHB'd%J@Y8PhI*4&QarGIIJeq!"lGZrHK8m#*aZA2qZ'MQ&+cXE PEHA@QF)'C812lYjcl%dhppLH2E[[eV9lqJDBmBalPR-Aei+40c2#$VMqcCLD+0L %pHhD[IXEcQjeYl1V3$UJL[M!'[YQQElA(Ed,Xh!4HR0XJN9JJhUE+0EE&FaXG6C RXa-!XI2TMTG5SdapfJhZ-9[&#&X"0+5Fc@F#RaE1GfRJFPaZ6AIEZ3RIj1SIj)2 (hK)%XPUQjhih%CL%#E0B#KFU&36r9PPHrh+R21@1!d3&+8$)A9Z"8$3KXRGb(aJ 4#q%@(YRm#'%[B%1ArRe-IY)pEcGhb4%j,lFS51@Dp3D!%RER(M#Ze26[BZ)6-*9 fFfh!HhcRql!3S3i""Y#%U68S#'L*mH[I3rPZGd`(&k%44K`4P%)FA+ZQ)&4`iA` pZZI``G3!%ajh5p#I5j!!SC%5#Q9[B6'ZeC!!hUlG(BF2T*UIrjJl!S4N3+[R)bQ 3!&Se[Q8$Mqjf1PlS%bq2ZX9J39%"dPNc""2X[QRAVMGKXU@8`irFBD60Zm&,C&+ 3!-!MN!!0GqrHiacVF!mrQqTeaSmJIAVGR'0[2HfmjEaec$RQ2*'U$ccNUX-r2KR NIrh!5r(r0['aIc,1r)XX22EI,0Pr"LVr4iPYe3@*rqjd0YkM3pmmYqcFFmp'F&j SANepFh0S([jqe)S3rKC8U+Dq$RpV%RpVZ,ka#Im(-I[Fl(0P'[jhcPrqrbElVcV 2`#Cbq-G8elRVA(AlHPhebrrXkIh,TZ*A[MiIllZKQrRrCIj3I2LA6GBcN!!iLrq A!$H4+KIrl4cr%q!P2hVP+*b--UAFN`'N#TKb!#K(dS9-qejk#6HF,k()c@GH[B4 66'BcP!XSf"$,`9'XcdeX56`-hjGpArlIVSRqlh'rG%h5d8YmRq*AlZJSIQA,@8U 0"drbhbpEem5JdQp3Q@C8IlT"j`#$cJbM@M-05V--5V10kX+Ipa5djKUdjKR9N!" [8&YJ8)[*IiPDr2A6AJ!K3aALp2GSYMU+SeImFqA#l%`36+DA@@-cQ9jQMFeNHKP d4-l,H$80AUCrXM4Q!,K#-ljCKR$-!rMp,f6qerqJ$&4qT[L9V4q$63lPmk[rL0b (P2)BrL*d5d[,KTD0,Cp[ZD1PTE[PMCBA@hDLa&IKacF3X$Am-CA4*cY`i6DYa4X cAVcJ$pi(&mjc['#Ji4-GZI"Ah&b&6p$jFL8R[TTM8rK!P8hKHfdkjFH3!,ff#Rq c)0IBFEeS!&G$Tq+jq1L(6MQjX0p1c)J`&q1rDm$4If[eYClX`(rE0"C[X0"N)[J N'2jEKMU+%(DBK1+YdAYM"XBD,$8TKE&Q`LcIdFci'Pm$%`S*kp&jMK[*,ZQiHI2 ef@dR5I$EBR-VmFBIU+dNYrk6hiBT0ehim+Gr%2p"r)lZclqamF80m0[@*rbf-l1 e$MKjJGr@qHFAiAf"8PAjIcJrq'hpKfF&G0'F[IKV5AYTRq@M3@`[fM'!+BE2qrB Pa(6VP&CLaAA[08iE2Xf))BaY&pqEF0VflUA6pYXc0mcMZmehfdN1A,EBKbT`5h8 r#Vpj8JfA6Fl2'RKqaNbQPeNM'FpDbe4(BmCLd`9br0CJE$RdY4NPQ5ae&0q(l%r JIcXJFG)&[qe5[+F"[p1K8IklhhBp,0R2qHlSrN(h(GfIZl$e`ZX[A)pIaAMMYjf IIIj*$[bff,5KaE'Cq-Iq(iC5q`2l`@m6Bd`pL@p@daM$plZIP*biD)j1LEIf*,f eE2505LCCK-p,X"AIj[-5-SA0bpFRP*I'B&h[T2MYLfl*RI$JrYMhabFpm1!Ha2X jT8iP9NpQiF&Pqp("JBpf00X2GQ'Qm&mbq[-8eS0E!d21ZR'U)A(`GM+(ZlHCHF@ a5r+8'REa2rP`NhXfG2IprQ,$UGGRFh*lpqCldLG+*,NelArCHI4E&V!Hhj6$9q9 NHlERp9c&3,f2[ZGi(YqQIlG!pQ3rAkHU'qU@K9*0&M8dV0!C8"0l*A#a4[R5jXb DHQ+X[ZFXeld*2I*cKR&IqG1R9Fke1a@UFjI2K'a8(jTH1A(qY9GGr*0cFY5F,2q FqU2II[lPbdjl2VE`#cp05p[`KD'r124Lr[JjRkLiCZF0rX(hEKhhk+#9Q9GHHqH -9DGmm*2eDiIFrr',$ahT`DIcB*&1Q99jhk5&3%@QZNme)b*kdr$M`2NU6lBBJ%V p[KU*kV6!TDYUDkkCAlXb[%dqaVG)S5i,GGKED%QiTRjP3f1iUDQqNAAC!1'LaR# S,KaH%Pi5DUi20BA$SHEPY8fKKYUDjP@0B5k"U0lPJelp9e46,Cam!G8Vra(9F(" rM@TCc`G[r#%&reK"Y@r!cMr09k'mY*erFZll"NflBX-MTdqq2qq+d89VjKmGGZV ThhfLmNqHDrp4f8p(IkRUjA'$Lapkr*5"'`lZKdJrQ35@Zhci*`6rLQAFRCiN!-Y rp2pKHI8rBYPm`4)l5eY[p!abTNEV4j[r0HEpA-lLc80S+'62Z@6q&)d1G'kCe6- @QC'D#'S@jZIR,``X[(,R$cYfr2@1'bFe"%G8ql,['Aa,RMm[Er6#Zp4jRmYmk6E RXVBqm*rX!IX2q%jSb[q*IA&D6Vl!ijHI6)$(L`em,pMaq2fc[hcRbm9jQhr8fMA dm+'K8AI!U!ceq$d$(b`-A3@Il535U%9i$rbaEq#GRT2qiUAh$m-!DRKDSaSdCrV NqDT)EEZNFNS9rYV&`j-[QcY2CILHaKpEU&0TrP&6,r[ckD$&+qI80c3MAVSkh0L %relC@&[6"!lhhi`BR1Er-Z,h)YikII)Xa[I9eUaJr81)@Ir)T&Q9Z*!!pcmjmk` PXa$[VjU,HCArm2bCFbl"20&jdkqFS[aT"C@Ac!C+dmkYR$iIrG)qY+5QH6(L'Ek RJ"B,e,p43a'+S[3pVH$qk5X`hei&*p2hJZ6fU8QB6d5`laNe%lP25HiYmYAT)c@ P-lG*-`3qF+[8CYcRi,QHZ3EKAE@"ZH[f5qicc(hU@XRG`0aDQ8qe-RI$9C,Eb&` Vh%Pb2R-E[klP!R1h`GA&Fc0cAlaFFTpPlNX&NVZ&ZDrVllEGbY`@c@HIBfiV0C0 5RfIZEZS)"8bSN!$bDacX95#,E2ce$DRl5%J0N!!jJ*@2)5Ilmch0hKNMG$rQ#RA [IDkEVJ,#h,jR*#Hlm6dVZ5ek*ZPGTfGL,R0*BUDX+a-cC3Zf4SSV(Y!i`,rQ43i HSfECN!"U@*(qiTaVHI-88X2[4qlf(r`&H`3&icSh82CKFZ)`QYbq41i8Z))f0d4 M9c"p+YaMH9E#RG,I+CDkBH"-lNDY4@k3!0k0`YqY18f%LGBb`ap2Ih(@GlNRj*j &$RrD4h+[)[FJX)A[HJbh1,X&A%+hLEN[JM-%SlRcep4I8PXA*UIQeUbSE`S[@G6 B@,mQZliKA#HC3,+UUAj&lC)#Ve651C*UV&ff[$PE8N[Uep5P6i-N5+qUE@V1RF4 'i8B1@$"l69fi-34C8,qUX5DF6X*1*eX2Q,LiIP9Ip2m!N!36"J!!: \ No newline at end of file diff --git a/MacstodonConstants.py b/MacstodonConstants.py old mode 100644 new mode 100755 index 6acf93c..bd9c43e --- a/MacstodonConstants.py +++ b/MacstodonConstants.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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.4" \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 VERSION = "1.0" \ No newline at end of file diff --git a/MacstodonHelpers.py b/MacstodonHelpers.py old mode 100644 new mode 100755 index 39d5957..aa0369a --- a/MacstodonHelpers.py +++ b/MacstodonHelpers.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 try: handle.close() except IOError: pass 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 +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 Fm import formatter import htmllib import ic import Lists import macfs import os import Qd import QuickDraw import string import TE import time import urllib import urlparse import W from TextEdit import * from Wlists import List # ########## # My Imports # ########## from MacstodonConstants import DEBUG, VERSION # ################### # Third-Party Imports # ################### from third_party.PixMapWrapper import PixMapWrapper # ######### # Functions # ######### def getFilenameFromURL(url): """ Returns the file name, including extension, from a URL. i.e. http://www.google.ca/foo/bar/baz/asdf.jpg returns "asdf.jpg" """ parsed_url = urlparse.urlparse(url) path = parsed_url[2] file_name = os.path.basename(string.replace(path, "/", ":")) return file_name def cleanUpUnicode(content): """ Do the best we can to manually clean up unicode stuff """ # The text is UTF-8, but is being interpreted as MacRoman. # First step is to convert the extended characters into # MacRoman so that they display correctly. 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, "√•", "å") 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, "√¥", "ô") 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, "¬∂", "¶") 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, "‚â•", "≥") 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, "¬ø", "¿") 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, "≈í", "Œ") 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, "‚Ǩ", "€") content = string.replace(content, "‚Äπ", "‹") content = string.replace(content, "‚Ä∫", "›") content = string.replace(content, "Ô¨Å", "fi") content = string.replace(content, "Ô¨Ç", "fl") 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, "√è", "Ï") 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, "Àò", "˘") content = string.replace(content, "Àô", "˙") content = string.replace(content, "Àö", "˚") content = string.replace(content, "¬∏", "¸") content = string.replace(content, "Àù", "˝") content = string.replace(content, "Àõ", "˛") content = string.replace(content, "Àá", "ˇ") # Next, convert unicode codepoints back into characters content = string.replace(content, "\\u003e", ">") content = string.replace(content, "\\u003c", "<") content = string.replace(content, "\\u0026", "&") # Lastly, convert HTML entities back into characters content = string.replace(content, """, '"') content = string.replace(content, "'", "'") content = string.replace(content, "&", "&") content = string.replace(content, ">", ">") content = string.replace(content, "<", "<") # After conversion, any characters above byte 127 are # outside the MacRoman character set and should be # stripped. for char in content: if ord(char) > 127: content = string.replace(content, char, "") 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 attachmentsDialog(media_attachments): """ Draws a modal dialog box with Download and Close buttons. Between the text and buttons a list is drawn containing attachments. Clicking on an attachment, then clicking on the Download button will save the contents of the attachment to disk. Clicking the Close button will close the window. """ if len(media_attachments) == 0: okDialog("The selected toot contains no attachments.") return global attachmentsWindow, attachmentsData attachmentsFormatted = [] attachmentsData = [] for attachment in media_attachments: if attachment["description"] is not None: desc = cleanUpUnicode(attachment["description"]) listStr = desc + "\r" else: desc = None listStr = "No description\r" if attachment["type"] == "image": listStr = listStr + "Image, %s" % attachment["meta"]["original"]["size"] elif attachment["type"] == "video": listStr = listStr + "Video, %s, %s" % (attachment["meta"]["size"], attachment["meta"]["length"]) elif attachment["type"] == "gifv": listStr = listStr + "GIFV, %s, %s" % (attachment["meta"]["size"], attachment["meta"]["length"]) elif attachment["type"] == "audio": listStr = listStr + "Audio, %s" % attachment["meta"]["length"] else: listStr = listStr + "Unknown" attachmentsFormatted.append(listStr) attachmentsData.append({"url": attachment["url"], "alt": desc}) attachmentsWindow = W.ModalDialog((360, 240), "Macstodon %s - Attachments" % VERSION) def openAttachmentCallback(): """ Run when the user clicks the Download button """ selected = attachmentsWindow.attachments.getselection() if len(selected) > 0: url = attachmentsData[selected[0]]["url"] default_file_name = getFilenameFromURL(url) fss, ok = macfs.StandardPutFile('Save as:', default_file_name) if not ok: return 1 file_path = fss.as_pathname() file_name = os.path.split(file_path)[-1] urllib.urlretrieve(url, file_path) okDialog("Successfully downloaded '%s'!" % file_name) else: okDialog("Please select an attachment first.") def altTextCallback(): """ Run when the user clicks the Alt Text button """ selected = attachmentsWindow.attachments.getselection() if len(selected) > 0: alt_text = attachmentsData[selected[0]]["alt"] if alt_text is not None: okDialog(alt_text) else: okDialog("This attachment doesn't have any alt text.") else: okDialog("Please select an attachment first.") text = "The following attachments were found in the selected toot. " \ "Click on an attachment in the list, then click on the Download button " \ "to download it to your computer. You can also click on the Alt Text " \ "button to view the attachment's alt text in a dialog." attachmentsWindow.label = W.TextBox((10, 10, -10, -60), text) attachmentsWindow.attachments = TwoLineListWithFlags((10, 76, -10, -42), attachmentsFormatted, callback=None, flags = Lists.lOnlyOne, cols = 1, typingcasesens=0) attachmentsWindow.alt_btn = W.Button((-240, -30, -170, -10), "Alt Text", altTextCallback) attachmentsWindow.close_btn = W.Button((-160, -30, -90, -10), "Close", attachmentsWindow.close) attachmentsWindow.download_btn = W.Button((-80, -30, -10, -10), "Download", openAttachmentCallback) attachmentsWindow.setdefaultbutton(attachmentsWindow.close_btn) attachmentsWindow.open() def linksDialog(le): """ Draws a modal dialog box with Open and Close buttons. Between the text and buttons a list is drawn containing links. Clicking on a link, then clicking on the Open button will open the link using the user's web browser. Clicking the Close button will close the window. """ global linksWindow, linksFormatted linksFormatted = [] for desc, url in le.anchors.items(): linksFormatted.append(desc + "\r" + url[0]) if len(linksFormatted) == 0: okDialog("The selected toot contains no links.") return linksWindow = W.ModalDialog((240, 240), "Macstodon %s - Links" % VERSION) def openLinkCallback(): """ Run when the user clicks the Open button """ selected = linksWindow.links.getselection() if len(selected) > 0: linkString = linksFormatted[selected[0]] linkParts = string.split(linkString, "\r") ic.launchurl(linkParts[1]) else: okDialog("Please select a link first.") text = "The following links were found in the selected toot. " \ "Click on a link in the list, then click on the Open button " \ "to open it with your browser." linksWindow.label = W.TextBox((10, 10, -10, -40), text) linksWindow.links = TwoLineListWithFlags((10, 56, -10, -42), linksFormatted, callback=None, flags = Lists.lOnlyOne, cols = 1, typingcasesens=0) linksWindow.close_btn = W.Button((-160, -30, -90, -10), "Close", linksWindow.close) linksWindow.open_btn = W.Button((-80, -30, -10, -10), "Open", openLinkCallback) linksWindow.setdefaultbutton(linksWindow.close_btn) linksWindow.open() def handleRequest(app, path, data = None, use_token = 0): """ HTTP request wrapper """ try: W.SetCursor("watch") 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 W.SetCursor("arrow") 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 W.SetCursor("arrow") 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 W.SetCursor("arrow") 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 try: handle.close() except IOError: pass 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 W.SetCursor("arrow") return decoded except: del pb W.SetCursor("arrow") 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 W.SetCursor("arrow") return None # ####### # Classes # ####### class ImageWidget(W.ClickableWidget): """ A widget that displays an image. The image should be passed in as a PixMapWrapper. """ def __init__(self, possize, pixmap=None, callback=None): W.ClickableWidget.__init__(self, possize) self._callback = callback self._enabled = 1 # Set initial image self._imgloaded = 0 self._pixmap = None if pixmap: self.setImage(pixmap) def click(self, point, modifiers): """ Runs the callback if the user clicks on the image """ if not self._enabled: return if self._callback: return W.CallbackCall(self._callback, 0) 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: if isinstance(self._pixmap, PixMapWrapper): self._pixmap.blit( x1=self._bounds[0], y1=self._bounds[1], x2=self._bounds[2], y2=self._bounds[3], port=self._parentwindow.wid.GetWindowPort() ) else: Qd.SetPort(self._parentwindow.wid.GetWindowPort()) Qd.DrawPicture(self._pixmap, self._bounds) class LinkExtractor(htmllib.HTMLParser): """ A very basic link extractor that gets the URL and associated text. Sourced from: https://oreilly.com/library/view/python-standard-library/0596000960/ch05s05.html """ def __init__(self, verbose=0): self.anchors = {} f = formatter.NullFormatter() htmllib.HTMLParser.__init__(self, f, verbose) def anchor_bgn(self, href, name, type): self.save_bgn() self.anchor = href def anchor_end(self): text = string.strip(self.save_end()) if self.anchor and text: self.anchors[text] = self.anchors.get(text, []) + [self.anchor] class ProfileBanner(ImageWidget): """ The ProfileBanner is just an ImageWidget that renders text atop it at a fixed location, using a specific font and style. """ def __init__(self, possize, drop_shadow, pixmap=None, display_name="", acct_name=""): ImageWidget.__init__(self, possize, pixmap) self.display_name = display_name self.acct_name = acct_name self.drop_shadow = drop_shadow def draw(self, visRgn = None): """ Draws the profile banner text using pure QuickDraw (instead of widgets) """ Qd.SetPort(self._parentwindow.wid.GetWindowPort()) ImageWidget.draw(self, visRgn) Qd.TextFont(Fm.GetFNum("Geneva")) # Display Name Qd.TextSize(14) Qd.TextFace(QuickDraw.bold) Qd.RGBForeColor((0,0,0)) if self.drop_shadow: rect = (self._bounds[0] + 7, self._bounds[1] + 70, self._bounds[2], 0) TE.TETextBox(self.display_name, rect, teJustLeft) Qd.RGBForeColor((65535,65535,65535)) rect = (self._bounds[0] + 5, self._bounds[1] + 68, self._bounds[2], 0) TE.TETextBox(self.display_name, rect, teJustLeft) # Account Name Qd.TextSize(9) Qd.TextFace(QuickDraw.normal) Qd.RGBForeColor((0,0,0)) if self.drop_shadow: rect = (self._bounds[0] + 7, self._bounds[1] + 90, self._bounds[2], 0) TE.TETextBox(self.acct_name, rect, teJustLeft) Qd.RGBForeColor((65535,65535,65535)) rect = (self._bounds[0] + 5, self._bounds[1] + 88, self._bounds[2], 0) TE.TETextBox(self.acct_name, rect, teJustLeft) Qd.RGBForeColor((0,0,0)) Qd.TextFont(Fm.GetFNum("Monaco")) def populate(self, display_name=None, acct_name=None): """ Sets the display and account names. """ if display_name: self.display_name = display_name if acct_name: self.acct_name = acct_name class ProfilePanel(W.Group): """ The ProfilePanel is my poor man's implementation of tabs. It uses three buttons to swap out the widget beneath them. """ def __init__(self, possize): W.Group.__init__(self, possize) self.title = W.TextBox((0, 2, 0, 16), "Bio") self.btnStats = W.Button((-40, 0, 40, 16), "Stats", self.statsCallback) self.btnLinks = W.Button((-90, 0, 40, 16), "Links", self.linksCallback) self.btnBio = W.Button((-140, 0, 40, 16), "Bio", self.bioCallback) # Bio editor = W.EditText((0, 24, -15, 0), "", readonly=1) self._bary = W.Scrollbar((-16, 24, 0, 0), editor.vscroll, max=32767) self.bioText = editor # Stats & Links self.list = TwoLineListWithFlags((0, 24, 0, 0), [], callback=None, flags = Lists.lOnlyOne, cols = 1, typingcasesens=0) self.linksData = [] self.statsData = [] self.toots = 0 self.following = 0 self.followers = 0 self.locked = "No" self.bot = "No" self.group = "No" self.discoverable = "No" self.noindex = "No" self.moved = "No" self.suspended = "No" self.limited = "No" # hide stats/links by default self.list.show(0) self.bioText.select(0) def statsCallback(self): """ Shows the stats pane and hides bio/links """ self.title.set("Stats") self.bioText.show(0) self.bioText.enable(0) self.bioText.select(0) self.bioText._selectable = 0 self._bary.show(0) self.list.show(1) self.list.enable(1) self.list.select(1) self.btnStats.draw() self.btnLinks.draw() self.btnBio.draw() self.list.set(self.statsData) def linksCallback(self): """ Shows the links pane and hides bio/stats """ self.title.set("Links") self.bioText.show(0) self.bioText.enable(0) self.bioText.select(0) self.bioText._selectable = 0 self._bary.show(0) self.list.show(1) self.list.enable(1) self.list.select(1) self.btnStats.draw() self.btnLinks.draw() self.btnBio.draw() self.list.set(self.linksData) def bioCallback(self): """ Shows the bio pane and hides stats/links """ self.title.set("Bio") self.list.show(0) self.list.enable(0) self.list.select(0) self.bioText.show(1) self.bioText.enable(1) self.bioText._selectable = 1 self._bary.show(1) self._bary.draw() self.btnStats.draw() self.btnLinks.draw() self.btnBio.draw() def setBio(self, value): """ Updates the content of the bio text """ self.bioText.set(value) def collectStatsData(self): """ Updates the content of the stats list """ self.statsData = [ "Toots\r%s" % self.toots, "Following\r%s" % self.following, "Followers\r%s" % self.followers, "Locked\r%s" % self.locked, "Bot\r%s" % self.bot, "Group\r%s" % self.group, "Discoverable\r%s" % self.discoverable, "NoIndex\r%s" % self.noindex, "Moved\r%s" % self.moved, "Suspended\r%s" % self.suspended, "Limited\r%s" % self.limited ] self.list.set(self.statsData) def setLinks(self, linksData): """ Updates the content of the links list """ self.linksData = linksData self.list.set(self.linksData) def setToots(self, num): """ Updates the user's toot count """ self.toots = num self.collectStatsData() def setFollowers(self, num): """ Updates the user's followers count """ self.followers = num self.collectStatsData() def setFollowing(self, num): """ Updates the user's following count """ self.following = num self.collectStatsData() def setLocked(self, flag): if flag: self.locked = "Yes" else: self.locked = "No" self.collectStatsData() def setBot(self, flag): if flag: self.bot = "Yes" else: self.bot = "No" self.collectStatsData() def setDiscoverable(self, flag): if flag: self.discoverable = "Yes" else: self.discoverable = "No" self.collectStatsData() def setNoIndex(self, flag): if flag: self.noindex = "Yes" else: self.noindex = "No" self.collectStatsData() def setMoved(self, flag): if flag: self.moved = "Yes" else: self.moved = "No" self.collectStatsData() def setSuspended(self, flag): if flag: self.suspended = "Yes" else: self.suspended = "No" self.collectStatsData() def setLimited(self, flag): if flag: self.limited = "Yes" else: self.limited = "No" self.collectStatsData() 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/MacstodonSplash.py b/MacstodonSplash.py old mode 100644 new mode 100755 index 3b6352e..4323c87 --- a/MacstodonSplash.py +++ b/MacstodonSplash.py @@ -1 +1 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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. """ """ A quick note from Scott: This file has been almost copied/pasted verbatim from the MacPython IDE, which is why it looks nothing like the other source files style-wise. Changes I made here include: - updated the resources to use the Macstodon logo and give the dialog a black BG - Bugfix to the TextFont calls which were failing and causing the progress bar to not render (the MacPython IDE is affected by this as well, lol) - Fixed the _real__import__ calls which were only passing the name and not the other params, causing crashes when trying to run this in the IDE - Changed the text color to white and changed the text itself - Removed the easter egg """ import Dlg import Res from MacstodonConstants import VERSION splash = Dlg.GetNewDialog(468, -1) splash.DrawDialog() import Qd, TE, Fm, sys _real__import__ = None def install_importhook(): global _real__import__ import __builtin__ if _real__import__ is None: _real__import__ = __builtin__.__import__ __builtin__.__import__ = my__import__ def uninstall_importhook(): global _real__import__ if _real__import__ is not None: import __builtin__ __builtin__.__import__ = _real__import__ _real__import__ = None _progress = 0 def importing(module): global _progress Qd.SetPort(splash) Qd.TextFont(Fm.GetFNum("Geneva")) Qd.TextSize(9) rect = (85, 270, 415, 286) Qd.RGBForeColor((65535,65535,65535)) if module: TE.TETextBox('Importing: ' + module, rect, 0) if not _progress: Qd.FrameRect((85, 286, 415, 294)) pos = min(86 + 330 * _progress / 77, 414) Qd.PaintRect((86, 287, pos, 293)) _progress = _progress + 1 else: Qd.EraseRect(rect) Qd.PaintRect((86, 287, pos, 293)) Qd.RGBForeColor((0,0,0)) def my__import__(name, globals=None, locals=None, fromlist=None): try: return sys.modules[name] except KeyError: try: importing(name) except: try: rv = _real__import__(name, globals, locals, fromlist) finally: uninstall_importhook() return rv return _real__import__(name, globals, locals, fromlist) install_importhook() kHighLevelEvent = 23 import Win from Fonts import * from QuickDraw import * from TextEdit import * import string _keepsplashscreenopen = 0 abouttext1 = """Macstodon %s A basic Mastodon client for Classic Mac OS by Scott Small, @smallsco@oldbytes.space GitHub: https://github.com/smallsco/macstodon Icon and logo design by MhzModels Built with MacPython %s %s Written by Guido van Rossum with Jack Jansen (and others)""" def nl2return(text): return string.join(string.split(text, '\n'), '\r') def UpdateSplash(drawdialog = 0): if drawdialog: splash.DrawDialog() drawtext() Win.ValidRect(splash.GetWindowPort().portRect) def drawtext(): Qd.SetPort(splash) Qd.TextFont(Fm.GetFNum("Geneva")) Qd.TextSize(9) Qd.RGBForeColor((65535,65535,65535)) rect = (10, 135, 487, 270) import __main__ abouttxt = nl2return(abouttext1 % (VERSION, sys.version, sys.copyright)) TE.TETextBox(abouttxt, rect, teJustCenter) Qd.RGBForeColor((0,0,0)) UpdateSplash(1) def wait(): import Evt from Events import * global splash try: splash except NameError: return Qd.InitCursor() time = Evt.TickCount() while _keepsplashscreenopen: ok, event = Evt.EventAvail(highLevelEventMask) if ok: # got apple event, back to mainloop break ok, event = Evt.EventAvail(mDownMask | keyDownMask | updateMask) if ok: ok, event = Evt.WaitNextEvent(mDownMask | keyDownMask | updateMask, 30) if ok: (what, message, when, where, modifiers) = event if what == updateEvt: if Win.WhichWindow(message) == splash: UpdateSplash(1) else: break del splash def about(): global splash, splashresfile, _keepsplashscreenopen _keepsplashscreenopen = 1 splash = Dlg.GetNewDialog(468, -1) splash.DrawDialog() wait() \ No newline at end of file +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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. """ """ A quick note from Scott: This file has been almost copied/pasted verbatim from the MacPython IDE, which is why it looks nothing like the other source files style-wise. Changes I made here include: - updated the resources to use the Macstodon logo and give the dialog a black BG - Bugfix to the TextFont calls which were failing and causing the progress bar to not render (the MacPython IDE is affected by this as well, lol) - Fixed the _real__import__ calls which were only passing the name and not the other params, causing crashes when trying to run this in the IDE - Changed the text color to white and changed the text itself - Removed the easter egg """ import Dlg import Res from MacstodonConstants import VERSION splash = Dlg.GetNewDialog(468, -1) splash.DrawDialog() import Qd, TE, Fm, sys _real__import__ = None def install_importhook(): global _real__import__ import __builtin__ if _real__import__ is None: _real__import__ = __builtin__.__import__ __builtin__.__import__ = my__import__ def uninstall_importhook(): global _real__import__ if _real__import__ is not None: import __builtin__ __builtin__.__import__ = _real__import__ _real__import__ = None _progress = 0 def importing(module): global _progress Qd.SetPort(splash) Qd.TextFont(Fm.GetFNum("Geneva")) Qd.TextSize(9) rect = (85, 270, 415, 286) Qd.RGBForeColor((65535,65535,65535)) if module: TE.TETextBox('Importing: ' + module, rect, 0) if not _progress: Qd.FrameRect((85, 286, 415, 294)) pos = min(86 + 330 * _progress / 77, 414) Qd.PaintRect((86, 287, pos, 293)) _progress = _progress + 1 else: Qd.EraseRect(rect) Qd.PaintRect((86, 287, pos, 293)) Qd.RGBForeColor((0,0,0)) def my__import__(name, globals=None, locals=None, fromlist=None): try: return sys.modules[name] except KeyError: try: importing(name) except: try: rv = _real__import__(name, globals, locals, fromlist) finally: uninstall_importhook() return rv return _real__import__(name, globals, locals, fromlist) install_importhook() kHighLevelEvent = 23 import Win from Fonts import * from QuickDraw import * from TextEdit import * import string _keepsplashscreenopen = 0 abouttext1 = """Macstodon %s A basic Mastodon client for Classic Mac OS by Scott Small, @smallsco@oldbytes.space GitHub: https://github.com/smallsco/macstodon Application icon and logo design by MhzModels, @mhzmodeis@artsio.com Additional icon design by CM Harrington, @octothorpe@mastodon.online Built with MacPython %s %s Written by Guido van Rossum with Jack Jansen (and others)""" def nl2return(text): return string.join(string.split(text, '\n'), '\r') def UpdateSplash(drawdialog = 0): if drawdialog: splash.DrawDialog() drawtext() Win.ValidRect(splash.GetWindowPort().portRect) def drawtext(): Qd.SetPort(splash) Qd.TextFont(Fm.GetFNum("Geneva")) Qd.TextSize(9) Qd.RGBForeColor((65535,65535,65535)) rect = (10, 135, 487, 270) import __main__ abouttxt = nl2return(abouttext1 % (VERSION, sys.version, sys.copyright)) TE.TETextBox(abouttxt, rect, teJustCenter) Qd.RGBForeColor((0,0,0)) UpdateSplash(1) def wait(): import Evt from Events import * global splash try: splash except NameError: return Qd.InitCursor() time = Evt.TickCount() while _keepsplashscreenopen: ok, event = Evt.EventAvail(highLevelEventMask) if ok: # got apple event, back to mainloop break ok, event = Evt.EventAvail(mDownMask | keyDownMask | updateMask) if ok: ok, event = Evt.WaitNextEvent(mDownMask | keyDownMask | updateMask, 30) if ok: (what, message, when, where, modifiers) = event if what == updateEvt: if Win.WhichWindow(message) == splash: UpdateSplash(1) else: break del splash def about(): global splash, splashresfile, _keepsplashscreenopen _keepsplashscreenopen = 1 splash = Dlg.GetNewDialog(468, -1) splash.DrawDialog() wait() \ No newline at end of file diff --git a/PrefsWindow.py b/PrefsWindow.py new file mode 100755 index 0000000..cd6ab87 --- /dev/null +++ b/PrefsWindow.py @@ -0,0 +1 @@ +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 macostools import os import W # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import dprint, okDialog # ########### # PrefsWindow # ########### class PrefsWindow(W.ModalDialog): def __init__(self): """ Initializes the PrefsWindow class. """ W.ModalDialog.__init__(self, (200, 200), "Macstodon %s - Preferences" % VERSION) self.setupwidgets() # ######################### # Window Handling Functions # ######################### def setupwidgets(self): """ Defines the prefs window """ app = self.parent prefs = app.getprefs() # debugging dprint("prefs.toots_to_load_startup: %s" % prefs.toots_to_load_startup) dprint("prefs.toots_to_load_refresh: %s" % prefs.toots_to_load_refresh) dprint("prefs.toots_per_timeline: %s" % prefs.toots_per_timeline) dprint("prefs.show_avatars: %s" % prefs.show_avatars) dprint("prefs.show_banners: %s" % prefs.show_banners) # Toots to load at startup self.toots_to_load_startup_text = "Toots to load at startup:\r(0 for none)" self.toots_to_load_startup_label = W.TextBox((10, 10, 160, 32), self.toots_to_load_startup_text) self.toots_to_load_startup_field = W.EditText((170, 10, 24, 16), prefs.toots_to_load_startup) # Toots to load when pressing Refresh in a timeline self.toots_to_load_refresh_text = "Toots to load on refresh:\r(0 for unlimited)" self.toots_to_load_refresh_label = W.TextBox((10, 40, 160, 32), self.toots_to_load_refresh_text) self.toots_to_load_refresh_field = W.EditText((170, 40, 24, 16), prefs.toots_to_load_refresh) # Maximum amount of toots to keep in a timeline self.toots_per_timeline_text = "Max toots per timeline:" self.toots_per_timeline_label = W.TextBox((10, 70, 160, 32), self.toots_per_timeline_text) self.toots_per_timeline_field = W.EditText((170, 70, 24, 16), prefs.toots_per_timeline) # Show/hide images, and clear image cache self.show_avatars_box = W.CheckBox((10, 90, 160, 16), "Show Avatars", None, prefs.show_avatars) self.show_banners_box = W.CheckBox((10, 110, 160, 16), "Show Banners", self.showBannersCallback, prefs.show_banners) self.clear_cache_btn = W.Button((10, 140, 120, 16), "Clear Image Cache", self.clearImageCacheCallback) # Save/cancel changes self.cancel_btn = W.Button((10, -22, 60, 16), "Cancel", self.close) self.save_btn = W.Button((-70, -22, 60, 16), "Save", self.saveButtonCallback) self.setdefaultbutton(self.save_btn) # ################## # Callback Functions # ################## def clearImageCacheCallback(self): """ Run when the user clicks the "Clear Image Cache" button """ app = self.parent cache_acct_list = os.listdir(app.cacheacctfolderpath) for file in cache_acct_list: os.remove(os.path.join(app.cacheacctfolderpath, ":" + file)) cache_media_list = os.listdir(app.cachemediafolderpath) for file in cache_media_list: os.remove(os.path.join(app.cachemediafolderpath, ":" + file)) okDialog("Image cache cleared.") def showBannersCallback(self): """ Run when the user clicks the "Show Banners" checkbox """ if self.show_banners_box.get(): okDialog( "WARNING - while this makes profiles look pretty, it also has a memory leak affecting uncached images that will cause the app to crash eventually." ) def saveButtonCallback(self): """ Run when the user clicks the "Save" button """ app = self.parent prefs = app.getprefs() prefs.toots_to_load_startup = self.toots_to_load_startup_field.get() prefs.toots_to_load_refresh = self.toots_to_load_refresh_field.get() prefs.toots_per_timeline = self.toots_per_timeline_field.get() prefs.show_avatars = self.show_avatars_box.get() prefs.show_banners = self.show_banners_box.get() prefs.save() self.close() \ No newline at end of file diff --git a/ProfileWindow.py b/ProfileWindow.py new file mode 100755 index 0000000..41aeba4 --- /dev/null +++ b/ProfileWindow.py @@ -0,0 +1 @@ +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 ic import Image import Lists import Qd import re import string import time import urllib import urlparse import W # ################### # Third-Party Imports # ################### from third_party.PixMapWrapper import PixMapWrapper # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import attachmentsDialog, cleanUpUnicode, dprint, handleRequest, ImageWidget, \ LinkExtractor, linksDialog, okCancelDialog, okDialog, ProfileBanner, ProfilePanel, \ TitledEditText, TimelineList # ########### # Application # ########### class ProfileWindow(W.Window): def __init__(self, account): """ Initializes the ProfileWindow class. """ # Set window size. Default to 400x400 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[3] <= 400: bounds = (0, 20, 400, 342) else: bounds = (400, 400) self.account = account self.acct_name = None self.domain_name = None self.defaulttext = "Click on a toot or notification in one of the above lists..." self.timeline = [] W.Window.__init__(self, bounds, "Macstodon %s - Profile" % VERSION, minsize=(400, 342)) self.setupwidgets() def setupwidgets(self): """ Defines the Profile window. """ prefs = self.parent.getprefs() self.panes = W.HorizontalPanes((8, 8, -8, -20), (0.35, 0.45, 0.2)) self.panes.avgroup = W.Group(None) self.panes.avgroup.banner = ProfileBanner((0, 0, 0, 0), drop_shadow=prefs.show_banners) self.panes.avgroup.avatar = ImageWidget((8, 8, 48, 48)) self.panes.avgroup.note = W.TextBox((-106, 10, 96, 48), "") self.panes.avgroup.note.show(0) self.panes.avgroup.followBtn = W.Button((8, -20, 64, 16), "Follow", callback=self.followCallback) self.panes.avgroup.actionsmenu = W.PopupWidget((80, -20, 16, 16)) self.panes.tlpanes = W.VerticalPanes(None, (0.5, 0.5)) self.panes.tlpanes.info = ProfilePanel(None) self.panes.tlpanes.timeline = TimelineList(None, "Timeline", self.timeline, btnCallback=self.refreshTimelineCallback, callback=self.timelineClickCallback, flags=Lists.lOnlyOne) self.panes.tootgroup = W.Group(None) self.panes.tootgroup.toottxt = TitledEditText((56, 0, -24, 0), title="Toot", text=self.defaulttext, readonly=1, vscroll=1) # Links and Attachment buttons self.panes.tootgroup.links = ImageWidget((-20, 0, 16, 16), pixmap=self.parent.pctLnkDis, callback=self.linksCallback) self.panes.tootgroup.attch = ImageWidget((-20, 18, 16, 16), pixmap=self.parent.pctAtcDis, callback=self.attachmentsCallback) # Reply/boost/favourite/bookmark buttons self.panes.tootgroup.reply = ImageWidget((4, 0, 16, 16), pixmap=self.parent.pctRplDis, callback=self.replyCallback) self.panes.tootgroup.rpnum = W.TextBox((24, 3, 28, 16), "") self.panes.tootgroup.favrt = ImageWidget((4, 18, 16, 16), pixmap=self.parent.pctFvtDis, callback=self.favouriteCallback) self.panes.tootgroup.fvnum = W.TextBox((24, 21, 28, 16), "") self.panes.tootgroup.boost = ImageWidget((4, 36, 16, 16), pixmap=self.parent.pctBstDis, callback=self.boostCallback) self.panes.tootgroup.bonum = W.TextBox((24, 39, 28, 16), "") self.panes.tootgroup.bmark = ImageWidget((4, 54, 16, 16), pixmap=self.parent.pctBkmDis, callback=self.bookmarkCallback) def close(self): del self.parent.profilewindows[self.wid] W.Window.close(self) def open(self): """ Populates an empty profile window with data for a given user. """ W.Window.open(self) self.parent.profilewindows[self.wid] = self self.account["relationship"] = self.getRelationship() if not self.account["relationship"]: self.close() return prefs = self.parent.getprefs() try: W.SetCursor("watch") pb = EasyDialogs.ProgressBar(maxval=10) pb.label("Formatting user name...") pb.inc() display_name = self.account["display_name"] or self.account["username"] display_name = cleanUpUnicode(display_name) acct_split = string.split(self.account["acct"], "@") acct_name = "@%s" % acct_split[0] if len(acct_split) > 1: domain_name = acct_split[1] acct_name_full = "@%s" % self.account["acct"] else: parsed_server = urlparse.urlparse(prefs.server) dprint(parsed_server) domain_name = parsed_server[1] acct_name_full = "%s@%s" % (acct_name, domain_name) self.acct_name = acct_name self.domain_name = domain_name # Window title pb.label("Setting window title...") pb.inc() self.settitle("Macstodon %s - Profile - %s" % (VERSION, acct_name_full)) # Profile banner pb.label("Loading Banner...") pb.inc() self.panes.avgroup.banner.populate(display_name, acct_name_full) if prefs.show_banners: banner = self.parent.imagehandler.getImageFromURL(self.account["header"], "banner") if banner: self.panes.avgroup.banner.setImage(banner) self.panes.avgroup.banner.enable(0) # Follow button label if self.account["relationship"]["following"]: self.panes.avgroup.followBtn.settitle("Unfollow") # Avatar pb.label("Loading Avatar...") pb.inc() if prefs.show_avatars: avatar = self.parent.imagehandler.getImageFromURL(self.account["avatar"], "account") if avatar: self.panes.avgroup.avatar.setImage(avatar) # Note pb.label("Loading Note...") pb.inc() if self.account["relationship"]["note"] != "": self.panes.avgroup.note.show(1) content = self.account["relationship"]["note"] content = string.replace(content, "\n", "\r") self.panes.avgroup.note.set(content) # Stats pb.label("Loading Stats...") pb.inc() self.panes.tlpanes.info.setToots(self.account["statuses_count"]) self.panes.tlpanes.info.setFollowers(self.account["followers_count"]) self.panes.tlpanes.info.setFollowing(self.account["following_count"]) self.panes.tlpanes.info.setLocked(self.account.get("locked", 0)) self.panes.tlpanes.info.setBot(self.account.get("bot", 0)) self.panes.tlpanes.info.setDiscoverable(self.account.get("discoverable", 0)) self.panes.tlpanes.info.setNoIndex(self.account.get("noindex", 0)) self.panes.tlpanes.info.setMoved(self.account.get("moved", 0)) self.panes.tlpanes.info.setSuspended(self.account.get("suspended", 0)) self.panes.tlpanes.info.setLimited(self.account.get("limited", 0)) # Bio pb.label("Loading Bio...") pb.inc() bio = cleanUpUnicode(self.account["note"]) bio = string.replace(bio, "
", "\r") bio = string.replace(bio, "
", "\r") bio = string.replace(bio, "
", "\r") bio = string.replace(bio, "

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

", "\r\r") # Extract links in bio bio_le = LinkExtractor() bio_le.feed(bio) bio_le.close() # Strip remaining HTML from bio bio = re.sub('<[^<]+?>', '', bio) self.panes.tlpanes.info.setBio(bio) # Links pb.label("Loading Links...") pb.inc() linksData = [] # Field links for field in self.account["fields"]: name = cleanUpUnicode(field["name"]) if field["verified_at"] is not None: name = "√ " + name value = cleanUpUnicode(field["value"]) field_le = LinkExtractor() field_le.feed(value) field_le.close() for desc, url in field_le.anchors.items(): value = url[0] linksData.append(name + "\r" + value) # Bio links (i.e. hashtags) for desc, url in bio_le.anchors.items(): linksData.append(desc + "\r" + url[0]) self.panes.tlpanes.info.setLinks(linksData) # Interaction Menu pb.label("Building Interaction Menu...") pb.inc() self.buildInteractionMenu() # Cleanup pb.label("Done.") pb.inc() time.sleep(0.5) del pb W.SetCursor("arrow") except KeyboardInterrupt: # the user pressed cancel in the progress bar window W.SetCursor("arrow") self.close() return None # Timeline initial_toots = int(prefs.toots_to_load_startup) if initial_toots: self.refreshTimelineCallback(initial_toots) # ####################### # Menu Handling Functions # ####################### def buildInteractionMenu(self): if self.account["relationship"]["muting"]: muteLabel = "Unmute %s" else: muteLabel = "Mute %s" if self.account["relationship"]["blocking"]: blockLabel = "Unblock %s" else: blockLabel = "Block %s" self.panes.avgroup.actionsmenu.set([ ("Mention %s" % self.acct_name, self.mentionCallback), ("Direct Message %s" % self.acct_name, self.dmCallback), "-", ("Open Original Page", self.openPageCallback), "-", ("Set note for %s" % self.acct_name, self.setNoteCallback), "-", (muteLabel % self.acct_name, self.muteCallback), (blockLabel % self.acct_name, self.blockCallback) ]) def can_logout(self, menuitem): """ Enable the Logout menu item when the Timeline window is open and active. """ return 1 def domenu_logout(self, *args): """ Log out when the Logout menu item is selected. """ self.parent.timelinewindow.close() def can_prefs(self, menuitem): """ Enable the Preferences menu item when the Timeline window is open and active. """ return 1 def domenu_prefs(self, *args): """ Open the Preferences window when the Preferences menu item is selected. """ win = self.parent.PrefsWindow() win.open() # ################## # Callback Functions # ################## def mentionCallback(self): """ Run when "Mention " is selected from the interaction menu """ self.parent.tootwindow = self.parent.TootWindow(replyUser=self.account) self.parent.tootwindow.open() def dmCallback(self): """ Run when "Direct Message " is selected from the interaction menu """ self.parent.tootwindow = self.parent.TootWindow(replyUser=self.account, visibility="direct") self.parent.tootwindow.open() def openPageCallback(self): """ Run when "Open Original Page" is selected from the interaction menu """ prefs = self.parent.getprefs() ic.launchurl("%s/@%s" % (prefs.server, self.account["acct"])) def setNoteCallback(self): """ Run when "Set note for " is selected from the interaction menu """ note = EasyDialogs.AskString( "Please enter a new note for this user, or leave blank to remove any existing note." ) if note is not None: req_data = { "comment": note } path = "/api/v1/accounts/%s/note" % self.account["id"] data = handleRequest(self.parent, 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 setting user note:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when setting user note:\r\r %s" % data['error']) else: if note != "": self.panes.avgroup.note.show(1) else: self.panes.avgroup.note.show(0) self.panes.avgroup.banner.enable(1) self.panes.avgroup.banner.enable(0) self.panes.avgroup.actionsmenu.draw() self.panes.avgroup.avatar.draw() self.panes.avgroup.note.set(note) def muteCallback(self): """ Run when "Mute " is selected from the interaction menu """ if self.account["relationship"]["muting"]: # already muting, undo it action = "unmute" else: # not muting yet action = "mute" try: okCancelDialog("Are you sure you want to mute @%s indefinitely?" % self.account["acct"]) except KeyboardInterrupt: return path = "/api/v1/accounts/%s/%s" % (self.account["id"], action) data = handleRequest(self.parent, 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 trying to %s user:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when trying to %s user:\r\r %s" % (action, data['error'])) else: self.account["relationship"] = data self.buildInteractionMenu() okDialog("User %sd successfully!" % action) def blockCallback(self): """ Run when "Block " is selected from the interaction menu """ if self.account["relationship"]["blocking"]: # already blocking, undo it action = "unblock" else: # not blocking yet action = "block" try: okCancelDialog("Are you sure you want to block @%s?" % self.account["acct"]) except KeyboardInterrupt: return path = "/api/v1/accounts/%s/%s" % (self.account["id"], action) data = handleRequest(self.parent, 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 user:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing user:\r\r %s" % (action, data['error'])) else: self.account["relationship"] = data self.buildInteractionMenu() okDialog("User %sed successfully!" % action) def followCallback(self): """ Run when the user clicks the Follow (Unfollow) button """ if self.account["relationship"]["following"]: # already following, undo it action = "unfollow" else: # not following yet action = "follow" path = "/api/v1/accounts/%s/%s" % (self.account["id"], action) data = handleRequest(self.parent, 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 user:\r\r %s" % (action, data['error_description'])) elif data.get("error") is not None: okDialog("Server error when %sing user:\r\r %s" % (action, data['error'])) else: self.account["relationship"] = data okDialog("User %sed successfully!" % action) if action == "follow": self.panes.avgroup.followBtn.settitle("Unfollow") else: self.panes.avgroup.followBtn.settitle("Follow") def timelineClickCallback(self): """ Run when the user clicks somewhere in the named timeline """ list = self.panes.tlpanes.timeline selected = list.getselection() if len(selected) < 1: self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.panes.tootgroup.toottxt.set(self.defaulttext) return else: index = selected[0] toot = self.timeline[index] self.formatAndDisplayToot(toot) def refreshTimelineCallback(self, limit=None): """ Run when the user clicks the Refresh button above the timeline """ self.updateTimeline(limit) self.panes.tlpanes.timeline.set(self.formatTimelineForList()) 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.parent.tootwindow = self.parent.TootWindow(replyTo=toot) self.parent.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.parent, 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) if action == "reblog": self.panes.tootgroup.boost.setImage(self.parent.pctBstClr) else: self.panes.tootgroup.boost.setImage(self.parent.pctBstBnW) 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.parent, 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) if action == "favourite": self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) 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.parent, 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) if action == "bookmark": self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) else: okDialog("Please select a toot first.") def linksCallback(self): """ Displays a dialog containing the links in the toot and allows the user to open them. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: content = toot["content"] # Replace HTML linebreak tags with actual linebreaks content = cleanUpUnicode(content) # Extract links le = LinkExtractor() le.feed(content) le.close() linksDialog(le) else: okDialog("Please select a toot first.") def attachmentsCallback(self): toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: attachmentsDialog(toot["media_attachments"]) 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 """ # clear existing toot self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.panes.tootgroup.toottxt.set("Loading toot...") 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) 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"] favourited = toot["reblog"]["favourited"] reblogged = toot["reblog"]["reblogged"] bookmarked = toot["reblog"]["bookmarked"] else: 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"] favourited = toot["favourited"] reblogged = toot["reblogged"] bookmarked = toot["bookmarked"] # 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: self.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 le = LinkExtractor() le.feed(content) le.close() # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) # Render content into UI self.panes.tootgroup.reply.setImage(self.parent.pctRplBnW) if favourited: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) if reblogged: self.panes.tootgroup.boost.setImage(self.parent.pctBstClr) else: self.panes.tootgroup.boost.setImage(self.parent.pctBstBnW) if bookmarked: self.panes.tootgroup.bmark.setImage(self.parent.pctBkmClr) else: self.panes.tootgroup.bmark.setImage(self.parent.pctBkmBnW) self.panes.tootgroup.links.setImage(self.parent.pctLnkBnW) self.panes.tootgroup.attch.setImage(self.parent.pctAtcBnW) self.panes.tootgroup.toottxt.setTitle(title) self.panes.tootgroup.toottxt.set(content) self.panes.tootgroup.fvnum.set(str(favourites_count)) self.panes.tootgroup.bonum.set(str(reblogs_count)) self.panes.tootgroup.rpnum.set(str(replies_count)) def formatTimelineForList(self): """ Formats toots for display in a timeline list """ listitems = [] for toot in self.timeline: 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 # ################ # Helper Functions # ################ def getRelationship(self): """ Returns properties related to the relationship between the logged-in user and the user whos profile is being viewed """ # can't use urllib here because py1.5.2 doesn't support the [] syntax path = "/api/v1/accounts/relationships?id[]=" + self.account["id"] data = handleRequest(self.parent, path, use_token=1) if not data: # handleRequest failed and should have popped an error dialog return 0 # if data is a list, it worked if type(data) == type([]): return data[0] # if data is a dict, it failed elif type(data) == type({}) and data.get("error") is not None: okDialog("Server error when reading account relationship:\r\r %s" % data['error']) return 0 # i don't think this is reachable, but just in case... else: okDialog("Server error when reading account relationship. Unable to determine data type.") return 0 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. """ timeline = self.panes.tlpanes.timeline selected = timeline.getselection() if len(selected) > 0: index = selected[0] toot = self.timeline[index] timeline = self.timeline else: return None, None, None, None if toot.get("reblog") and resolve_boosts: return toot["reblog"], toot, timeline, index else: return toot, None, timeline, index def updateTimeline(self, limit = None): """ Pulls a timeline from the server and updates the global dict """ params = {} app = self.parent prefs = app.getprefs() if limit: # If a limit was explicitly set in the call, use that params["limit"] = limit else: # Otherwise, use the refresh limit from the prefs if one was set refresh_toots = int(prefs.toots_to_load_refresh) if refresh_toots: params["limit"] = refresh_toots if len(self.timeline) > 0: params["min_id"] = self.timeline[0]["id"] path = "/api/v1/accounts/%s/statuses" % self.account["id"] encoded_params = urllib.urlencode(params) if encoded_params: path = path + "?" + encoded_params data = handleRequest(self.parent, path, 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.timeline.insert(0, data[i]) self.timeline = self.timeline[:int(prefs.toots_per_timeline)] # if data is a dict, it failed elif type(data) == type({}) and data.get("error") is not None: okDialog("Server error when refreshing timeline:\r\r %s" % data['error']) # i don't think this is reachable, but just in case... else: okDialog("Server error when refreshing timeline. Unable to determine data type.") \ No newline at end of file diff --git a/README.md b/README.md old mode 100644 new mode 100755 index c84a13c..1b4c3be --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ 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, or, any Power Macintosh -* At least 1.5 MB of free memory (more if you want to be able to view avatars) +* At least 1.5 MB of free memory (4 MB to view avatars, 8 MB to view banners) * 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 @@ -25,12 +25,24 @@ The following extensions are required for System 7 users, and can be found in th

Timeline View

+

+ Profile View +

Replying to a Toot

Content Warning Dialog

+

+ Viewing Links in a Toot +

+

+ Viewing Attachments in a Toot +

+

+ Editing Preferences +

## Features @@ -40,6 +52,10 @@ The following extensions are required for System 7 users, and can be found in th * View your notifications * Favourite, boost, and bookmark toots * Reply to toots from others +* Follow links in toots +* Download attachments from toots +* Look up users by handle and view their profiles +* Follow/unfollow, mute/unmute, and block/unblock users That's it for now. Maybe more features will be implemented in a later version. @@ -89,10 +105,10 @@ 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. -* 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 support for Unicode whatsoever, and there never will be. Toots or usernames with emojis and special characters in them will have those characters removed. * 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) +* There is a nasty memory leak around the loading of uncached banner images. Enabling the option to view banner images will cause Macstodon to run out of memory and crash pretty quickly. ## Troubleshooting When in doubt, delete the preferences file. It is named `Macstodon Prefs` and lives in the Preferences folder in your System Folder. Deleting the preferences file will make Macstodon forget about the saved server, tokens, etc. @@ -100,14 +116,8 @@ When in doubt, delete the preferences file. It is named `Macstodon Prefs` and li There is also a subfolder of the Preferences folder named Macstodon Cache. This folder contains avatars and other images that have been resized, so we don't need to download and resize them again the next time we encounter them in the wild. Occasionally this can become corrupted and an original image can be cached instead of a resized one, leading to poor performance and high memory usage. If this happens, you can delete this folder, it will be recreated on the next launch. ## Credits -Special thanks to the following third-party software, for whom without Macstodon would not be possible: - -**BeautifulSoup pre-1.3** -Copyright ©2004 Leonard Richardson -License: Python - - -Extra special thanks to: +Special thanks to the following people, for whom without Macstodon would not be possible: [Dan](https://bitbang.social/@billgoats) - for the inspiration to work on this project [Mingo](https://oldbytes.space/@mingo) - for [suggesting the name](https://oldbytes.space/@mingo/109316322622806248) -MhzModels - for the beautiful logo at the top of this README, and the Macstodon application icon! +[MhzModels](https://artsio.com/@mhzmodels) - for the beautiful logo at the top of this README, and the application icon +[CM Harrington](https://mastodon.online/@octothorpe) - for additional icon design diff --git a/TimelineHandler.py b/TimelineHandler.py deleted file mode 100644 index 0bec918..0000000 --- a/TimelineHandler.py +++ /dev/null @@ -1 +0,0 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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/TimelineWindow.py b/TimelineWindow.py new file mode 100755 index 0000000..ffd6ba3 --- /dev/null +++ b/TimelineWindow.py @@ -0,0 +1 @@ +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 from EasyDialogs import AskString # ########## # My Imports # ########## from MacstodonConstants import VERSION from MacstodonHelpers import attachmentsDialog, cleanUpUnicode, dprint, handleRequest, \ ImageWidget, LinkExtractor, linksDialog, okDialog, okCancelDialog, TitledEditText, \ TimelineList # ########### # Application # ########### class TimelineWindow(W.Window): def __init__(self): """ Initializes the TimelineWindow class. """ # 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) self.defaulttext = "Click on a toot or notification in one of the above lists..." self.timelines = { "home": [], "local": [], "notifications": [] } w = W.Window.__init__(self, bounds, "Macstodon %s - Timeline" % VERSION, minsize=(512, 342)) self.setupwidgets() # ######################### # Window Handling Functions # ######################### def setupwidgets(self): """ Defines the Timeline window """ self.panes = W.HorizontalPanes((8, 8, -8, -20), (0.6, 0.4)) self.panes.tlpanes = W.VerticalPanes(None, (0.34, 0.33, 0.33)) self.panes.tlpanes.home = TimelineList( None, "Home Timeline", self.timelines["home"], btnCallback=self.refreshHomeCallback, callback=self.homeClickCallback, flags=Lists.lOnlyOne ) self.panes.tlpanes.local = TimelineList( None, "Local Timeline", self.timelines["local"], btnCallback=self.refreshLocalCallback, callback=self.localClickCallback, flags=Lists.lOnlyOne ) self.panes.tlpanes.notifications = TimelineList( None, "Notifications", self.timelines["notifications"], btnCallback=self.refreshNotificationsCallback, callback=self.notificationClickCallback, flags=Lists.lOnlyOne ) self.panes.tootgroup = W.Group(None) self.panes.tootgroup.toottxt = TitledEditText( (56, 0, -24, -20), title="", text=self.defaulttext, readonly=1, vscroll=1 ) # Links and Attachment buttons self.panes.tootgroup.links = ImageWidget( (-20, 52, 16, 16), pixmap=self.parent.pctLnkDis, callback=self.linksCallback ) self.panes.tootgroup.attch = ImageWidget( (-20, 70, 16, 16), pixmap=self.parent.pctAtcDis, callback=self.attachmentsCallback ) # Avatar, reply/boost/favourite/bookmark buttons self.panes.tootgroup.authorimg = ImageWidget((0, 0, 24, 24), callback=self.avatarClickCallback) self.panes.tootgroup.boosterimg = ImageWidget((24, 24, 24, 24), callback=self.boosterClickCallback) self.panes.tootgroup.reply = ImageWidget( (4, 52, 16, 16), pixmap=self.parent.pctRplDis, callback=self.replyCallback ) self.panes.tootgroup.rpnum = W.TextBox((24, 55, 28, 16), "") self.panes.tootgroup.favrt = ImageWidget( (4, 70, 16, 16), pixmap=self.parent.pctFvtDis, callback=self.favouriteCallback ) self.panes.tootgroup.fvnum = W.TextBox((24, 73, 28, 16), "") self.panes.tootgroup.boost = ImageWidget( (4, 88, 16, 16), pixmap=self.parent.pctBstDis, callback=self.boostCallback ) self.panes.tootgroup.bonum = W.TextBox((24, 91, 28, 16), "") self.panes.tootgroup.bmark = ImageWidget( (4, 106, 16, 16), pixmap=self.parent.pctBkmDis, callback=self.bookmarkCallback ) self.panes.tootgroup.logoutbutton = W.Button((56, -15, 80, 0), "Logout", self.close) self.panes.tootgroup.prefsbutton = W.Button((146, -15, 80, 0), "Preferences", self.prefsCallback) self.panes.tootgroup.profilebutton = W.Button((-170, -15, 80, 0), "Find User", self.profileCallback) self.panes.tootgroup.tootbutton = W.Button((-80, -15, 80, 0), "Post Toot", self.tootCallback) def open(self): """ When the timeline window is opened, populate the timelines. """ app = self.parent prefs = app.getprefs() W.Window.open(self) initial_toots = int(prefs.toots_to_load_startup) if initial_toots: self.refreshHomeCallback(initial_toots) self.refreshLocalCallback(initial_toots) self.refreshNotificationsCallback(initial_toots) def close(self): """ When the timeline window is closed, return to the login window. """ for window in self.parent.profilewindows.values(): window.close() self.parent.loginwindow = self.parent.LoginWindow() self.parent.loginwindow.open() W.Window.close(self) # ####################### # Menu Handling Functions # ####################### def can_logout(self, menuitem): """ Enable the Logout menu item when the Timeline window is open and active. """ return 1 def domenu_logout(self, *args): """ Log out when the Logout menu item is selected. """ self.close() def can_prefs(self, menuitem): """ Enable the Preferences menu item when the Timeline window is open and active. """ return 1 def domenu_prefs(self, *args): """ Open the Preferences window when the Preferences menu item is selected. """ win = self.parent.PrefsWindow() win.open() # ################## # Callback Functions # ################## def profileCallback(self): """ Prompt the user for a username when the Find User button is clicked """ acctname = AskString( "Please enter a user and domain name to view their profile.", "username@example.com" ) if acctname: req_data = { "acct": acctname } path = "/api/v1/accounts/lookup?%s" % urllib.urlencode(req_data) data = handleRequest(self.parent, path) 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 querying user ID:\r\r %s" % data['error_description']) elif data.get("error") is not None: okDialog("Server error when querying user ID:\r\r %s" % data['error']) else: win = self.parent.ProfileWindow(data) win.open() def prefsCallback(self): """ Open the Preferences window when the Preferences button is clicked. """ win = self.parent.PrefsWindow() win.open() def avatarClickCallback(self): """ Run when clicking on a user's avatar. Opens the user's profile. """ dprint("clicked on avatar") toot, _, _, _ = self.getSelectedToot(resolve_boosts=1) if toot: win = self.parent.ProfileWindow(toot["account"]) win.open() else: okDialog("Please select a toot, then click on the author's avatar to view their profile.") def boosterClickCallback(self): dprint("clicked on booster") toot, _, _, _ = self.getSelectedToot(resolve_boosts=0) if toot: win = self.parent.ProfileWindow(toot["account"]) win.open() else: okDialog("Please select a toot, then click on the author's avatar to view their profile.") def timelineClickCallback(self, name): """ Run when the user clicks somewhere in the named timeline """ if name == "home": list = self.panes.tlpanes.home elif name == "local": list = self.panes.tlpanes.local selected = list.getselection() if len(selected) < 1: self.panes.tootgroup.authorimg.clearImage() self.panes.tootgroup.boosterimg.clearImage() self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.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 """ self.panes.tlpanes.local.setselection([-1]) self.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("home") def localClickCallback(self): """ Run when the user clicks somewhere in the local timeline """ self.panes.tlpanes.home.setselection([-1]) self.panes.tlpanes.notifications.setselection([-1]) self.timelineClickCallback("local") def notificationClickCallback(self): """ Run when the user clicks somewhere in the notification timeline """ self.panes.tlpanes.home.setselection([-1]) self.panes.tlpanes.local.setselection([-1]) list = self.panes.tlpanes.notifications selected = list.getselection() if len(selected) < 1: self.panes.tootgroup.authorimg.clearImage() self.panes.tootgroup.boosterimg.clearImage() self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.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) elif notification["type"] == "admin.report": okDialog("Sorry, displaying the notification type '%s' is not supported yet" % notification["type"]) self.panes.tootgroup.authorimg.clearImage() self.panes.tootgroup.boosterimg.clearImage() self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.panes.tootgroup.toottxt.set(self.defaulttext) else: win = self.parent.ProfileWindow(notification["account"]) win.open() self.panes.tootgroup.authorimg.clearImage() self.panes.tootgroup.boosterimg.clearImage() self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.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.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.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.panes.tlpanes.notifications.set(listitems) def tootCallback(self): """ Run when the user clicks the "Post Toot" button from the timeline window. It opens up the toot window. """ self.parent.tootwindow = self.parent.TootWindow() self.parent.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.parent.tootwindow = self.parent.TootWindow(replyTo=toot) self.parent.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.parent, 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) if action == "reblog": self.panes.tootgroup.boost.setImage(self.parent.pctBstClr) else: self.panes.tootgroup.boost.setImage(self.parent.pctBstBnW) 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.parent, 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) if action == "favourite": self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) 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.parent, 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) if action == "bookmark": self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) else: okDialog("Please select a toot first.") def linksCallback(self): """ Displays a dialog containing the links in the toot and allows the user to open them. """ toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: content = toot["content"] # Replace HTML linebreak tags with actual linebreaks content = cleanUpUnicode(content) # Extract links le = LinkExtractor() le.feed(content) le.close() linksDialog(le) else: okDialog("Please select a toot first.") def attachmentsCallback(self): toot, origToot, timeline, index = self.getSelectedToot(resolve_boosts=1) if toot: attachmentsDialog(toot["media_attachments"]) 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 """ prefs = self.parent.getprefs() # clear existing toot self.panes.tootgroup.authorimg.clearImage() self.panes.tootgroup.boosterimg.clearImage() self.panes.tootgroup.reply.setImage(self.parent.pctRplDis) self.panes.tootgroup.favrt.setImage(self.parent.pctFvtDis) self.panes.tootgroup.boost.setImage(self.parent.pctBstDis) self.panes.tootgroup.bmark.setImage(self.parent.pctBkmDis) self.panes.tootgroup.links.setImage(self.parent.pctLnkDis) self.panes.tootgroup.attch.setImage(self.parent.pctAtcDis) self.panes.tootgroup.fvnum.set("") self.panes.tootgroup.bonum.set("") self.panes.tootgroup.rpnum.set("") self.panes.tootgroup.toottxt.setTitle("") self.panes.tootgroup.toottxt.set("Loading toot...") display_name = toot["account"]["display_name"] or toot["account"]["username"] display_name = cleanUpUnicode(display_name) if toot["reblog"]: self.panes.tootgroup.authorimg.resize(24,24) if prefs.show_avatars: image = self.parent.imagehandler.getImageFromURL(toot["reblog"]["account"]["avatar"], "account") bimage = self.parent.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") else: image = None bimage = None 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"] favourited = toot["reblog"]["favourited"] reblogged = toot["reblog"]["reblogged"] bookmarked = toot["reblog"]["bookmarked"] else: self.panes.tootgroup.authorimg.resize(48,48) if prefs.show_avatars: image = self.parent.imagehandler.getImageFromURL(toot["account"]["avatar"], "account") else: image = None 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"] favourited = toot["favourited"] reblogged = toot["reblogged"] bookmarked = toot["bookmarked"] # 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: self.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 le = LinkExtractor() le.feed(content) le.close() # Strip all other HTML tags content = re.sub('<[^<]+?>', '', content) # Render content into UI if image: self.panes.tootgroup.authorimg.setImage(image) if bimage: self.panes.tootgroup.boosterimg.setImage(bimage) self.panes.tootgroup.reply.setImage(self.parent.pctRplBnW) if favourited: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtClr) else: self.panes.tootgroup.favrt.setImage(self.parent.pctFvtBnW) if reblogged: self.panes.tootgroup.boost.setImage(self.parent.pctBstClr) else: self.panes.tootgroup.boost.setImage(self.parent.pctBstBnW) if bookmarked: self.panes.tootgroup.bmark.setImage(self.parent.pctBkmClr) else: self.panes.tootgroup.bmark.setImage(self.parent.pctBkmBnW) self.panes.tootgroup.links.setImage(self.parent.pctLnkBnW) self.panes.tootgroup.attch.setImage(self.parent.pctAtcBnW) self.panes.tootgroup.toottxt.setTitle(title) self.panes.tootgroup.toottxt.set(content) self.panes.tootgroup.fvnum.set(str(favourites_count)) self.panes.tootgroup.bonum.set(str(reblogs_count)) self.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"] display_name = cleanUpUnicode(display_name) 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. """ homeTimeline = self.panes.tlpanes.home localTimeline = self.panes.tlpanes.local notificationsTimeline = self.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 = {} app = self.parent prefs = app.getprefs() if limit: # If a limit was explicitly set in the call, use that params["limit"] = limit else: # Otherwise, use the refresh limit from the prefs if one was set refresh_toots = int(prefs.toots_to_load_refresh) if refresh_toots: params["limit"] = refresh_toots 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) if encoded_params: path = path + "?" + encoded_params data = handleRequest(self.parent, path, 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]) self.timelines[name] = self.timelines[name][:int(prefs.toots_per_timeline)] # 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 deleted file mode 100644 index 3d39d64..0000000 --- a/TootHandler.py +++ /dev/null @@ -1 +0,0 @@ -""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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/TootWindow.py b/TootWindow.py new file mode 100755 index 0000000..eed09a9 --- /dev/null +++ b/TootWindow.py @@ -0,0 +1 @@ +""" Macstodon - a Mastodon client for classic Mac OS MIT License Copyright (c) 2022-2023 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 TootWindow(W.ModalDialog): def __init__(self, replyTo=None, replyUser=None, visibility="public"): """ Initializes the TootWindow class. """ self.replyTo = replyTo self.replyUser = replyUser self.visibility = visibility W.ModalDialog.__init__(self, (320, 210), "Macstodon %s - Toot" % VERSION) self.setupwidgets() # ######################### # Window Handling Functions # ######################### def setupwidgets(self): """ Defines the Toot window. """ # this isn't the best place for this code to go # but the app crashes if i try to run it earlier prefs = self.parent.getprefs() if not prefs.max_toot_chars: prefs.max_toot_chars = self.getMaxTootChars() prefs.save() heading = "Type your toot below (max %s characters):" % prefs.max_toot_chars if self.replyTo: title = "Replying to %s:" % self.replyTo["account"]["acct"] text = "@%s " % self.replyTo["account"]["acct"] content = self.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) self.reply = TitledEditText((10, 6, -10, -140), title=title, text=content, readonly=1, vscroll=1) self.toot = TitledEditText((10, 76, -10, -70), title=heading, text=text, vscroll=1) else: if self.replyUser: text = "@%s " % self.replyUser["acct"] else: text = "" self.toot = TitledEditText((10, 6, -10, -70), title=heading, text=text, vscroll=1) # Visibility radio buttons visButtons = [] self.vis_public = W.RadioButton((10, 145, 55, 16), "Public", visButtons, self.visPublicCallback) self.vis_unlisted = W.RadioButton((75, 145, 65, 16), "Unlisted", visButtons, self.visUnlistedCallback) self.vis_followers = W.RadioButton((150, 145, 75, 16), "Followers", visButtons, self.visFollowersCallback) self.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 self.replyTo: self.visibility = self.replyTo["visibility"] if self.visibility == "unlisted": self.vis_unlisted.set(1) elif self.visibility == "private": self.vis_followers.set(1) elif self.visibility == "direct": self.vis_mentioned.set(1) else: self.vis_public.set(1) # Content warning checkbox and text field self.cw = W.CheckBox((10, -45, 30, 16), "CW", self.cwCallback) self.cw_text = W.EditText((50, -45, -10, 16)) # Close button self.close_btn = W.Button((10, -22, 60, 16), "Close", self.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. self.toot_btn = W.Button((-69, -22, 60, 16), "Toot!", self.tootCallback) def open(self): W.Window.open(self) # If replying to a toot with a CW, apply the CW to the reply. # For new toots, default to the CW off. if self.replyTo: if self.replyTo["sensitive"]: self.cw.set(1) if self.replyTo["spoiler_text"][:3] == "re:": self.cw_text.set(self.replyTo["spoiler_text"]) else: self.cw_text.set("re: " + self.replyTo["spoiler_text"]) else: self.cw_text.enable(0) self.cw_text.show(0) else: self.cw_text.enable(0) self.cw_text.show(0) def close(self): W.Window.close(self) # ################## # Callback Functions # ################## def cwCallback(self): """ Called when the CW checkbox is ticked or unticked. Used to show/hide the CW text entry field. """ self.cw_text.select(not self.cw_text._visible) self.cw_text.show(not self.cw_text._visible) self.cw_text.enable(not self.cw_text._enabled) 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.toot.get(), "visibility": self.visibility } if self.replyTo: req_data["in_reply_to_id"] = self.replyTo["id"] if self.cw.get() == 1: req_data["sensitive"] = "true" req_data["spoiler_text"] = self.cw_text.get() path = "/api/v1/statuses" data = handleRequest(self.parent, 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.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.parent, path) if not data: dprint("No data returned from server when getting max toot chars") 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: try: # Try the Mastodon 4 official api method first max_toot_chars = data["configuration"]["statuses"]["max_characters"] dprint("max chars: %s" % max_toot_chars) except KeyError: # Fall back to the glitch-soc method 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/readme_screenshots/attachments.png b/readme_screenshots/attachments.png new file mode 100644 index 0000000..5539c4a Binary files /dev/null and b/readme_screenshots/attachments.png differ diff --git a/readme_screenshots/links.png b/readme_screenshots/links.png new file mode 100644 index 0000000..ef2616e Binary files /dev/null and b/readme_screenshots/links.png differ diff --git a/readme_screenshots/prefs.png b/readme_screenshots/prefs.png new file mode 100644 index 0000000..2b5c5e8 Binary files /dev/null and b/readme_screenshots/prefs.png differ diff --git a/readme_screenshots/profile.png b/readme_screenshots/profile.png new file mode 100644 index 0000000..1070903 Binary files /dev/null and b/readme_screenshots/profile.png differ diff --git a/readme_screenshots/timeline.png b/readme_screenshots/timeline.png index 89cdda7..cf37021 100644 Binary files a/readme_screenshots/timeline.png and b/readme_screenshots/timeline.png differ diff --git a/third_party/BeautifulSoup.py b/third_party/BeautifulSoup.py deleted file mode 100755 index 04d4284..0000000 --- a/third_party/BeautifulSoup.py +++ /dev/null @@ -1 +0,0 @@ -"""Beautiful Soup Elixir and Tonic "The Screen-Scraper's Friend" The BeautifulSoup class turns arbitrarily bad HTML into a tree-like nested tag-soup list of Tag objects and text snippets. A Tag object corresponds to an HTML tag. It knows about the HTML tag's attributes, and contains a representation of everything contained between the original tag and its closing tag (if any). It's easy to extract Tags that meet certain criteria. A well-formed HTML document will yield a well-formed data structure. An ill-formed HTML document will yield a correspondingly ill-formed data structure. If your document is only locally well-formed, you can use this to process the well-formed part of it. At the end of this file is a runnable script with lots of examples. You can also see real-world examples at: http://www.crummy.com/software/BeautifulSoup/examples.html This library has no external dependencies. It works with Python 1.5.2 and up. If you can install a Python extension, you might want to use the ElementTree Tidy HTML Tree Builder instead: http://www.effbot.org/zone/element-tidylib.htm You can use BeautifulSoup on any SGML-like substance, such as XML or a domain-specific language that looks like HTML but has different tag names. For such purposes you may want to use the BeautifulStoneSoup class, which knows nothing at all about HTML per se. I also reserve the right to make the BeautifulSoup parser smarter between releases, so if you want forwards-compatibility without having to think about it, you might want to go with BeautifulStoneSoup. Release status: (I do a new release whenever I make a change that breaks backwards compatibility.) Current release: The desired value of an attribute can now be any of the following: * A string * A string with SQL-style wildcards * A compiled RE object * A callable that returns None/false/empty string if the given value doesn't match, and any other value otherwise. This is much easier to use than SQL-style wildcards (see, regular expressions are good for something). Because of this, I no longer recommend you use SQL-style wildcards. They may go away in a future release to clean up the code. Made Beautiful Soup handle processing instructions as text instead of ignoring them. Applied patch from Richie Hindle (richie at entrian dot com) that makes tag.string a shorthand for tag.contents[0].string when the tag has only one string-owning child. Added still more nestable tags. The nestable tags thing won't work in a lot of cases and needs to be rethought. Fixed an edge case where searching for "%foo" would match any string shorter than "foo". 1.2 "Who for such dainties would not stoop?" (2004/07/08): Applied patch from Ben Last (ben at benlast dot com) that made Tag.renderContents() correctly handle Unicode. Made BeautifulStoneSoup even dumber by making it not implicitly close a tag when another tag of the same type is encountered; only when an actual closing tag is encountered. This change courtesy of Fuzzy (mike at pcblokes dot com). BeautifulSoup still works as before. 1.1 "Swimming in a hot tureen": Added more 'nestable' tags. Changed popping semantics so that when a nestable tag is encountered, tags are popped up to the previously encountered nestable tag (of whatever kind). I will revert this if enough people complain, but it should make more people's lives easier than harder. This enhancement was suggested by Anthony Baxter (anthony at interlink dot com dot au). 1.0 "So rich and green": Initial release. """ __author__ = "Leonard Richardson (leonardr@segfault.org)" __version__ = "1.1 $Revision: 1.18 $" __date__ = "$Date: 2004/10/18 00:14:20 $" __copyright__ = "Copyright (c) 2004 Leonard Richardson" __license__ = "Python" from sgmllib import SGMLParser import string import types class PageElement: """Contains the navigational information for some part of the page (either a tag or a piece of text)""" def __init__(self, parent=None, previous=None): self.parent = parent self.previous = previous self.next = None class NavigableText(PageElement): """A simple wrapper around a string that keeps track of where in the document the string was found. Doesn't implement all the string methods because I'm lazy. You could have this extend UserString if you were using 2.2.""" def __init__(self, string, parent=None, previous=None): PageElement.__init__(self, parent, previous) self.string = string def __eq__(self, other): return self.string == str(other) def __str__(self): return self.string def strip(self): return self.string.strip() class Tag(PageElement): """Represents a found HTML tag with its attributes and contents.""" def __init__(self, name, attrs={}, parent=None, previous=None): PageElement.__init__(self, parent, previous) self.name = name self.attrs = attrs self.contents = [] self.foundClose = 0 def get(self, key, default=None): return self._getAttrMap().get(key, default) def __call__(self, *args): return apply(self.fetch, args) def __getitem__(self, key): return self._getAttrMap()[key] def __setitem__(self, key, value): self._getAttrMap() self.attrMap[key] = value for i in range(0, len(self.attrs)): if self.attrs[i][0] == key: self.attrs[i] = (key, value) def _getAttrMap(self): if not hasattr(self, 'attrMap'): self.attrMap = {} for (key, value) in self.attrs: self.attrMap[key] = value return self.attrMap def __repr__(self): return str(self) def __ne__(self, other): return not self == other def __eq__(self, other): if not isinstance(other, Tag) or self.name != other.name or self.attrs != other.attrs or len(self.contents) != len(other.contents): return 0 for i in range(0, len(self.contents)): if self.contents[i] != other.contents[i]: return 0 return 1 def __str__(self): attrs = '' if self.attrs: for key, val in self.attrs: attrs = attrs + ' %s="%s"' % (key, val) close = '' closeTag = '' if self.isSelfClosing(): close = ' /' elif self.foundClose: closeTag = '' % self.name s = self.renderContents() if not hasattr(self, 'hideTag'): s = '<%s%s%s>' % (self.name, attrs, close) + s + closeTag return s def renderContents(self): s='' #non-Unicode for c in self.contents: try: s = s + str(c) except UnicodeEncodeError: if type(s) <> types.UnicodeType: s = s.decode('utf8') #convert ascii to Unicode #str() should, strictly speaking, not return a Unicode #string, but NavigableText never checks and will return #Unicode data if it was initialised with it. s = s + str(c) return s def isSelfClosing(self): return self.name in BeautifulSoup.SELF_CLOSING_TAGS def append(self, tag): self.contents.append(tag) def first(self, name=None, attrs={}, contents=None, recursive=1): r = None l = self.fetch(name, attrs, contents, recursive) if l: r = l[0] return r def _sqlStyleStringMatch(self, match, matchAgainst): result = (match == matchAgainst) if len(matchAgainst) > 1 and matchAgainst[0] == '%' and matchAgainst[-1] == '%' and matchAgainst[-2] != '\\': result = (match.find(matchAgainst[1:-1]) != -1) elif matchAgainst[0] == '%': findVal = match.rfind(matchAgainst[1:]) result = findVal != -1 and findVal == len(match)-len(matchAgainst)+1 elif matchAgainst[-1] == '%': result = match.find(matchAgainst[:-1]) == 0 return result def fetch(self, name=None, attrs={}, contents=None, recursive=1): """Extracts Tag objects that match the given criteria. You can specify the name of the Tag, any attributes you want the Tag to have, and what text and Tags you want to see inside the Tag.""" if contents and type(contents) != type([]): contents = [contents] results = [] for i in self.contents: if isinstance(i, Tag): if not name or i.name == name: match = 1 for attr, matchAgainst in attrs.items(): match = i.get(attr) #By default, find the specific matchAgainst called for. #Use SQL-style wildcards to find substrings, prefix, #suffix, etc. Or use a regular expression object #to do RE matching. result = (match == matchAgainst) if match and matchAgainst: if type(matchAgainst) == types.StringType: #It's a string, possibly with SQL wildcards. result = self._sqlStyleStringMatch(match, matchAgainst) elif hasattr(matchAgainst, 'match'): #It's a regexp object result = matchAgainst.match(match) else: #Assume it's a predicate callable result = matchAgainst(match) if not result: match = 0 break match = match and (not contents or i.contents == contents) if match: results.append(i) if recursive: results.extend(i.fetch(name, attrs, contents, recursive)) return results class BeautifulSoup(SGMLParser, Tag): """The actual parser. It knows the following facts about HTML, and not much else: * Some tags have no closing tag and should be interpreted as being closed as soon as they are encountered. * Most tags can't be nested; encountering an open tag when there's already an open tag of that type in the stack means that the previous tag of that type should be implicitly closed. However, some tags can be nested. When a nestable tag is encountered, it's okay to close all unclosed tags up to the last nestable tag. It might not be safe to close any more, so that's all it closes. * The text inside some tags (ie. 'script') may contain tags which are not really part of the document and which should be parsed as text, not tags. If you want to parse the text as tags, you can always get it and parse it explicitly.""" SELF_CLOSING_TAGS = ['br', 'hr', 'input', 'img', 'meta', 'spacer', 'link', 'frame'] NESTABLE_TAGS = ['font', 'table', 'tr', 'td', 'th', 'tbody', 'p', 'div', 'li', 'ul', 'ol'] QUOTE_TAGS = ['script'] IMPLICITLY_CLOSE_TAGS = 1 def __init__(self, text=None): Tag.__init__(self, '[document]') SGMLParser.__init__(self) self.quoteStack = [] self.hideTag = 1 self.reset() if text: self.feed(text) def feed(self, text): SGMLParser.feed(self, text) self.endData() def reset(self): SGMLParser.reset(self) self.currentData = '' self.currentTag = None self.tagStack = [] self.pushTag(self) def popTag(self, closedTagName=None): tag = self.tagStack.pop() if closedTagName == tag.name: tag.foundClose = 1 # Tags with just one string-owning child get the same string # property as the child, so that soup.tag.string is shorthand # for soup.tag.contents[0].string if len(self.currentTag.contents) == 1 and \ hasattr(self.currentTag.contents[0], 'string'): self.currentTag.string = self.currentTag.contents[0].string #print "Pop", tag.name self.currentTag = self.tagStack[-1] return self.currentTag def pushTag(self, tag): #print "Push", tag.name if self.currentTag: self.currentTag.append(tag) self.tagStack.append(tag) self.currentTag = self.tagStack[-1] def endData(self): if self.currentData: if not string.strip(self.currentData): if '\n' in self.currentData: self.currentData = '\n' else: self.currentData = ' ' o = NavigableText(self.currentData, self.currentTag, self.previous) if self.previous: self.previous.next = o self.previous = o self.currentTag.contents.append(o) self.currentData = '' def _popToTag(self, name, closedTag=0): """Pops the tag stack up to and including the most recent instance of the given tag. If a list of tags is given, will accept any of those tags as an excuse to stop popping, and will *not* pop the tag that caused it to stop popping.""" if self.IMPLICITLY_CLOSE_TAGS: closedTag = 1 numPops = 0 mostRecentTag = None oneTag = (type(name) == types.StringType) for i in range(len(self.tagStack)-1, 0, -1): thisTag = self.tagStack[i].name if (oneTag and thisTag == name) \ or (not oneTag and thisTag in name): numPops = len(self.tagStack)-i break if not oneTag: numPops = numPops - 1 closedTagName = None if closedTag: closedTagName = name for i in range(0, numPops): mostRecentTag = self.popTag(closedTagName) return mostRecentTag def unknown_starttag(self, name, attrs): if self.quoteStack: #This is not a real tag. #print "<%s> is not real!" % name attrs = map(lambda(x, y): '%s="%s"' % (x, y), attrs) self.handle_data('<%s %s>' % (name, attrs)) return self.endData() tag = Tag(name, attrs, self.currentTag, self.previous) if self.previous: self.previous.next = tag self.previous = tag if not name in self.SELF_CLOSING_TAGS: if name in self.NESTABLE_TAGS: self._popToTag(self.NESTABLE_TAGS) else: self._popToTag(name) self.pushTag(tag) if name in self.SELF_CLOSING_TAGS: self.popTag() if name in self.QUOTE_TAGS: #print "Beginning quote (%s)" % name self.quoteStack.append(name) def unknown_endtag(self, name): if self.quoteStack and self.quoteStack[-1] != name: #This is not a real end tag. #print " is not real!" % name self.handle_data('' % name) return self.endData() self._popToTag(name, 1) if self.quoteStack and self.quoteStack[-1] == name: #print "That's the end of %s!" % self.quoteStack[-1] self.quoteStack.pop() def handle_data(self, data): self.currentData = self.currentData + data def handle_pi(self, text): "Propagate processing instructions right through." self.handle_data("" % text) def handle_comment(self, text): "Propagate comments right through." self.handle_data("" % text) def handle_charref(self, ref): "Propagate char refs right through." self.handle_data('&#%s;' % ref) def handle_entityref(self, ref): "Propagate entity refs right through." self.handle_data('&%s;' % ref) def handle_decl(self, data): "Propagate DOCTYPEs right through." self.handle_data('' % data) class BeautifulStoneSoup(BeautifulSoup): """A version of BeautifulSoup that doesn't know anything at all about what HTML tags have special behavior. Useful for parsing things that aren't HTML, or when BeautifulSoup makes an assumption counter to what you were expecting.""" IMPLICITLY_CLOSE_TAGS = 0 SELF_CLOSING_TAGS = [] NESTABLE_TAGS = [] QUOTE_TAGS = [] if __name__ == '__main__': #Here are some examples. #Generally putting it through its paces. #--------------------------------------- text = ''' The Title Link text (italicized) Link text 2 ''' soup = BeautifulSoup(text) print soup("a") #Returns a list of 2 Tag objects, one for each link in #the source print soup.first("a", {'class':'foo'})['href'] #Returns http://www.crummy.com/ print soup.first("title").contents[0] #Returns "The title" print soup.first("a", {'href':'http://www.crummy.com/'}).first("i").contents[0] #Returns "text (italicized)" #Different ways of matching an attribute #--------------------------------------- import re soup = BeautifulSoup('') print soup.first('img', {'src' : 'foo.jpg'}) print soup.first('img', {'src' : re.compile('.*\.jpg')}) print soup.first('img', {'src' : lambda(x): x in ('foo.jpg', 'foo.png')}) #Example of SQL-style attribute wildcards -- all four 'find' calls will #find the link. These are obsolete and will be removed in the next #version. Use a compiled RE object instead #---------------------------------------------------------------------- soup = BeautifulSoup() soup.feed('''bla''') print soup.fetch('a', {'href': 'http://%'}) print soup.fetch('a', {'href': '%.com/'}) print soup.fetch('a', {'href': '%o.c%'}) #Example with horrible HTML #-------------------------- soup = BeautifulSoup(''' Go here or go Home ''') print soup.fetch('a') #Returns a list of 2 Tag objects. print soup.first(attrs={'href': 'here.html'})['class'] #Returns "that" print soup.first(attrs={'class': 'that'}).first('i').contents[0] #returns "here" \ No newline at end of file