From d8bf384ef07aeec637da30a342f20d7e5449ff01 Mon Sep 17 00:00:00 2001 From: joelnir Date: Sat, 2 Jan 2016 22:49:16 +0100 Subject: [PATCH] Initial files Added initial files that were created without version control. A number of .py files and a resource folder with a couple of icons. --- TESTING.py | 3 + config.py | 50 ++++++ ls_connection.py | 99 ++++++++++++ main_window.py | 370 ++++++++++++++++++++++++++++++++++++++++++++ note_reader.py | 122 +++++++++++++++ resources/green.png | Bin 0 -> 2506 bytes resources/red.png | Bin 0 -> 2295 bytes 7 files changed, 644 insertions(+) create mode 100644 TESTING.py create mode 100644 config.py create mode 100644 ls_connection.py create mode 100644 main_window.py create mode 100644 note_reader.py create mode 100644 resources/green.png create mode 100644 resources/red.png diff --git a/TESTING.py b/TESTING.py new file mode 100644 index 0000000..8d2fae7 --- /dev/null +++ b/TESTING.py @@ -0,0 +1,3 @@ +import tkinter + +tkinter.messagebox.showinfo("hi!") \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..a4af114 --- /dev/null +++ b/config.py @@ -0,0 +1,50 @@ +#CONFIG FILE WITH CONSTANTS + +DEBUG = True + +#Livesplit connection +HOST = "localhost" +PORT = 16834 + +#In network communication, time out after this time. (in seconds) +COM_TIMEOUT = 0.5 + +#Possible commands to send to LiveSplit +LS_COMMANDS = { + "best_possible": "getbestpossibletime\r\n", + "cur_split_index": "getsplitindex\r\n", + "cur_split_name": "getcurrentsplitname\r\n" +} + +#Default Window Size +DEFAULT_WINDOW = {"WIDTH": 200, "HEIGHT": 500, "TITLE": "SplitNotes"} + +#Color Scheme +COLOR_SCHEME = {} + +#Default Welcome Message +DEFAULT_MSG = "Right Click to Open Notes." + +#Update time for polling livesplit and other actions (in seconds) +POLLING_TIME = 0.5 + +#file names and path of icons +ICON_FOLDER = "resources" +ICONS= {"GREEN": "green.png", "RED": "red.png"} + +#Popup menu options +MENU_OPTIONS = { + "SINGLE": "Set Single Layout", + "DOUBLE": "Set Double Layout", + "LOAD": "Load Notes" + } + +#Error messages +ERRORS = {"NOTES_EMPTY": ("Error", "Notes empty or can not be loaded!")} + +#Max file size for notes +MAX_FILE_SIZE = 1000000000 #1 Giga-Byte + +#TO be added to title to alert user that timer is running +RUNNING_ALERT = "RUNNING" + diff --git a/ls_connection.py b/ls_connection.py new file mode 100644 index 0000000..06a3b31 --- /dev/null +++ b/ls_connection.py @@ -0,0 +1,99 @@ +""" +ls refers to LiveSplit in the entire document. +Conversation with livesplit is done through the server component. +""" +import socket +from threading import Thread +import config +import select #used for checking if socket has data pending + +def ls_connect(ls_socket, call_func, window): + """Connects given socket to the livesplit server.""" + con_thread = Thread(target=try_connection, args=(ls_socket, call_func, window)) + con_thread.start() + + +def init_socket(): + """Returns a fresh socket""" + return socket.socket() + +def try_connection(ls_socket, call_func, window): + """ + Tries to connect given socket to ls. + If connection is successful given "call_func" + is called with window as argument. + (made to be ran in a separate thread) + """ + try: + ls_socket.connect((config.HOST, config.PORT)) + except: + return False + + call_func(window) + +def close_socket(socket): + """Closes given socket.""" + socket.close() + +def check_connection(ls_socket): + """ + Check so connection between socket and livesplit + is still active and working. + Returns boolean + """ + if send_to_ls(ls_socket, "best_possible"): + return True + else: + return False + + +def send_to_ls(ls_socket, command): + """ + Sends given command to ls using given socket. + If connected is False, tries to send without socket being connected to ls. + Returns the response, or False if an error occurs. + Check config.LS_COMMANDS for avaiable commands. + """ + + try: + ls_socket.send(str.encode(config.LS_COMMANDS[command])) + except: + return False + + socket_ready = select.select([ls_socket], [], [], config.COM_TIMEOUT) + if socket_ready[0]: + try: + return (ls_socket.recv(1000)).decode("utf-8") + except: + return False + else: + return False + + +def get_split_index(ls_socket): + """ + Returns the index of the active split in livesplit. + Returns -1 if timer is not yet started. + (First split is 0) + + Returns False on Error + """ + ls_data = send_to_ls(ls_socket, "cur_split_index") + + if not isinstance(ls_data, bool): + return int(ls_data) + else: + return False + + +def get_split_name(ls_socket): + """ + Returns name of the active split in livesplit. + Returns False if no split is active or Error occurs. + """ + ls_data = send_to_ls(ls_socket, "cur_split_name") + + if ls_data: + return ls_data + else: + return False \ No newline at end of file diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..dc46ab6 --- /dev/null +++ b/main_window.py @@ -0,0 +1,370 @@ +import tkinter +from tkinter import messagebox + +import socket +import os + +import config +import ls_connection as con +import note_reader as noter + +runtime_info = { + "ls_connected": False, + "timer_running": False, + "icon_active": False, + "active_split": -1, + "notes": [], + "double_layout": False + } + +root = tkinter.Tk() + +red_path= os.path.join( + str(os.path.dirname(os.path.realpath(__file__))), + config.ICON_FOLDER, + config.ICONS["RED"] + ) +green_path= os.path.join( + str(os.path.dirname(os.path.realpath(__file__))), + config.ICON_FOLDER, + config.ICONS["GREEN"] + ) +red_icon = tkinter.Image("photo", file=red_path) +green_icon = tkinter.Image("photo", file=green_path) + +def update(window, com_socket, text1, text2): + """ + Function to loop along tkinter mainloop. + """ + if not runtime_info["ls_connected"]: + #try connecting to ls + connection_ok = con.ls_connect(com_socket, server_found, window) + else: + #is_connected + if runtime_info["notes"]: + #notes loaded + + #get index of current split + new_index = con.get_split_index(com_socket) + + if isinstance(new_index, bool): + #Connection error + test_connection(com_socket) + else: + #index retrieved succesfully + if new_index == -1: + #timer not running + if runtime_info["timer_running"]: + runtime_info["timer_running"] = False + runtime_info["active_split"] = new_index + update_notes(window, text1, text2, new_index) + set_title_notes(window, 0) + else: + #timer is running + runtime_info["timer_running"] = True + + if not runtime_info["active_split"] == new_index: + #new split, need to update + + #update notes + update_notes(window, text1, text2, new_index) + + #set new window title + new_split = con.get_split_name(com_socket) + + if new_split: + set_title_notes(window, new_index, new_split) + else: + #connection error + set_title_notes(window, new_index) + test_connection(com_socket) + + runtime_info["active_split"] = new_index + + else: + #notes not yet loaded + if not con.check_connection(com_socket): + #connection lost + runtime_info["ls_connected"] = False + update_icon(False, window) + com_socket = con.init_socket() + + + + #self looping + window.after(int(config.POLLING_TIME * 1000), update, window, com_socket, text1, text2) + + +def test_connection(com_socket): + if con.check_connection(com_socket): + return True + else: + reset_connection(com_socket) + return False + + +def reset_connection(com_socket): + runtime_info["ls_connected"] = False + com_socket = con.init_socket() + + +def server_found(window): + """ + Executes correct settings for when + ls connection has been established. + """ + runtime_info["ls_connected"] = True + update_icon(True, window) + + +def update_icon(active, window): + """Updates icon of window depending on "active" variable""" + if active and not runtime_info["icon_active"]: + window.tk.call('wm', 'iconphoto', window._w, green_icon) + runtime_info["icon_active"] = True + elif runtime_info["icon_active"]: + window.tk.call('wm', 'iconphoto', window._w, red_icon) + runtime_info["icon_active"] = False + + +def update_title(name, window): + """Sets the title of given window to name.""" + window.wm_title(name) + + +def adjust_content(window, box1, box2): + """ + Adjusts size of box1 and box2 according to + layout and size of window. + """ + if runtime_info["double_layout"]: + set_double_layout(window, box1, box2) + else: + set_single_layout(window, box1, box2) + + +def set_double_layout(window, box1, box2): + """ + Configures boxes in the window to fit as in double layout. + """ + runtime_info["double_layout"] = True + + w_width = window.winfo_width() + w_height = window.winfo_height() + + box1.place(height=(w_height // 2), width=w_width) + box2.place(height=(w_height // 2), width=w_width, y=(w_height // 2)) + + +def set_single_layout(window, box1, box2): + """ + Configures boxes in the window to fit as in single layout. + """ + runtime_info["double_layout"] = False + + box2.place_forget() + box1.place(height=window.winfo_height(), width=window.winfo_width()) + + +def show_popup(event, menu): + """Displays given popup menu at cursor position.""" + menu.post(event.x_root, event.y_root) + + +def menu_change_layout(window, box1, box2, popup): + """Menu option for changing layout selected.""" + if runtime_info["double_layout"]: + set_single_layout(window, box1, box2) + popup.entryconfig(0, label=config.MENU_OPTIONS["DOUBLE"]) + else: + set_double_layout(window, box1, box2) + popup.entryconfig(0, label=config.MENU_OPTIONS["SINGLE"]) + + +def menu_load_notes(window, text1, text2): + """Menu selected load notes option.""" + load_notes(window, text1, text2) + + +def load_notes(window, text1, text2): + """ + Prompts user to select notes and then tries to load these into the UI. + """ + file = noter.select_file() + + if file: + notes = noter.get_notes(file) + if notes: + #Notes loaded correctly + runtime_info["notes"] = notes + + split_c = len(notes) + show_info(("Notes Loaded", ("Loaded notes with " + str(split_c) + " splits."))) + + update_notes(window, text1, text2, runtime_info["active_split"]) + set_title_notes(window, 0, split_name = False) + else: + show_info(config.ERRORS["NOTES_EMPTY"], True) + + +def show_info(info, warning = False): + """ + Displays an infor popup window. + if warning is True window has a warning triangle. + """ + if warning: + messagebox.showwarning(info[0], info[1]) + else: + messagebox.showinfo(info[0], info[1]) + + +def update_notes(window, text1, text2, index): + """ + Displays notes with the given index in given text widgets. + If index is lower than 0, displays notes for index 0. + If index is higher than the highest index there are + notes for the text widgets are left empty. + + text2 is always given the notes at index (index + 1) if existing + """ + max_index = (len(runtime_info["notes"]) - 1) + + if index < 0: + index = 0 + + text1.config(state=tkinter.NORMAL) + text2.config(state=tkinter.NORMAL) + + text1.delete("1.0", tkinter.END) + text2.delete("1.0", tkinter.END) + + if index <= max_index: + text1.insert(tkinter.END, runtime_info["notes"][index]) + + #cand disply notes for index+1 + if index < max_index: + text2.insert(tkinter.END, runtime_info["notes"][index + 1]) + + text1.config(state=tkinter.DISABLED) + text2.config(state=tkinter.DISABLED) + + +def right_arrow(window, text1, text2): + """Event handler for right arrow key.""" + change_preview(window, text1, text2, 1) + + +def left_arrow(window, text1, text2): + """Event handler for left arrow key.""" + change_preview(window, text1, text2, -1) + + +def change_preview(window, text1, text2, move): + """move is either 1 for next or -1 for previous.""" + if runtime_info["notes"] and (not runtime_info["timer_running"]): + max_index = (len(runtime_info["notes"]) - 1) + index = runtime_info["active_split"] + + if index < 0: + index = 0 + + index += move + + if index > max_index: + index = max_index + + update_notes(window, text1, text2, index) + runtime_info["active_split"] = index + + if index < 0: + index = 0 + set_title_notes(window, index) + +def set_title_notes(window, index, split_name = False): + """ + Set window title to fit with displayed notes. + """ + title = config.DEFAULT_WINDOW["TITLE"] + + disp_index = str(index + 1) #start at 1 + title += " - " + disp_index + + if split_name: + title += " - " + split_name + + if runtime_info["timer_running"]: + title += " - " + config.RUNNING_ALERT + + update_title(title, window) + +def init_UI(root): + """Draws default UI and creates event bindings.""" + + #Graphical components + root.geometry(str(config.DEFAULT_WINDOW["WIDTH"]) + "x" + str(config.DEFAULT_WINDOW["HEIGHT"])) + + box1 = tkinter.Frame(root) + box2 = tkinter.Frame(root) + + scroll1 = tkinter.Scrollbar(box1) + scroll1.pack(side=tkinter.RIGHT, fill=tkinter.Y) + + scroll2 = tkinter.Scrollbar(box2) + scroll2.pack(side=tkinter.RIGHT, fill=tkinter.Y) + + + text1 = tkinter.Text( + box1, + yscrollcommand=scroll1.set, + wrap=tkinter.WORD + ) + text1.insert(tkinter.END, config.DEFAULT_MSG) + text1.config(state=tkinter.DISABLED) + text1.pack(fill=tkinter.BOTH, expand=True) + + text2 = tkinter.Text( + box2, + yscrollcommand=scroll2.set, + wrap=tkinter.WORD + ) + text2.insert(tkinter.END, config.DEFAULT_MSG) + text2.config(state=tkinter.DISABLED) + text2.pack(fill=tkinter.BOTH, expand=True) + + scroll1.config(command=text1.yview) + scroll2.config(command=text2.yview) + + set_single_layout(root, box1, box2) + + #create popup menu + popup = tkinter.Menu(root, tearoff=0) + popup.add_command( + label=config.MENU_OPTIONS["DOUBLE"], + command=( + lambda: menu_change_layout(root, box1, box2, popup) + ) + ) #Needs to be at index 0 + popup.add_command( + label=config.MENU_OPTIONS["LOAD"], + command=(lambda: menu_load_notes(root, text1, text2)) + ) + + + #Set default window icon and title + root.tk.call('wm', 'iconphoto', root._w, red_icon) + update_title(config.DEFAULT_WINDOW["TITLE"], root) + + #Event binds + root.bind("", (lambda e: adjust_content(root, box1, box2))) + root.bind("", (lambda e: show_popup(e, popup))) + root.bind("", (lambda e: right_arrow(root, text1, text2))) + root.bind("", (lambda e: left_arrow(root, text1, text2))) + + #call update loop + com_socket = con.init_socket() + update(root, com_socket, text1, text2) + + +init_UI(root) + +root.mainloop() \ No newline at end of file diff --git a/note_reader.py b/note_reader.py new file mode 100644 index 0000000..8a57647 --- /dev/null +++ b/note_reader.py @@ -0,0 +1,122 @@ +import tkinter.filedialog as file_dia +import os.path as path + +import config + +""" +NOTE STANDARD FORMATTING + +empty newlines separate notes for different splits + +lines that start and end with [ ] are ignored for notes. +these can be used for titles. +(ex. [Split1] is not included in notes) + +all other lines of text are added as notes +""" + +def get_note_lines(file_path): + """ + Reads file at given path and returns + a list containing all rows of text in given file. + Returns false if file can not be read. + """ + + + #check so file isn't too big + if path.getsize(file_path) > config.MAX_FILE_SIZE: + return False + + try: + notes_file = open(file_path, "r") + except: + return False + + #read file line per line + f_lines = [] + keep_reading = True + while keep_reading: + cur_line = notes_file.readline() + + if cur_line: + f_lines.append(cur_line) + else: + keep_reading = False + + return f_lines + + +def encode_notes(note_lines): + """ + Takes a list containing strings. + Encodes given strings according to the note formatting. + Returns the list containing the notes for every split. + """ + + def is_title(line): + if not line: + return False + + return (line[0] == "[") and (line[-1] == "]") + + def is_newline(line): + return (line == "\n") or (line == "\r") + + def remove_new_line(line): + if (len(line) >= 1) and (is_newline(line[-1])): + return line[:-1] + else: + return line + + note_list = [] + cur_notes = "" + + for line in note_lines: + #remove whitespace at beginning and end + line = line.strip(" ") + + if is_newline(line): + if cur_notes: + note_list.append(cur_notes) + cur_notes = "" + else: + line = remove_new_line(line) + if not is_title(line): + cur_notes += line + "\n" #newline + + if cur_notes: + note_list.append(cur_notes) + + return note_list + + +def get_notes(file_path): + """ + Takes a path to a file and returns a list with the notes + in the file encoded according to the note fromatting. + + Returns False if file is empty. + """ + note_lines = get_note_lines(file_path) + + if not note_lines: + return False + + note_list = encode_notes(note_lines) + + return note_list + + +def select_file(): + """ + Opens a file select window. + Returns False upon no file selection. + Otherwise returns absolute path to selected file. + """ + + file = file_dia.askopenfilename() + + if file: + return file + else: + return False \ No newline at end of file diff --git a/resources/green.png b/resources/green.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a139b17b304c71fcb505051ae237b0567698bc GIT binary patch literal 2506 zcmV;*2{rbKP)Jak|)GnkN!8MYWUBxGC| zP!cvq(8Vl>VTFlkj2kw@II@@(A)2tjN8Ffp5J%B$GxLus?$05JLhzt`gc`VS5@~FRpqcrL{0+_1NQ>=wCXYc%2nV>tG)}o zsjB}tbY2bvpom1kL%`#{9^bO7cYq7N{#8}01M_wefFd#lp7r(4h3`xSxy!hEw*f%k zx9A7H?k)ZPhroH@ysGYP&Eqxzy~NG}&(o+K0On*wbY?{SNQ#_{kXvNmzI0d7OBLnC zoa&7^_R>ITSN(Th*+!xp0~C=*fnNak*Z1x=Bu`F=&ZL+V(WX3f$X>3f-pDC_J44^M z?MH6|&#CI`8}Yj?KoK#(&wwA*_l^k3(^Hb~jbSVZ`%4QzS#v-$rFb=?_~jmYz0>9# z@Do+FOImjMUI(Cv907g@JPJT6Bu|Y=zCXnrHO+e~0Jg!c3skQu#mjpXzsVr4+q@2Z zM^&${dP{dGa=N0#iFPqA;6 z;ns)1gZqJ83ec83`Qb%#URqW6F^wC4j7h7EB}`r48Tg7 zEAyXzM)}%2+}Q!XwoF|Y0`vyukN7zkKQ?0g;;rsk4a^XEU@a=UxM6P$qx44Rc2{ z0GCKgN;VQ94iZ$<0o0O$g1P3*b6SKA&>+A8rMsKcta9qF(HDH@%L`7>Uu0!TjO!Cy2;*}Ym_p`vS1`_m+^S^*&azvP1{37P4 zV>B3$L{i2wVrnLgWx~`<7|Cd=FD`)Ofxc@G1D2D&Kq^zL{+aT zvq%0DJf5q-SAv6XTJJrJcbt=_r!}~+6hz_-iUTxaV#bWT^hT1B;^v~arM006_BKdA zmBgvG88Pu1kealf%WW&XF3C~nGI~1Zu{09&bMN24opQHf`j^|0aU;EinAD7z$e5{_ za*Irvmy6@5HBiO!@QhS~-u?yq|^%os5pU~TGy6b~|%sP4;wi)!b=Ea+zy zMQ76X&&LM<&B>?}&joOiL_~fbg&K+R5;OZc*^7XhAW3>&kpH{3!Y!9?9Wo~(%*mR6 zhsh_1j=K>U4H;@lG3ph^$YQCjCaI8)Ul=FYans+V{8Wvy0(ggUeUC?AM9} z5dgYD+F@l^-2ix#*l-!iErLvI9QQN;?=qbin~#Ke&ecKLA}bf51UD|MkL^A;37rNY z8!XEK){Lmu!D-bh4MW2e#42$0Uw+Ukzo@%Fc!R}#O@POMIw?B<*zqIFR&}0#V6@Bp za&XO{`96K%TT<3Ra1C-xNw%;Y8=_jqh&l#b{8oB~Tv;`L`YIg;t!#x*u3s1N2ZcQh zigDieeZ3@WO%}y!MJW}9mgJI?YeJ+^?V=%oHtXinL2LPjBY;XQMk@+R<~nyij5Uan`dy3dM)O{xZS@RutZ**htA z=is)q7~r0wyE<44Yv!mBN^)(|5m->8ekP_Q*g6tyFVrFFRBA=x=WwPOGtHQ5R;MjW zEeTKa5S z-elEqQr$RpxPZ!wdHd&^A)>zw95*K;CV#$t&?u;z%V0YNbn#)*#D|j9p-(%yZ!a}< zUX-HFk5*c4l`>pLvu}JH_88w$)e}Kn7l0qwOBMEVHQ3`*6hWF)yBO4uxX)2jXyL$l z>uSUqnlPU#pAszVYe}WfVjgr^2xKo;?G4lm&;+~}!rkhPoJG`L$b(zufaT$Ug3p8V0*Y6&rTw%=I?NlFwXT9U>GvI4)tQlIH@Ij;@Xm+w0w;zx&L$e;)=gSTphKZ#)m6*WI@9TAeQ`wzZ}$D~CnJ%bk7v z=iA3lR&KtSQ^R%f^F*sQUpnEEfAAatxw<{^kJdWO@PDDOrQJH5@Lc~a%^)81`)lU_ z^gZ9f{P54S9hm0YKa_B8(HWYxx3EVq9=UUZ=s$dYrP;=w?0;+i85W9%&wQaTo&W#< literal 0 HcmV?d00001 diff --git a/resources/red.png b/resources/red.png new file mode 100644 index 0000000000000000000000000000000000000000..ad9476ae8ee5a6715f157ac12411fd2c349abc31 GIT binary patch literal 2295 zcmVqyb>a4<%>+>6~q`pba3!N zKI9UR65YJyXo5r_2M0w7x#>D}0&V~|M5Hx0kMjU@68jLiO0ISQtc4-xqlk008f#(5YsQRy z*9#Hu#W9<`9=Ez(?!|Gj%eMQ@ALo(i%m9rs=YU@Wr}W-x5OA^CC6xn578 ztvF_VZ;uDL+TI1eCnBGX<$oMN2kzfE4|%3q<>SML`I<^iK{@Y85DWk{#vlr2T$>W) z!!%|6=~M3X`+DmU@a{N}BLO+P*BG@VDhtHn*AUA>UmpPtM0Dpz^mS?I}{Q%(_ifYbB+2{CAj$9U?fL3@O+_u|2T5Q+FTJ_t!9B$ zs~GQG0U8|;5o?Dky?!5hJ;WX9_w9Kc&kXWmnsRn`*LOVUi}U0FKo_BW0-TDB;qNOe z)?51E6i5_VVD)+i)+j*V@%R$#_pQXbT??|)$wBsdx%$qau3K@;cb+`aDdbb&Z9n1( z3jIICMJSgVjeII_(lDxyQG|&iXf&YFz%&||X478R>)Fw`->KJaYc>b^nr749*TGTy zohD##bUF;V)M)7CDTg5!no7_G&i@5gmyO|*qetx|?wkpgij`OtS;m#&D9TmUw?Cgr zCqZ#`NUxVmu+u@>ZRm8M+qLRi^s*D^Nt*KZ%ZFD8kh1xi-{lwvT>y1}Uxt0hgDS^m5f<$tCD)ZwENG z8U$<|Jxary3J62XM7?g+%Bh;#pYf-MGUoirJV_u)tfabKE5TL^TCGfy((BDE#kL5i zA3xRr{Sk0nL=u(Yd$?%jVzX%jXUJ{OlR{=MzFLl&yJ~}KgnHsR>=2+~3>TYC zy*%bHWh#0>e>opT!%VqJJOid)(+B2H?nUOx-lqfR&7V4u^QdXYdrlXI0Ir4MU_R$f z4?aVgU6n~~92J8Fjed%@>#O<2Nf{95n$_;EEzMgKm^Pb@lW+GcU)9`_BCTz%5j{?0BT9E?Bj^oD5N+q%?s9# zH8oHkmkiHH9u^Pah-#Sl5xyp?Nn!!d!%T5-G*9%8`_vK-%ZUINo()ORXTnc+96bFtZ{wx4h1t(5-fhkzaX0ou+cX= z=}@+xF_)>OGNKu|GNo-?ou!}&;w8V?p!NH?8SFz%JuKTHc6I0LJ4s1ZyijhS*F)SS zFO`a&8v&`+FxtO3QTqU$#KljUH2L*3W1bq#0Kgo}0|huN!2)BFBp7APMH~VdFe%2S zFj#cb_o(E$T??+;%`^KX*|+oxSaN_bMR*}HJQo(7j4?G&d`Oq&qIe?jmROeK1E;2_| zt&BE%J^%8yX7rx{C)dJ|J4cQTKc(vC1>0Ex%CCzS6IQjsq68JFdea4g130t0t5W=2 zMBdcMbOZRwy*TE6k_@l$iCAWpfgs52MeUDD#Y)Am<><4;`P450mxX|H()}dyRdfUL zgm**l-s*PCpDwz4b@}S*)nT;T;A$)b&99tk^{lLHzYmZF-Zx-e(P>m>F{$QKlKu>= zFsTbK3*7fXV~b9%5_|(-J59OSZjXLbS;*x_Ga9Klx1ZUZA7@Sz-pxoi+iicz`-WDx z#}|If0`MaFk}f+hGVfn6DBhf^@at||1$ZXH_12U-H%8@gqwH$|wpOI;t(L!zf7Rc1 zGI{Z3nli?hNHZuc8l9=J8} z&H~kGQPdG({pnMGZSY#z9-8Twu$Pw)klop7UrX2krJE2A9kNT=Um`7J6GHamsBI+E zOdkh8^K2s-%c*0}fTelh zTe-51(`?3DApKuA;|)CUs&1zoc<5E$5IgYjtGcCm=q0b}ru307{R;i9;D7HC)NtaA Rs9692002ovPDHLkV1lm4Q|15w literal 0 HcmV?d00001