From db5972f1d0f50026cfbb20c39ade5bebeba3faa5 Mon Sep 17 00:00:00 2001 From: HeyMoon <101842728+UnironicHeyMoon@users.noreply.github.com> Date: Wed, 17 Aug 2022 21:47:04 -0500 Subject: [PATCH] varuious lol --- BotModels.py | 9 +- automeme.py | 225 ++++++++++++++++++++++++++++++++----- automeme_database_tools.py | 18 +++ meme_generator.py | 46 ++++++-- utils.py | 6 + 5 files changed, 254 insertions(+), 50 deletions(-) create mode 100644 automeme_database_tools.py create mode 100644 utils.py diff --git a/BotModels.py b/BotModels.py index b06b439..a79b1c6 100644 --- a/BotModels.py +++ b/BotModels.py @@ -47,11 +47,6 @@ class Comment(Base): id = Column(Integer, primary_key = True) user_comment_id = Column(Integer) bbbb_comment_id = Column(Integer) - comment_string = Column(String) - - def get_past_comments(session : Session) -> 'list[str]': - stmt = sqlalchemy.select(Comment) - return [i.comment_string for i in session.execute(stmt).scalars().fetchall()[0:100]] def get_user_comment(user_comment_id:int, session : Session): stmt = sqlalchemy.select(Comment).where(Comment.user_comment_id == user_comment_id) @@ -81,8 +76,8 @@ class Comment(Base): def has_replied_to_comment(comment_id : int, session : Session): return Comment.get_comment(comment_id, session) == None - def create_new_comment(user_comment_id : int, bbbb_comment_id : int, comment_string : str, session : Session): - comment = Comment(user_comment_id = user_comment_id, bbbb_comment_id = bbbb_comment_id, comment_string = comment_string) + def create_new_comment(user_comment_id : int, bbbb_comment_id : int, session : Session): + comment = Comment(user_comment_id = user_comment_id, bbbb_comment_id = bbbb_comment_id) session.add(comment) class Post(Base): diff --git a/automeme.py b/automeme.py index 235d7eb..793a0d3 100644 --- a/automeme.py +++ b/automeme.py @@ -2,7 +2,9 @@ import base64 import io import json import re +from typing import Callable, TypeVar import meme_generator +from meme_generator import WebcomicPanel, OneCharacterWebcomicPanel, TwoCharacterWebcomicPanel, TitleCardWebcomicPanel, add_watermark, create_webcomic from RDramaAPIInterface import RDramaAPIInterface from datetime import datetime from os.path import exists, join, realpath, split @@ -13,21 +15,21 @@ from sqlalchemy.orm import Session import os from markdown import markdown from bs4 import BeautifulSoup - +from utils import get_real_filename TEST_MODE = True TEST_AUTH_TOKEN = "ED3eURMKP9FKBFbi-JUxo8MPGWkEihuyIlAUGtVL7xwx0NEy4Nf6J_mxWYTPgAQx1iy1X91hx7PPHyEBS79hvKVIy5DMEzOyAe9PAc5pmqSJlLGq_-ROewMwFzGrqer4" MINUTES_BEFORE_FORCED_SHUTDOWN = 10 DB_FILENAME = "automeme_database.db" PAGES_TO_SCAN = 5 -AUTOMEME_ID = 3 #TODO +AUTOMEME_ID = 13427 ALLOWED_COMMENTS_PER_POST = 20 ALLOWED_COMMENTS_PER_USER_PER_DAY = 20 -def get_real_filename(filename : str): - path_to_script = realpath(__file__) - path_to_script_directory, _ = split(path_to_script) - return join(path_to_script_directory, filename) +EMOJI_REGEX = r":[^ ]*:" +IMAGE_REGEX = r"!\[\]\(/images/([1234567890]*)\.webp\)" +PARSED_IMAGE_REGEX = r"IMAGE:/images/[1234567890]*\.webp" +INJECTABLE_IMAGE_REGEX = r"IMAGE:/images/\1\.webp" # rdrama = RDramaAPIInterface(TEST_AUTH_TOKEN, "localhost", sleep=5, https=False) # print(open('emoji_cache/klanjak.webp', 'rb')) @@ -38,11 +40,52 @@ def get_real_filename(filename : str): # file = {'file': ('based.webp', output.getvalue(), 'image/webp')} # rdrama.reply_to_comment_easy(175, 1, "assddsfssdssdsd", file=file) -def comment_with_image(image, comment_id, post_id): +def comment_with_image(message, image, comment_id, post_id): output = io.BytesIO() image.save(output, format="webp") file = {'file': ('based.webp', output.getvalue(), 'image/webp')} - rdrama.reply_to_comment_easy(comment_id, post_id, "ffffff", file=file) + return rdrama.reply_to_comment_easy(comment_id, post_id, message, file=file)['id'] + +class TextLine: + def __init__(self, string): + self.line = text_elements(string) + + @property + def text(self) -> str: + text = [i.text for i in self.line if isinstance(i, Text)] + return " ".join(text) + + @property + def captions(self) -> 'list[Text]': + return [i for i in self.line if isinstance(i, Text)] + + @property + def images(self) -> 'list[Image]': + return [i for i in self.line if isinstance(i, Image)] + + @property + def emojis(self) -> 'list[Emoji]': + return [i for i in self.line if isinstance(i, Emoji)] + + @property + def is_dialogue_line(self): + return len(self.emojis) == 1 and len(self.captions) == 1 + + @property + def is_argument_line(self): + return len(self.emojis) == 2 and (len(self.captions) == 2 or len(self.captions) == 1) + + @property + def is_pure_text_line(self): + return len(self.emojis) == 0 and len(self.images) == 0 + + @property + def is_big_marsey_line(self): + return len(self.emojis) == 1 and self.emojis[0].big and len(self.captions) == 0 + + @property + def is_image_line(self): + return len(self.images) == 1 and len(self.captions) == 0 class TextElement(): pass @@ -54,6 +97,13 @@ class Text(TextElement): def __repr__(self) -> str: return f"Text({self.text})" +class Image(TextElement): + def __init__(self, link): + self.link = link + + def __repr__(self) -> str: + return f"Link({self.link})" + class Emoji(TextElement): def __init__(self, emoji, big): self.emoji = emoji @@ -67,15 +117,15 @@ def get_text_only(text_elements : list[TextElement]) -> str: return " ".join(text) def text_elements(string : str): - EMOJI_REGEX = r"(:[^ ]*:)" - elements = re.split(EMOJI_REGEX, string) + FULL_REGEX = rf"({EMOJI_REGEX})|({PARSED_IMAGE_REGEX})" + elements = re.split(FULL_REGEX, string) to_return = [] for element in elements: - if element == "": + if element == None: continue - if not re.match(EMOJI_REGEX, element): - to_return.append(Text(element.strip())) - else: + if element.strip() == "": + continue + if re.match(EMOJI_REGEX, element): if "#" in element: big = True element = element.replace("#","") @@ -83,9 +133,14 @@ def text_elements(string : str): big = False element = element.strip(":") to_return.append(Emoji(element, big)) + elif re.match(PARSED_IMAGE_REGEX, element): + to_return.append(Image(element.strip()[6:])) + else: + to_return.append(Text(element.strip())) return to_return def strip_markdown(markdown_string): + markdown_string = re.sub(IMAGE_REGEX, INJECTABLE_IMAGE_REGEX, markdown_string) markdown_string = re.sub(">.*\n", "", markdown_string) try: html = markdown(markdown_string) @@ -116,21 +171,31 @@ def remove_duplicates(list): def get_eligible_comments(rdrama : RDramaAPIInterface, session : Session): comments = rdrama.get_comments(number_of_pages=PAGES_TO_SCAN)['data'] comments = [comment for comment in comments if not comment['is_bot']] #No bots - comments = [comment for comment in comments if not comment['author']['id'] == BBBB_ID] #Don't reply to self + comments = [comment for comment in comments if not comment['author']['id'] == AUTOMEME_ID] #Don't reply to self comments = [comment for comment in comments if Post.get_number_of_replies(comment['post_id'], session) < ALLOWED_COMMENTS_PER_POST] #Don't spam posts comments = [comment for comment in comments if User.get_number_of_comments(comment['author']['id'], session) < ALLOWED_COMMENTS_PER_USER_PER_DAY] #Don't spam users comments = [comment for comment in comments if Comment.get_comment(comment['id'], session) is None] #Double check that we haven't replied to the comment comments = remove_duplicates(comments) #Remove the duplicates return comments +T = TypeVar('T') +def lambda_count(list : list[T], predicate : 'Callable[[T], bool]' ): + return sum(1 for i in list if predicate(i)) + +def get_full_rdrama_image_url(partial_url) -> str: + if (TEST_MODE): + return f"http://localhost{partial_url}" + else: + return f"https://rdrama.net{partial_url}" + def main_processing_task(rdrama : RDramaAPIInterface, session : Session): is_chudded = False #Do we have the chud award? can_communicate = True #Can we send any message at all? is_pizzad = False rdrama.get_front_page() - bbbb_information = rdrama.get_user_information(BBBB_ID) - print(f"coins: {bbbb_information['coins']} comments: {bbbb_information['comment_count']}") - for badge in bbbb_information['badges']: + automeme_information = rdrama.get_user_information(AUTOMEME_ID) + print(f"coins: {automeme_information['coins']} comments: {automeme_information['comment_count']}") + for badge in automeme_information['badges']: if (badge['name'] == "Marsey Award"): print("We have the marsey award. STOP.") can_communicate = False @@ -144,32 +209,130 @@ def main_processing_task(rdrama : RDramaAPIInterface, session : Session): print("We have the Pizzashill Award. CONTINUE.") is_pizzad = True - if bbbb_information['is_banned']: + if automeme_information['is_banned']: print("We are banned. STOP.") can_communicate = False if can_communicate: eligible_comments = get_eligible_comments(rdrama, session) for eligible_comment in eligible_comments: + under_post_limit = Post.get_number_of_replies(eligible_comment['post_id'], session) < ALLOWED_COMMENTS_PER_POST + under_user_limit = User.get_number_of_comments(eligible_comment['author']['id'], session) < ALLOWED_COMMENTS_PER_USER_PER_DAY + has_not_replied_to_comment = Comment.get_comment(eligible_comment['id'], session) is None + + if (not (under_post_limit and under_user_limit and has_not_replied_to_comment)): + continue + comment_text = eligible_comment['body'] cleaned_comment_text = strip_markdown(comment_text) comment_lines = cleaned_comment_text.split("\n") comment_lines = [comment_line for comment_line in comment_lines if comment_line != ""] - element_lines = [text_elements(line) for line in comment_lines] + element_lines = [TextLine(line) for line in comment_lines] - image = None - if (len(element_lines) == 2): - if isinstance(element_lines[0][0], Emoji) and isinstance(element_lines[1][0], Emoji): - emoji1 = element_lines[0][0].emoji - emoji2 = element_lines[1][0].emoji - caption1 = get_text_only(element_lines[0][1:]) - caption2 = get_text_only(element_lines[1][1:]) + argument_lines_count, dialog_lines_count, text_lines_count, big_marsey_lines_count = 0,0,0,0 + + dialog_lines = list(filter(lambda a : a.is_dialogue_line, element_lines)) + argument_lines = list(filter(lambda a : a.is_argument_line, element_lines)) + pure_text_lines = list(filter(lambda a : a.is_pure_text_line, element_lines)) + big_marsey_lines = list(filter(lambda a : a.is_big_marsey_line, element_lines)) + image_lines = list(filter(lambda a : a.is_image_line, element_lines)) + + argument_lines_count = len(argument_lines) + dialog_lines_count = len(dialog_lines) + pure_text_lines_count = len(pure_text_lines) + big_marsey_lines_count = len(big_marsey_lines) + image_lines_count = len(image_lines) - image = meme_generator.create_soy_vs_chad_meme(emoji1, emoji2, caption1, caption2) - + image = None + if (dialog_lines_count == 2): + #Soy vs Chad + line1 = dialog_lines[0] + line2 = dialog_lines[1] + + emoji1 = line1.emojis[0].emoji + emoji2 = line2.emojis[0].emoji + caption1 = line1.text + caption2 = line2.text + + image = meme_generator.create_soy_vs_chad_meme(emoji1, emoji2, caption1, caption2) + elif (big_marsey_lines_count == 1 and pure_text_lines_count == 1): + # Modern Meme with Marsey + text_line = pure_text_lines[0] + marsey_line = big_marsey_lines[0] + + marsey = marsey_line.emojis[0].emoji + caption = text_line.text + + image = meme_generator.create_modern_meme_from_emoji(marsey, caption) + elif (image_lines_count == 1 and pure_text_lines_count == 1): + # Modern Meme with Image + text_line = pure_text_lines[0] + image_line = image_lines[0] + + image = image_line.images[0].link + full_image_url = get_full_rdrama_image_url(image) + caption = text_line.text + + image = meme_generator.create_modern_meme_from_url(full_image_url, caption) + elif (big_marsey_lines_count == 1 and pure_text_lines_count == 2): + # Classic Meme with big marsey + top_text_line = pure_text_lines[0] + bottom_text_line = pure_text_lines[1] + marsey_line = big_marsey_lines[0] + + emoji = marsey_line.emojis[0].emoji + top_caption = top_text_line.text + bottom_caption = bottom_text_line.text + image = meme_generator.create_classic_meme_from_emoji(emoji, top_caption, bottom_caption) + elif (image_lines_count == 1 and pure_text_lines_count == 2): + # Classic Meme with Image + top_text_line = pure_text_lines[0] + bottom_text_line = pure_text_lines[1] + image_line = image_lines[0] + + image = image_line.images[0].link + full_image_url = get_full_rdrama_image_url(image) + top_caption = top_text_line.text + bottom_caption = bottom_text_line.text + + image = meme_generator.create_classic_meme_from_url(full_image_url, top_caption, bottom_caption) + elif (argument_lines_count >= 1 or dialog_lines_count >= 1): + panels : 'list[WebcomicPanel]' = [] + + for element_line in element_lines: + if element_line.is_dialogue_line: + caption = element_line.text + emoji = element_line.emojis[0].emoji + if len(caption) > 100: + in_background = True + else: + in_background = False + oneCharacterWebcomicPanel = OneCharacterWebcomicPanel(emoji, caption, in_background) + panels.append(oneCharacterWebcomicPanel) + elif element_line.is_argument_line: + left_caption = element_line.captions[0].text + if len(element_line.captions) == 2: + right_caption = element_line.captions[1].text + else: + right_caption = "" + left_emoji = element_line.emojis[0].emoji + right_emoji = element_line.emojis[1].emoji + twoCharacterWebcomicPanel = TwoCharacterWebcomicPanel(left_emoji, left_caption, right_emoji, right_caption) + panels.append(twoCharacterWebcomicPanel) + elif element_line.is_pure_text_line: + panels.append(TitleCardWebcomicPanel(element_line.text)) + + image = create_webcomic(panels) if image != None: - comment_with_image(image, eligible_comment['id'], eligible_comment['post_id']) + image = add_watermark(image, eligible_comment['author']['username']) + user_id = eligible_comment['author']['id'] + parent_comment_id = eligible_comment['id'] + post_id = eligible_comment['post_id'] + automeme_comment_id = comment_with_image("yo got a meme for ya nigga", image, eligible_comment['id'], eligible_comment['post_id']) + Comment.create_new_comment(parent_comment_id, automeme_comment_id, session) + Post.increment_replies(post_id, session) + User.increase_number_of_comments(user_id, session) if __name__ == "__main__": TEST_AUTH_TOKEN = "ED3eURMKP9FKBFbi-JUxo8MPGWkEihuyIlAUGtVL7xwx0NEy4Nf6J_mxWYTPgAQx1iy1X91hx7PPHyEBS79hvKVIy5DMEzOyAe9PAc5pmqSJlLGq_-ROewMwFzGrqer4" @@ -179,7 +342,7 @@ if __name__ == "__main__": auth = TEST_AUTH_TOKEN https = False timeout = 1 - BBBB_ID = 6 + AUTOMEME_ID = 7 OPERATOR_ID = 9 ACTUALLY_CALL_OPEN_AI = False else: diff --git a/automeme_database_tools.py b/automeme_database_tools.py new file mode 100644 index 0000000..756cd3c --- /dev/null +++ b/automeme_database_tools.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import Session +from sqlalchemy import create_engine +from automeme import get_real_filename +from BotModels import Base, User +import sys + +if __name__ == "__main__": + db_filename = "automeme_database.db" + engine = create_engine(f"sqlite:///{get_real_filename(db_filename)}") + Base.metadata.create_all(engine) + + with Session(engine) as session: + command = sys.argv[1] + if (command == "reset_users"): + User.reset_all_comments(session) + print("Reset the users!") + + \ No newline at end of file diff --git a/meme_generator.py b/meme_generator.py index 162f5fd..03ebce1 100644 --- a/meme_generator.py +++ b/meme_generator.py @@ -7,10 +7,15 @@ from PIL import ImageFont from PIL import ImageOps import requests import io +from utils import get_real_filename from image_utils import ImageText from os.path import exists +from random import choice HIGHLIGHT_MODE = False +CAPTION_FILENAME = "watermark_captions.txt" + + class ColorScheme: BLACK = 0 @@ -25,7 +30,7 @@ def create_soy_vs_chad_meme(emoji1, emoji2, caption1, caption2): IMAGE_ROW = 300 MIDDLE_MARGIN_ROW = 20 TEXT_ROW = 100 - BOTTOM_MARGIN_ROW = 200 + BOTTOM_MARGIN_ROW = 80 total_image_size_x = 2*CONTENT_COLUMN + LEFT_MARGIN_COLUMN + RIGHT_MARGIN_COLUMN + MIDDLE_MARGIN_COLUMN total_image_size_y = TOP_MARGIN_ROW + IMAGE_ROW + MIDDLE_MARGIN_ROW + TEXT_ROW + BOTTOM_MARGIN_ROW @@ -63,7 +68,7 @@ def create_soy_vs_chad_meme(emoji1, emoji2, caption1, caption2): align="cht", font="impact.ttf", color=ColorScheme.WHITE_WITH_BLACK_BORDER) - return add_watermark(base) + return base def create_classic_meme(image: Image, top_caption : str, bottom_caption : str): image_x_size, image_y_size = image.size @@ -115,7 +120,8 @@ def create_modern_meme_from_emoji(emoji: str, caption: str): class WebcomicPanel(): PANEL_SIZE = 400 - FONT = "Little Story.ttf" + FONT = "Impact.ttf" + COLOR = ColorScheme.WHITE_WITH_BLACK_BORDER def __init__(self): pass @@ -123,6 +129,9 @@ class WebcomicPanel(): def create_image(self) -> Image: return Image.new(mode="RGB", size=(self.PANEL_SIZE,self.PANEL_SIZE), color=(255,255,255)) + def add_text_box(self, base : Image, caption : str, region_size : tuple[int, int], coordinates : tuple[int, int], align=""): + add_text_box(base, caption, region_size, coordinates, align=align, font=self.FONT, color=self.COLOR, init_font_size=int(self.PANEL_SIZE/10)) + class OneCharacterWebcomicPanel(WebcomicPanel): def __init__(self, emoji, caption, words_in_background): self.emoji = emoji @@ -136,7 +145,7 @@ class OneCharacterWebcomicPanel(WebcomicPanel): # We put the text in the background of the panel. text_region_x_size = int(panel_size_x) text_region_y_size = int(panel_size_y if self.words_in_background else panel_size_y/2) - add_text(base, self.caption, (text_region_x_size, text_region_y_size), (0,0), font=super().FONT) + self.add_text_box(base, self.caption, (text_region_x_size, text_region_y_size), (0,0)) # We put marsey in the bottom left quadrant emoji_region_x_size = int(panel_size_x) @@ -186,14 +195,14 @@ class TwoCharacterWebcomicPanel(WebcomicPanel): left_text_region_y_size = int(panel_size_y/4) left_text_region_x_position = 0 left_text_region_y_position = 0 - add_text_box(base, self.left_caption, (left_text_region_x_size, left_text_region_y_size), (left_text_region_x_position,left_text_region_y_position), font=super().FONT, align="bl") + self.add_text_box(base, self.left_caption, (left_text_region_x_size, left_text_region_y_size), (left_text_region_x_position,left_text_region_y_position), align="bl") # We put the text in the top half of the panel. right_text_region_x_size = int(CAPTION_UNITS*(panel_size_x/CAPTION_DIVISOR)) right_text_region_y_size = int(panel_size_y/4) right_text_region_x_position = int((CAPTION_DIVISOR-CAPTION_UNITS)*(panel_size_x/8)) right_text_region_y_position = int(panel_size_y/4) - add_text_box(base, self.right_caption, (right_text_region_x_size, right_text_region_y_size), (right_text_region_x_position,right_text_region_y_position), font=super().FONT, align="br") + self.add_text_box(base, self.right_caption, (right_text_region_x_size, right_text_region_y_size), (right_text_region_x_position,right_text_region_y_position), align="br") return add_border_to_image(base) @@ -204,7 +213,7 @@ class TitleCardWebcomicPanel(WebcomicPanel): def create_image(self) -> Image: base = super().create_image() - add_text_box(base, self.caption, base.size, (0,0), font=super().FONT, init_font_size=90, align="cvch") + self.add_text_box(base, self.caption, base.size, (0,0), align="cvch") return add_border_to_image(base) @@ -219,7 +228,7 @@ def create_webcomic(layout : 'list[WebcomicPanel]'): x = i%2 y = math.floor(i/2) image.paste(panel.create_image(), (x*assumed_panel_x_size, y*assumed_panel_y_size)) - return add_watermark(image) + return image def add_text_box(base : Image, caption : str, region_size : tuple[int, int], coordinates : tuple[int, int], font : str= "arial.ttf", init_font_size = 45, align :str = "", color = ColorScheme.BLACK): if caption == "": @@ -242,7 +251,7 @@ def add_text_box(base : Image, caption : str, region_size : tuple[int, int], coo if "ch" in align: place = "center" - actual_text_box_size = line_image.write_text_box((0,0), caption, region_x_size, font_size=init_font_size, font_filename=font, color=fill_color, stroke_color=stroke, stroke_size=stroke_size, place=place) + actual_text_box_size = line_image.write_text_box((stroke_size,0), caption, region_x_size, font_size=init_font_size, font_filename=font, color=fill_color, stroke_color=stroke, stroke_size=stroke_size, place=place) _, actual_text_box_y_size, input_text_block_x_size, actual_text_box_x_size = actual_text_box_size if actual_text_box_y_size <= region_y_size: actual_paste_x_coordinates, actual_paste_y_coordinates = coordinates @@ -284,7 +293,8 @@ def add_text(base : Image, caption : str, region_size : tuple[int, int], coordin line_image.fill_text_box((0,0), caption, region_x_size, region_y_size, font_filename=font) base.paste(line_image.image, coordinates, line_image.image) -def add_watermark(image : Image): +def add_watermark(image : Image, name_of_other_creator): + global watermark_captions WATERMARK_HEIGHT = 30 image_size_x, image_size_y = image.size base = Image.new(mode="RGB", size=(image_size_x, image_size_y + WATERMARK_HEIGHT), color=(255,255,255)) @@ -296,8 +306,9 @@ def add_watermark(image : Image): text_line_size = int(WATERMARK_HEIGHT/2) - add_text(base, "A meme by HeyMoon and Foo", (image_size_x, text_line_size), (WATERMARK_HEIGHT, image_size_y)) - add_text(base, "For instructions on how to legally build a pipe bomb, go to rdrama.net", (image_size_x, text_line_size), (WATERMARK_HEIGHT, image_size_y+text_line_size)) + caption = choice(watermark_captions) + add_text(base, f"A meme by {name_of_other_creator} and automeme", (image_size_x, text_line_size), (WATERMARK_HEIGHT, image_size_y)) + add_text(base, f"{caption}, go to rdrama.net", (image_size_x, text_line_size), (WATERMARK_HEIGHT, image_size_y+text_line_size)) return base @@ -349,6 +360,17 @@ def get_image_file_from_url(url): im = Image.open(image_file) return im +def parse_caption_file(filename): + if not exists(filename): + return [] + to_return = [] + with open(get_real_filename(filename), "r") as f: + for id in f.readlines(): + to_return.append(id.strip()) + return to_return + +watermark_captions = parse_caption_file(CAPTION_FILENAME) + #create_soy_vs_chad_meme("bigsmilesoyjak", "!marseyshooting", "I have fun new toys and games for your children", "Die").show() # create_webcomic([ diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..137586d --- /dev/null +++ b/utils.py @@ -0,0 +1,6 @@ +from os.path import exists, join, realpath, split + +def get_real_filename(filename : str): + path_to_script = realpath(__file__) + path_to_script_directory, _ = split(path_to_script) + return join(path_to_script_directory, filename) \ No newline at end of file