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+&!SdDca8lJ939NJEa+j9Z40@PHJfqKmYeS0C%E+aUTULkE25CRdV,b!@f
YCF8AU4P9bp,rXV)Zkr`E'K4kIDa""G*8K3T"XGhBhmPdi86Rje8PjaYjF4@f8+I
Ij4HVljmd3f%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!-*Md3f3pD3Q@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+eL6PTB)
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", "fi")
content = string.replace(content, "fl", "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", "") 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, "
", " ") # 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
+
+
+
+
+
+
+
+
", "") 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, "
", " ") # 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, "", "") 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, "
", " ") # 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, "", "") 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, "", "") 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 = '%s>' % 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 "%s> is not real!" % name self.handle_data('%s>' % 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("%s>" % 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 = '''