From 55ef585bdb402901d277959fcada68d3843730cc Mon Sep 17 00:00:00 2001 From: Lars Winzer Date: Mon, 1 Jun 2026 18:25:14 +0200 Subject: [PATCH 1/4] Remove: Old python implementation of the bot alongside config files --- config/.bot.template | 3 - config/.platforms.template | 6 - requirements.txt | 5 - src/VERSION | 1 - src/cogs/base_cog.py | 14 -- src/cogs/debug.py | 45 ------ src/cogs/post.py | 176 -------------------- src/const.py | 8 - src/main.py | 138 ---------------- src/platforms/reddit.py | 45 ------ src/utils/adv_configparser.py | 249 ----------------------------- src/utils/datetime_tools.py | 26 --- src/utils/event_counter.py | 110 ------------- src/utils/logger/custom_logging.py | 37 ----- src/utils/logger/decorator.py | 11 -- src/utils/logger/formatter.py | 30 ---- src/utils/singleton.py | 38 ----- src/utils/truncate_str.py | 21 --- 18 files changed, 963 deletions(-) delete mode 100644 config/.bot.template delete mode 100644 config/.platforms.template delete mode 100644 requirements.txt delete mode 100644 src/VERSION delete mode 100644 src/cogs/base_cog.py delete mode 100644 src/cogs/debug.py delete mode 100644 src/cogs/post.py delete mode 100644 src/const.py delete mode 100644 src/main.py delete mode 100644 src/platforms/reddit.py delete mode 100644 src/utils/adv_configparser.py delete mode 100644 src/utils/datetime_tools.py delete mode 100644 src/utils/event_counter.py delete mode 100644 src/utils/logger/custom_logging.py delete mode 100644 src/utils/logger/decorator.py delete mode 100644 src/utils/logger/formatter.py delete mode 100644 src/utils/singleton.py delete mode 100644 src/utils/truncate_str.py diff --git a/config/.bot.template b/config/.bot.template deleted file mode 100644 index a3adab1..0000000 --- a/config/.bot.template +++ /dev/null @@ -1,3 +0,0 @@ -[DISCORD] -TOKEN = -OWNER_ID = \ No newline at end of file diff --git a/config/.platforms.template b/config/.platforms.template deleted file mode 100644 index c109197..0000000 --- a/config/.platforms.template +++ /dev/null @@ -1,6 +0,0 @@ -; Create an script on the reddit developer page (https://www.reddit.com/prefs/apps) -[REDDIT] -CLIENT_ID = -CLIENT_SECRET = -USER_AGENT = Small discord bot to embed posts (given by url) into an standardized format -EMBED_COLOR = 0xff4500 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cc47caa..0000000 --- a/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -discord.py==2.4.0 -asyncpraw==7.8.1 -colorama==0.4.6 -aiohttp==3.11.11 -pillow==11.1.0 \ No newline at end of file diff --git a/src/VERSION b/src/VERSION deleted file mode 100644 index 2411653..0000000 --- a/src/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.5.2 \ No newline at end of file diff --git a/src/cogs/base_cog.py b/src/cogs/base_cog.py deleted file mode 100644 index 82fcd96..0000000 --- a/src/cogs/base_cog.py +++ /dev/null @@ -1,14 +0,0 @@ -import discord -from discord import app_commands -from discord.ext import commands -import logging - -class Base_Cog(commands.Cog): - def __init__(self, logger:logging.Logger): - self._logger = logger - - async def cog_load(self): - self._logger.debug(f"Cog for the '{self.__class__.__name__}' command got loaded") - - async def cog_unload(self): - self._logger.debug(f"Cog for the '{self.__class__.__name__}' command got unloaded") \ No newline at end of file diff --git a/src/cogs/debug.py b/src/cogs/debug.py deleted file mode 100644 index 4e8922f..0000000 --- a/src/cogs/debug.py +++ /dev/null @@ -1,45 +0,0 @@ -import discord -from discord import app_commands -from discord.ext import commands -from cogs.base_cog import Base_Cog - -import logging -from datetime import datetime -from utils.datetime_tools import get_elapsed_time_big -from utils.logger.decorator import log_command_execution - -class Debug_Command(Base_Cog): - def __init__(self, bot:commands.Bot): - self.__bot = bot - super().__init__(logging.getLogger("cmds.debug")) - - @app_commands.command(name = "debug", description = "Provides debug informations about the bot, useful for troubleshooting") - @log_command_execution - async def debug(self, ctx:discord.Interaction): - embed = discord.Embed(title="Debug Information") - - embed.add_field(name="Version", - value=ctx.client.VERSION, - inline=True) - embed.add_field(name="Uptime", - value=f"{get_elapsed_time_big(datetime.now().timestamp() - ctx.client.STARTUP_TIMESTAMP)}", - inline=True) - embed.add_field(name="Bot Owner", - value=f"<@{ctx.client.bot_config['DISCORD']['OWNER_ID']}>", - inline=True) - embed.add_field(name="Latency to Gateway", - value=f"{round(self.__bot.latency * 1000, 2)}ms", - inline=True) - embed.add_field(name="Application ID", - value=f"{self.__bot.application_id}", - inline=True) - embed.add_field(name="Number of Guilds", - value=f"{len(self.__bot.guilds)}", - inline=True) - embed.add_field(name = "Number of executed commands", value=f"Total: {ctx.client.no_executed_commands}\nSucceeded: {ctx.client.no_succeeded_commands}\nFailed: {ctx.client.no_failed_commands}") - - await ctx.response.send_message(embed=embed) - - -async def setup(bot:commands.Bot): - await bot.add_cog(Debug_Command(bot)) \ No newline at end of file diff --git a/src/cogs/post.py b/src/cogs/post.py deleted file mode 100644 index 2ead24e..0000000 --- a/src/cogs/post.py +++ /dev/null @@ -1,176 +0,0 @@ -import discord -from discord import app_commands -from discord.ext import commands -from cogs.base_cog import Base_Cog - -import logging -from urllib.parse import urlparse -from asyncpraw.models import Submission -import aiohttp -from PIL import Image -from io import BytesIO -from datetime import datetime -from utils.datetime_tools import get_elapsed_time_milliseconds - -class NoMediaFound(Exception): - pass - -class Post_Command(Base_Cog): - def __init__(self, bot:commands.Bot): - self.__bot = bot - super().__init__(logging.getLogger("cmds.post")) - - @app_commands.command(name = "post", description = "Post an embed in the Current Channel with a link to the content") - @app_commands.describe(url = "URL to the post", custom_note = "Describe the post with your own note", use_title = "Display the title of the post", quality = "Specifies the quality of the converted image, closer to 100 is better") - @app_commands.choices(quality = [ - app_commands.Choice(name = "Poor (60)", value = 60), - app_commands.Choice(name = "Fair (70)", value = 70), - app_commands.Choice(name = "Good (80)", value = 80), - app_commands.Choice(name = "Very Good (85)", value = 85), - app_commands.Choice(name = "Excellent (90)", value = 90), - app_commands.Choice(name = "Superior (95)", value = 95), - app_commands.Choice(name = "Perfect (100)", value = 100) - ]) - async def post(self, ctx:discord.Interaction, url:str, custom_note:str = None, use_title:bool = True, quality:app_commands.Choice[int] = 95): - try: - domain_info = urlparse(url) - toplevel_domain = '.'.join(domain_info.netloc.split('.')[-2:]) - begin_process = datetime.now().timestamp() - match toplevel_domain: - case "reddit.com": - self._logger.debug(f"Recieved command by {ctx.user} ({ctx.user.id}) for reddit ({url})") - subm:Submission = await ctx.client.reddit_adapter.fetch(url) - image_urls = [] - # Check if submission has a gallery - if hasattr(subm, "media_metadata"): - for media_id, media in subm.media_metadata.items(): - file_extension = media["m"].split("/")[1] - if file_extension not in ("jpg", "jpeg", "png", "webp", "heic", "heif"): - continue - image_urls.append(f"https://i.redd.it/{media_id}.{file_extension}") - else: - image_urls.append(subm.url) - image_count = len(image_urls) - if image_count == 0: - raise NoMediaFound - self._logger.debug(f"Found {image_count} image urls for the post") - - progress_title = f"`{image_count}` images are going to be converted, it may take a while." - progress_temp = progress_title + f"\n`0` of `{image_count}` have already been loaded" - await ctx.response.send_message(progress_temp, ephemeral = True) - - if isinstance(quality, app_commands.Choice): - quality_value = quality.value - else: - quality_value = quality - - # Download and convert each image - image_files:list[discord.File] = [] - begin_conversion = datetime.now().timestamp() - async with aiohttp.ClientSession() as session: - index = 0 - for image_url in image_urls: - async with session.get(image_url) as response: - response.raise_for_status() - image_data = await response.read() - - original_image = Image.open(BytesIO(image_data)) - webp_buffer = BytesIO() - original_image.save(webp_buffer, format = "WEBP", quality = quality_value) - webp_buffer.seek(0) - image_file = discord.File(webp_buffer, filename = f"image_{index}.webp") - image_files.append(image_file) - index += 1 - - progress_temp = progress_title + f"\n`{index}` of `{image_count}` have already been loaded" - await ctx.edit_original_response(content = progress_temp) - - self._logger.debug(f"Downloaded and converted {len(image_files)} images in {get_elapsed_time_milliseconds(datetime.now().timestamp() - begin_conversion)}") - - author = subm.author.name if subm.author else "Author not found" - content = f":copyright: [{author}]({url})" - if use_title: - content += f"\n# {subm.title}" - - if custom_note: - content += f"\n> {custom_note}" - - await ctx.delete_original_response() - message = await ctx.followup.send( - content = content, - suppress_embeds = True, - files = image_files - ) - - self._logger.info(f"Successfully processed the command executed by {ctx.user.name} ({ctx.user.id}) after {get_elapsed_time_milliseconds(datetime.now().timestamp() - begin_process)} (ID of message: {message.id})") - - # No domain for seperation found - case _: - if toplevel_domain == "": - toplevel_domain = "not_found" - - embed = discord.Embed( - title = "Domain not found", - description = f"The requested domain `{toplevel_domain}` is currently not supported\nOpen [an issue](https://github.com/official-Cromatin/Post-It/issues/new?assignees=&labels=feature-request&projects=&template=feature_request.yml) to request support for it.\n\nCurrently supported plattforms:\n- Reddit", - color = 0xED4337) - - await ctx.response.send_message(embed = embed, ephemeral = True) - - except NoMediaFound: - self._logger.error(f"Aborted issued command by {ctx.user.name} ({ctx.user.id}). Post had no media attatched") - - # Delete the original response, if existing - try: - await ctx.delete_original_response() - except discord.NotFound: - pass - embed = discord.Embed( - title = "No media found", - description = "The post had no media attatched, or was in an unsupported format\nVideos are not supported! (yet)", - color = 0xED4337 - ) - embed.set_footer(text = "Supported image formats: jpg, jpeg, png, webp, heic, heif") - - except discord.errors.HTTPException as error: - self._logger.error(f"Could not complete command by {ctx.user.name} ({ctx.user.id})") - self._logger.exception(error, stack_info = True) - - match error.code: - case 40005: - self._logger.error("Failed to upload images, payload too large") - - embed = discord.Embed( - title = "Error while processing", - description = "The attachments exceed the upload limit of Discord,\nchoose a different quality level via the argument `quality`", - color = 0xED4337 - ) - - if ctx.response.is_done(): - await ctx.followup.send(embed = embed, ephemeral = True) - else: - await ctx.response.send_message(embed = embed) - - except Exception as error: - self._logger.error(f"Could not complete command by {ctx.user.name} ({ctx.user.id})") - self._logger.exception(error, stack_info = True) - - # Delete the original response, if existing - try: - await ctx.delete_original_response() - except discord.NotFound: - pass - - # Explain to the user what the error was - embed = discord.Embed( - title = "Error while processing", - description = f"While we processed your request, the following exception occured: `{error}`", - color = 0xED4337 - ) - if ctx.response.is_done(): - await ctx.followup.send(embed = embed, ephemeral = True) - else: - await ctx.response.send_message(embed = embed) - - -async def setup(bot:commands.Bot): - await bot.add_cog(Post_Command(bot)) \ No newline at end of file diff --git a/src/const.py b/src/const.py deleted file mode 100644 index 1520a09..0000000 --- a/src/const.py +++ /dev/null @@ -1,8 +0,0 @@ -from pathlib import Path - -# Read VERSION file -with open(Path(__file__).resolve().parent / "VERSION") as f: - VERSION = f.read().strip() - - -__all__ = VERSION diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 2f0084c..0000000 --- a/src/main.py +++ /dev/null @@ -1,138 +0,0 @@ -from datetime import datetime -from utils.logger.custom_logging import Custom_Logger -import logging -from utils.adv_configparser import Advanced_ConfigParser -from utils.datetime_tools import get_elapsed_time_smal, get_elapsed_time_big, get_elapsed_time_milliseconds -import discord -from discord.ext import commands -from pathlib import Path -import re -import sys -import traceback -import asyncio -from typing import Union -from platforms.reddit import Reddit_Adapter -from const import VERSION - -print(" ____ ____ ___________ __________") -print(" / __ \/ __ \/ ___/_ __/ / _/_ __/") -print(" / /_/ / / / /\__ \ / /_____ / / / / ") -print(" / ____/ /_/ /___/ // /_____// / / / ") -print(f" /_/ \____//____//_/ /___/ /_/ v{VERSION}") -print(" Copyright (c) 2024-2025 Lars Winzer") -print() -print(" Source: https://github.com/official-Cromatin/Post-It") -print(" Report an Issue: https://github.com/official-Cromatin/Post-It/issues/new?assignees=&labels=bug&projects=&template=issue_report.yml") -print("\n") -startup = datetime.now().timestamp() - -# Initialize the logger -Custom_Logger.initialize() -app_logger = logging.getLogger("app") -startup_logger = logging.getLogger("app.startup") - -source_path = Path(__file__).resolve() -base_path = source_path.parents[1] -app_logger.info(f"Using the following path as entrypoint: '{base_path}'") - -intents = discord.Intents.default() -intents.messages = True - -class MyBot(commands.Bot): - def __init__(self): - super().__init__(command_prefix=None, help_command=None, intents=intents) - self.__first_on_ready = False - - self.VERSION = VERSION - self.STARTUP_TIMESTAMP: float = None - self.platforms_config: Advanced_ConfigParser = None - self.bot_config: Advanced_ConfigParser = None - self.reddit_adapter: Reddit_Adapter = None - - self.no_executed_commands:int = 0 - self.no_succeeded_commands:int = 0 - self.no_failed_commands:int = 0 - - async def setup_hook(self): - # Register cogs to handle commands - for cog_name in ["debug", "post"]: - await self.load_extension(f"cogs.{cog_name}") - await self.tree.sync() - - async def on_app_command_completion(self, interaction: discord.Interaction, command: Union[discord.app_commands.Command, discord.app_commands.ContextMenu]): - """Called when a `app_commands.Command` or `app_commands.ContextMenu` has successfully completed without error""" - self.no_succeeded_commands += 1 - - async def on_interaction(self, interaction: discord.Interaction): - """Called when an interaction happened""" - match interaction.type.name: - case discord.InteractionType.application_command.name: - self.no_executed_commands += 1 - case discord.InteractionType.ping.name: - print("App got pinged by discord") - case discord.InteractionType.autocomplete.name: - print("Interaction with autocomplete") - case discord.InteractionType.modal_submit.name: - print("Modal interaction submitted") - case discord.InteractionType.component.name: - print("Component interaction") - - async def on_connect(self): - """A coroutine to be called to setup the bot, after the bot is logged in but before it has connected to the Websocket""" - if not self.__first_on_ready: - startup_logger.info("Beginning startup routine ...") - routine_begin = datetime.now().timestamp() - await self.change_presence(status = discord.Status.dnd, activity = discord.CustomActivity("Executing pre startup routine")) - - # Create the adapters for the platforms - task_start = datetime.now().timestamp() - startup_logger.debug("Loading platforms config ...") - platforms_config = Advanced_ConfigParser(Path.joinpath(base_path, "config", "platforms.ini")) - self.platforms_config = platforms_config - startup_logger.info(f"Loaded platforms config after {get_elapsed_time_milliseconds(datetime.now().timestamp() - task_start)}") - - # Create platforms adapter - task_start = datetime.now().timestamp() - startup_logger.debug("Creating reddit adapter ...") - self.reddit_adapter = Reddit_Adapter(platforms_config["REDDIT"]["CLIENT_ID"], platforms_config["REDDIT"]["CLIENT_SECRET"]) - startup_logger.info(f"Created reddit adapter after {get_elapsed_time_milliseconds(datetime.now().timestamp() - task_start)}") - - await self.change_presence(status = discord.Status.online, activity = None) - startup_logger.info(f"Startup routine finished after {get_elapsed_time_milliseconds(datetime.now().timestamp() - routine_begin)}") - self.__first_on_ready = True - else: - startup_logger.info("Startup routine allready executed, omitting this execution") - - async def on_ready(self): - app_logger.info(f"Successfully logged in (after {get_elapsed_time_smal(datetime.now().timestamp() - startup)}) as {self.user}") - -bot = MyBot() -bot.STARTUP_TIMESTAMP = startup -bot.bot_config = Advanced_ConfigParser(Path.joinpath(base_path, "config", "bot.ini")) -if re.match(r'[A-Za-z\d]{24}\.[\w-]{6}\.[\w-]{27}', bot.bot_config["DISCORD"]["TOKEN"]): - app_logger.critical("Bot (config/bot.ini) configuration invalid, please set a valid token") - quit(1) -elif bot.bot_config.compare_to_template() not in ("equal", "config_minus"): - app_logger.critical("Bot (config/bot.ini) configuration is missing some parts. Make sure it at least has all the same keys as the template") - quit(1) -else: - app_logger.info("Bot configuration valid, continuing with startup") - -# Setup handlers to handle states of command execution -@bot.tree.error -async def on_app_command_error(ctx:discord.Interaction, error): - """Executed when exception during command execution occurs""" - print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr) - traceback.print_exception(type(error), error, error.__traceback__) - - bot.no_failed_commands += 1 - -try: - bot.run(bot.bot_config["DISCORD"]["TOKEN"], log_handler = None) -except discord.errors.LoginFailure: - app_logger.critical("Improper token has been passed. Aborting startup") - quit(1) - -app_logger.info("Quitting application ...") -asyncio.run(bot.close()) -app_logger.info(f"Exiting. Application ran for {get_elapsed_time_big(datetime.now().timestamp() - startup)}") \ No newline at end of file diff --git a/src/platforms/reddit.py b/src/platforms/reddit.py deleted file mode 100644 index b764fb2..0000000 --- a/src/platforms/reddit.py +++ /dev/null @@ -1,45 +0,0 @@ -import asyncpraw -import asyncpraw.models -import logging -from utils.event_counter import Event_Counter -from utils.datetime_tools import get_elapsed_time_milliseconds -from datetime import datetime - -class Reddit_Adapter(asyncpraw.Reddit): - """A class that extends and abstracts the functionality of the `asyncpraw.Reddit` class by adding - logging and request tracking capabilities.""" - VERSION = "1.0" - number_of_instances = 0 - - def __init__(self, client_id:str, client_secret:str): - """Initializes the Reddit Adapter, while stating credentials for the login to the reddit api""" - self.__instance_number = self.__class__.number_of_instances - self.__class__.number_of_instances += 1 - - super().__init__( - client_id = client_id, - client_secret = client_secret, - user_agent="Small discord bot to embed posts (given by url) into an standardized format" - ) - self.__events = Event_Counter(1000) - self.__logger = logging.getLogger(f"pltfm.reddit.{self.__instance_number}") - - async def fetch(self, post_url:str) -> asyncpraw.models.Submission: - """Fetches specified submission (post) and returns it""" - start_time = datetime.now().timestamp() - self.__events.increment() - subm = await self.submission(url = post_url) - self.__logger.debug(f"Submission for post (URL: {post_url}), successfully fetched after {get_elapsed_time_milliseconds(datetime.now().timestamp() - start_time)}") - return subm - - def get_total_requests(self) -> int: - """Returns the total number of requests made since the creation of the adapter""" - return self.__events.get_total_events() - - def get_events_last_5m(self) -> int: - """Returns the number of requests made in the last 5 minutes""" - return self.__events.get_count("5m") - - def get_events_last_5m_10m_15m(self) -> tuple[int]: - """Returns an tuple containing the number of requests made in the last 5, 10 and 15 minutes""" - return (self.__events.get_count("5m"), self.__events.get_count("10m"), self.__events.get_count("15m")) \ No newline at end of file diff --git a/src/utils/adv_configparser.py b/src/utils/adv_configparser.py deleted file mode 100644 index 7c9a5fd..0000000 --- a/src/utils/adv_configparser.py +++ /dev/null @@ -1,249 +0,0 @@ -import configparser -import logging -from os.path import isfile, split -from pathlib import Path -from shutil import copy - -class Advanced_ConfigParser(configparser.ConfigParser): - """Utility class to ease the use of the configparser libary""" - VERSION = "2.5" - number_of_instances = 0 - - def __init__(self, path:str, allow_template:bool = True, allow_update:bool = True) -> None: - """ - Initializes the Advanced_ConfigParser instance. - - Args: - path (str): The path to the configuration file. - allow_template (bool, optional): Determines if an existing template can be used when the config file is missing. Default is True. - allow_update (bool, optional): Specifies if the config file should be updated based on a template. Default is True. - - This constructor loads the configuration file if it exists, optionally creates one from a template if allowed, - and can update the configuration based on the template. It also initializes logging and tracks pending changes. - """ - super().__init__() - self.__instance_number = self.__class__.number_of_instances - self.__class__.number_of_instances += 1 - self.__from_template = False - - self.__logger = logging.getLogger(f"utils.config.{self.__instance_number}") - self.__logger.debug(f"New instance {self.__instance_number} of class created") - - # Check existence of provided file - self.__template_path = self.__get_template_path(path) - if isfile(path): - self.__logger.debug(f"Opening existing file at path: '{path}'") - else: - # Check if it is allowed to use an existing template - if allow_template: - if isfile(self.__template_path): - self.__logger.info(f"Using existing template file {self.__template_path}") - copy(self.__template_path, path) - self.__from_template = True - else: - self.__logger.warning("No potential template found, starting with empty file") - - # Load the configfile - self.__file_path = path - self.__pending_changes = 0 - - try: - self.read_file(open(path)) - except: - pass - else: - self.__logger.info(f"Successfully opened config file at path {path}") - - # Check for update if allowed - if allow_update: - self.check_for_update() - - def __get_template_path(self, path:str) -> str | None: - """Looks for a existing template and returns the path to it - - If no config file was found, None is returned""" - path_to_file, file_name = split(path) - file_name = file_name.split(".")[0] - path_to_temp = Path(path_to_file) / f".{file_name}.template" - - return path_to_temp - - def __has_all_template_options(self, template:configparser.ConfigParser): - """Check if config contains all sections and options from the template""" - for section in template.sections(): - if section not in self: - return False - template_options = set(template.options(section)) - config_options = set(config.options(section)) - if not template_options.issubset(config_options): - return False - return True - - def set(self, section: str, option: str, value: str | None = None) -> None: - old_value = self.get(section, option, fallback=None) - if old_value: - self.__logger.debug(f"New value in section '{section}' at option '{option}' added: '{value}'") - self.__pending_changes += 1 - else: - if self.has_option(section, option): - self.__logger.debug(f"Value in section '{section}' at option '{option}' changed: '{old_value}' -> '{value}'") - if old_value != value: - self.__pending_changes += 1 - - return super().set(section, option, value) - - def remove_option(self, section, option): - if self.has_option(section, option): - value = self.get(section, option) - self.__logger.debug(f"Option removed in section '{section}' for '{option}': '{value}'") - self.__pending_changes += 1 - - return super().remove_option(section, option) - - def remove_section(self, section): - if self.has_section(section): - self.__logger.info(f"Section removed: '{section}'") - self.__pending_changes += 1 - - return super().remove_section(section) - - def save(self): - """Saves changes to disk""" - with open(self.__file_path, 'w') as configfile: - self.write(configfile) - self.__logger.debug(f"Contents ({self.__pending_changes} changes) have been saved to disk") - - def get_config_file_path(self) -> str: - """Returns the path to the config file""" - return self.__file_path - - def get_template_file_path(self) -> str: - """Returns the path to the template file, a config could be created from - - Returns None if the is no template file""" - return self.__template_path - - def compare_to_template(self) -> str: - """Returns one of multiple possible keywords depending on the relation of both files - - +-----------------+--------------------------------------------------------------------------------------------------------------------------+ - | Return Keyword | Description | - +-----------------+--------------------------------------------------------------------------------------------------------------------------+ - | not_found | No template file found | - | equal | Both files contain the same sections and options | - | config_plus | Config contains additional sections and/or options compared to template | - | config_options | Config contains additional options but same sections | - | config_base | Same as "config_plus" but additionally config contains all sections and options from the template | - | config_minus | Config contains fewer sections and/or options as template | - +-----------------+--------------------------------------------------------------------------------------------------------------------------+""" - - # Check existence of template - if self.__template_path == None: - return "not_found" - - template_config = configparser.ConfigParser() - template_config.read_file(open(self.__template_path)) - config_sections = set(self.sections()) - template_sections = set(template_config.sections()) - - # Check if config contains all the same (or more) sections than template - same_or_more_sections = config_sections >= template_sections - - # Check if both files have the same amount of sections - same_amount_of_sections = len(config_sections) == len(template_sections) - - # Consolidate the options of both files - all_config_options = set() - all_template_options = set() - all_sections = config_sections.union(template_sections) - config_all_present = True - template_all_present = True - for section in all_sections: - try: - for option in self.options(section): - all_config_options.add(f"{section}.{option}") - except configparser.NoSectionError: - config_all_present = False - - try: - for option in template_config.options(section): - all_template_options.add(f"{section}.{option}") - except configparser.NoSectionError: - template_all_present = False - - # Check if both have the same options - same_or_more_options = all_config_options >= all_template_options - - # Check if both have the same amount of options - same_amount_of_options = len(all_config_options) == len(all_template_options) - - # Check for different cases - # Since both files have the same (or more) options (and the options contain the section name) and contain the same amount, they must be equal - if same_or_more_options and same_amount_of_options: - return "equal" - - # Since the config contains not even the same sections as present in the template, it doesn't contain the same base options - if not same_or_more_sections: - return "config_minus" - - # If the config contains more sections or options than the template - if same_or_more_sections: - if same_or_more_options: - # If config contains all sections and options from template but also has extras - return "config_base" - else: - # Config has extra sections and/or options, but not necessarily all from template - return "config_plus" - - # If the config contains the same sections but additional options within those sections - if same_amount_of_sections and not same_amount_of_options: - return "config_options" - - return "config_plus" - - def created_from_template(self) -> bool: - """Will ONLY return `True` if the config file has been created from template at the first run""" - return self.__from_template - - def check_for_update(self, omit_save:bool = False): - """Updates the already created config to the same level as the template, - - no options are deleted or their values changed.""" - if self.__template_path == None: - raise FileNotFoundError - template_config = configparser.ConfigParser() - template_config.read_file(open(self.__template_path)) - - updated_options = 0 - for section in template_config.sections(): - options = template_config.options(section) - - for option in options: - if not self.has_option(section, option): - self.set(section, option, template_config.get(section, option)) - updated_options += 1 - - if updated_options: - _, file_name = split(self.__file_path) - self.__logger.error(f"The confg updated at {updated_options} locations, you might want check the {file_name} for unchanged placeholders") - - if not omit_save: - self.save() - -if __name__ == "__main__": - # Dummy logger - console = logging.StreamHandler() - console.setLevel(logging.DEBUG) - logger = logging.getLogger("utils") - logger.addHandler(console) - logger.setLevel(logging.DEBUG) - - file_path = Path(__file__).resolve() - - base_path = file_path.parents[2] - print(f"Base path of the program: {base_path}") - - config = Advanced_ConfigParser(Path.joinpath(base_path, "config", "bot.ini")) - print(config.compare_to_template()) - # config["DEFAULT"]["Compression"] = "no" - # config.save() \ No newline at end of file diff --git a/src/utils/datetime_tools.py b/src/utils/datetime_tools.py deleted file mode 100644 index 04b8d2a..0000000 --- a/src/utils/datetime_tools.py +++ /dev/null @@ -1,26 +0,0 @@ -from datetime import datetime - -def get_elapsed_time(timestamp:float) -> str: - """Convert an timestamp into an predefined elapsed time format (00min 00sec), without miliseconds""" - time_elapsed = datetime.fromtimestamp(timestamp) - return f"{time_elapsed.minute // 60}min {time_elapsed.second:02}sec" - -def get_elapsed_time_ms(timestamp:float) -> str: - """Convert an timestamp into an predefined elapsed time format (00min 00sec 000ms), with miliseconds""" - time_elapsed = datetime.fromtimestamp(timestamp) - return f"{time_elapsed.minute // 60}min {time_elapsed.second:02}sec {time_elapsed.microsecond // 1000}ms" - -def get_elapsed_time_smal(timestamp:float) -> str: - """Convert an timestamp into an predefined elapsed time format (00sec 000ms)""" - time_elapsed = datetime.fromtimestamp(timestamp) - return f"{time_elapsed.second:02}sec {time_elapsed.microsecond // 1000:03}ms" - -def get_elapsed_time_big(timestamp:float) -> str: - """Convert an timestamp into an predefined elapsed time format (00days 00hrs 00min)""" - time_elapsed = datetime.fromtimestamp(timestamp) - return f"{time_elapsed.day - 1}days {time_elapsed.hour - 1}hrs {1 if time_elapsed.minute == 0 else time_elapsed.minute}min" - -def get_elapsed_time_milliseconds(timestamp:float) -> str: - """Convert an timestamp into an predefined elapsed time format (0000ms)""" - time_elapsed = datetime.fromtimestamp(timestamp) - return f"{time_elapsed.second * 1000 + time_elapsed.microsecond // 1000}ms" \ No newline at end of file diff --git a/src/utils/event_counter.py b/src/utils/event_counter.py deleted file mode 100644 index 5b5a596..0000000 --- a/src/utils/event_counter.py +++ /dev/null @@ -1,110 +0,0 @@ -from datetime import datetime -import re - -class Event_Counter: - """A class to track and count events, storing the event timestamps and allowing queries - - for the number of events in specific time windows (e.g., last 5 minutes, 10 minutes).""" - REGEX_PATTERN = r'(\d+)\s*(sec|second|seconds|s|min|minute|minutes|m|h|hour|hours|d|day|days)' - TIME_MULTIPLIERS = { - 'sec': 1, 'second': 1, 'seconds': 1, 's': 1, - 'min': 60, 'minute': 60, 'minutes': 60, 'm': 60, - 'h': 3600, 'hour': 3600, 'hours': 3600, - 'd': 86400, 'day': 86400, 'days': 86400 - } - - def __init__(self, retention_duration:int = 900) -> None: - """ - Initializes the Event_Counter object with a specified retention duration. - - Args: - retention_duration (int, optional): - The time period (in seconds) for which event data should be retained. Defaults to 900 (15 minutes). - - uwu - """ - self.__events:list[float] = [] - self.__total_events = 0 - self.__retention_duration = retention_duration - - def cleanup(self): - """Cleans up events that are older than the retention duration. - - Removes all events that occurred outside the defined retention window.""" - offset_time = datetime.now().timestamp() - self.__retention_duration - while len(self.__events) > 0: - event_time = self.__events[0] - if event_time < offset_time: - self.__events.pop(0) - else: - break - - @classmethod - def duration_to_seconds(cls, duration_str:str) -> int: - """Converts a human-readable duration string (e.g., '5min 2sec') into its equivalent in seconds. - - Args: - duration_str (str): The string representing the duration (e.g., "5min 2sec"). - - Returns: - int: The total duration in seconds derived from the input string.""" - matches = re.findall(cls.REGEX_PATTERN, duration_str, flags=re.IGNORECASE) - - total_seconds = 0 - for value, unit in matches: - unit = unit.lower() - total_seconds += int(value) * cls.TIME_MULTIPLIERS[unit] - - return total_seconds - - def increment(self, count:int = 1, skip_cleanup:bool = False): - """Increments the event count by a specified number of events. - - Args: - count (int, optional): The number of events to add. Defaults to 1. - skip_cleanup (bool, optional): If True, skips the cleanup process. Defaults to False.""" - timestamp = datetime.now().timestamp() - self.__total_events += count - self.__events.extend([timestamp] * count) - - if skip_cleanup: - return - self.cleanup() - - def get_count(self, time_window:str = "5M") -> int: - """Returns the number of events that occurred within a specified time window. - - Args: - time_window (str, optional): - The time window to query, e.g., "5M" for 5 minutes. Defaults to "5M". - - Returns: - int: The count of events that occurred within the specified time window.""" - self.cleanup() - - offset_time = datetime.now().timestamp() - self.duration_to_seconds(time_window) - counter = 0 - for event_time in self.__events: - if event_time > offset_time: - counter += 1 - else: - break - - return counter - - def get_total_events(self) -> int: - """Returns the total number of events tracked by the Event_Counter. - - Returns: - int: The total number of events that have been recorded.""" - return self.__total_events - -if __name__ == "__main__": - from time import sleep - counter = Event_Counter(1) - counter.increment() - sleep(1) - counter.increment() - print(counter.get_count("5sEc")) - sleep(0) - print(counter.get_count("5sEc")) \ No newline at end of file diff --git a/src/utils/logger/custom_logging.py b/src/utils/logger/custom_logging.py deleted file mode 100644 index 1b6a776..0000000 --- a/src/utils/logger/custom_logging.py +++ /dev/null @@ -1,37 +0,0 @@ -import logging -from utils.logger.formatter import Colored_Formatter - -class Custom_Logger: - console_handler: logging.StreamHandler - colored_formatter: Colored_Formatter - - @classmethod - def initialize(cls): - # Create the handler for logging to the console - cls.console_handler = logging.StreamHandler() - cls.console_handler.setLevel(logging.DEBUG) - - # Create the colorful formatter - cls.colored_formatter = Colored_Formatter() - cls.console_handler.setFormatter(cls.colored_formatter) - - # Create the loggers for the different sections of the app - discordpy_logger = logging.getLogger("discord") - discordpy_logger.addHandler(cls.console_handler) - discordpy_logger.setLevel(logging.INFO) - - app_logger = logging.getLogger("app") - app_logger.addHandler(cls.console_handler) - app_logger.setLevel(logging.DEBUG) - - utils_logger = logging.getLogger("utils") - utils_logger.addHandler(cls.console_handler) - utils_logger.setLevel(logging.DEBUG) - - commands_logger = logging.getLogger("cmds") - commands_logger.addHandler(cls.console_handler) - commands_logger.setLevel(logging.DEBUG) - - logging.getLogger('discord.app_commands.tree').setLevel(logging.DEBUG) - - app_logger.debug("Logging successfully initialized") \ No newline at end of file diff --git a/src/utils/logger/decorator.py b/src/utils/logger/decorator.py deleted file mode 100644 index c48aa77..0000000 --- a/src/utils/logger/decorator.py +++ /dev/null @@ -1,11 +0,0 @@ -from functools import wraps -import discord - -def log_command_execution(func): - """Wraps an command function to print execution logs to the console""" - @wraps(func) - async def wrapper(self, interaction:discord.Interaction, *args, **kwargs): - self._logger.debug(f"User {interaction.user} in channel {interaction.channel} on guild {interaction.guild} executed the {func.__name__} command") - - return await func(self, interaction, *args, **kwargs) - return wrapper diff --git a/src/utils/logger/formatter.py b/src/utils/logger/formatter.py deleted file mode 100644 index 2d4a036..0000000 --- a/src/utils/logger/formatter.py +++ /dev/null @@ -1,30 +0,0 @@ -from logging import Formatter -from colorama import Style, Fore - -class Colored_Formatter(Formatter): - def __init__(self, fmt=None, datefmt='%Y-%m-%d %H:%M:%S', style='%'): - super().__init__(fmt, datefmt, style) - - def format(self, record): - # Formatieren des Zeitstempels fett - date = f"{Style.BRIGHT}{Fore.BLACK}{self.formatTime(record, self.datefmt)}{Style.RESET_ALL}" - - # Färben des Log-Levels mit fester Breite - levelname_color = { - 'DEBUG': Fore.CYAN, - 'INFO': Fore.BLUE, - 'WARNING': Fore.YELLOW, - 'ERROR': Fore.RED, - 'CRITICAL': f"{Style.BRIGHT}{Fore.RED}" - } - levelname = f"{levelname_color.get(record.levelname, Fore.WHITE)}{record.levelname:<8}{Fore.RESET}{Style.RESET_ALL}" - - # Färben des Logger-Namens in Magenta mit fester Breite - name = f"{Fore.MAGENTA}{record.name:<20}{Fore.RESET}" - - # Die Nachricht bleibt unverändert - message = record.getMessage() - - # Zusammenfügen der gefärbten Teile - formatted_record = f"{date} {levelname} {name} {message}" - return formatted_record \ No newline at end of file diff --git a/src/utils/singleton.py b/src/utils/singleton.py deleted file mode 100644 index 63f90df..0000000 --- a/src/utils/singleton.py +++ /dev/null @@ -1,38 +0,0 @@ -class Singleton: - """ - A non-thread-safe helper class to ease implementing singletons. - This should be used as a decorator -- not a metaclass -- to the - class that should be a singleton. - - The decorated class can define one `__init__` function that - takes only the `self` argument. Also, the decorated class cannot be - inherited from. Other than that, there are no restrictions that apply - to the decorated class. - - To get the singleton instance, use the `instance` method. Trying - to use `__call__` will result in a `TypeError` being raised. - - """ - # Source: https://stackoverflow.com/questions/31875/is-there-a-simple-elegant-way-to-define-singletons - - def __init__(self, decorated): - self._decorated = decorated - - def instance(self): - """ - Returns the singleton instance. Upon its first call, it creates a - new instance of the decorated class and calls its `__init__` method. - On all subsequent calls, the already created instance is returned. - - """ - try: - return self._instance - except AttributeError: - self._instance = self._decorated() - return self._instance - - def __call__(self): - raise TypeError('Singletons must be accessed through `instance()`.') - - def __instancecheck__(self, inst): - return isinstance(inst, self._decorated) \ No newline at end of file diff --git a/src/utils/truncate_str.py b/src/utils/truncate_str.py deleted file mode 100644 index be86883..0000000 --- a/src/utils/truncate_str.py +++ /dev/null @@ -1,21 +0,0 @@ -def truncate_message_with_notice(output:str, max_length:int = 1000, suffix:str = "Output was truncated") -> str: - """Trim a long output string to a specified length and append a suffix message if truncation occurs""" - - total_max_length = max_length - len(suffix) # Adjust for the suffix length - - # If the output is already shorter than the max length, return it as is - if len(output) <= max_length: - return output - - # Split the output into lines and add lines until the length exceeds the allowed maximum - result = "" - lines = output.splitlines() - - for line in lines: - if len(result) + len(line) + 1 > total_max_length: # +1 accounts for the line break - break - result += line + "\n" - - # Append the suffix message - result += suffix - return result \ No newline at end of file -- 2.52.0 From 22a3b14edd2799ee743cdc3ebeeab7fa08a5440f Mon Sep 17 00:00:00 2001 From: Lars Winzer Date: Mon, 1 Jun 2026 18:26:15 +0200 Subject: [PATCH 2/4] Remove: Old dockerfile and vscode tasks --- .dockerignore | 28 ---------------------------- .vscode/tasks.json | 36 ------------------------------------ Dockerfile | 22 ---------------------- 3 files changed, 86 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .vscode/tasks.json delete mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4c721fc..0000000 --- a/.dockerignore +++ /dev/null @@ -1,28 +0,0 @@ -**/__pycache__ -**/.venv -**/.classpath -**/.dockerignore -**/.env -**/.git -**/.github -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/bin -**/charts -**/docker-compose* -**/compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index c8c52da..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Docker: Build Image", - "type": "docker-build", - "platform": "python", - "dockerBuild": { - "context": "${workspaceFolder}", - "dockerfile": "${workspaceFolder}/Dockerfile", - "tag": "post-it:dev" - } - }, - { - "label": "Run post-it (interactive, show logs)", - "type": "shell", - "dependsOn": ["Docker: Build Image"], - "command": "docker", - "args": [ - "run", - "--rm", - "-it", - "--name", - "post-it-dev", - "-v", - "${workspaceFolder}/config:/app/config", - "post-it:dev" - ], - "presentation": { - "reveal": "always", - "panel": "shared" - }, - "problemMatcher": [] - } - ] -} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e24116d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -# Base image (multi-arch via buildx) -FROM python:3.10-slim - -# Prevents pyc files + enables unbuffered logging -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 - -# Install dependencies -WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy only necessary files (via .dockerignore) -COPY . . - -# Create non-root user -RUN adduser --disabled-password --no-create-home --gecos "" appuser \ - && chown -R appuser /app -USER appuser - -# Default command -ENTRYPOINT ["python", "src/main.py"] -- 2.52.0 From a24fd875d835d44fbe1f3c16a6d035c4c81f6845 Mon Sep 17 00:00:00 2001 From: Lars Winzer Date: Mon, 1 Jun 2026 19:47:14 +0200 Subject: [PATCH 3/4] Chore: Update .gitignore file to be suitable for java project --- .gitignore | 212 +++++++++++++++-------------------------------------- 1 file changed, 61 insertions(+), 151 deletions(-) diff --git a/.gitignore b/.gitignore index 41d1c7a..12b54d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,172 +1,82 @@ -# Custom additions -logs/* -config/*.ini -pastebin.txt -.DS_Store -test*.py -docker-* -.vscode/settings.json +######## +# Java # +######## +# Compiled class file +*.class -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: +# Log file *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal -# Flask stuff: -instance/ -.webassets-cache +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar -# Scrapy stuff: -.scrapy +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* -# Sphinx documentation -docs/_build/ -# PyBuilder -.pybuilder/ -target/ +########## +# Gradle # +########## -# Jupyter Notebook -.ipynb_checkpoints +# Gradle files +.gradle +**/build/ +!**/src/**/build/ -# IPython -profile_default/ -ipython_config.py +# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) +!gradle-wrapper.jar -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version +# Avoid ignore Gradle wrappper properties +!gradle-wrapper.properties -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock +# Cache of project +.gradletasknamecache -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +# Gradle properties +gradle.properties -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ +######### +# MacOS # +######### -# Celery stuff -celerybeat-schedule -celerybeat.pid +# General +.DS_Store +__MACOSX/ +.AppleDouble +.LSOverride +Icon[] -# SageMath parsed files -*.sage.py +# Thumbnails +._* -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent -# Spyder project settings -.spyderproject -.spyproject +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk -# Rope project settings -.ropeproject -# mkdocs documentation -/site +######## +# Jenv # +######## -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.java-version -- 2.52.0 From dd4a0374795f3229c25f21e3851abe66152ac157 Mon Sep 17 00:00:00 2001 From: Lars Winzer Date: Mon, 1 Jun 2026 19:52:48 +0200 Subject: [PATCH 4/4] Add: Default java project with gradle --- build.gradle | 43 ++++ gradle/libs.versions.toml | 10 + gradle/wrapper/gradle-wrapper.jar | 3 + gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++++++++++++++++++++ gradlew.bat | 93 +++++++++ settings.gradle | 13 ++ src/main/java/postit/App.java | 14 ++ src/test/java/postit/AppTest.java | 14 ++ 9 files changed, 445 insertions(+) create mode 100644 build.gradle create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/postit/App.java create mode 100644 src/test/java/postit/AppTest.java diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9c4323c --- /dev/null +++ b/build.gradle @@ -0,0 +1,43 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java application project to get you started. + * For more details on building Java & JVM projects, please refer to https://docs.gradle.org/9.2.0/userguide/building_java_projects.html in the Gradle documentation. + */ + +plugins { + // Apply the application plugin to add support for building a CLI application in Java. + id 'application' +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + // Use JUnit Jupiter for testing. + testImplementation libs.junit.jupiter + + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // This dependency is used by the application. + implementation libs.guava +} + +// Apply a specific Java toolchain to ease working on different environments. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +application { + // Define the main class for the application. + mainClass = 'postit.App' +} + +tasks.named('test') { + // Use JUnit Platform for unit tests. + useJUnitPlatform() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..8da4f83 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,10 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format + +[versions] +guava = "33.4.6-jre" +junit-jupiter = "5.12.1" + +[libraries] +guava = { module = "com.google.guava:guava", version.ref = "guava" } +junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..79a0504 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.jar @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:423cb469ccc0ecc31f0e4e1c309976198ccb734cdcbb7029d4bda0f18f57e8d9 +size 45633 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bad7c24 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..86c9ec2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,13 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * The settings file is used to specify which projects to include in your build. + * For more detailed information on multi-project builds, please refer to https://docs.gradle.org/9.2.0/userguide/multi_project_builds.html in the Gradle documentation. + */ + +plugins { + // Apply the foojay-resolver plugin to allow automatic download of JDKs + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'PostIt' diff --git a/src/main/java/postit/App.java b/src/main/java/postit/App.java new file mode 100644 index 0000000..3ec2edd --- /dev/null +++ b/src/main/java/postit/App.java @@ -0,0 +1,14 @@ +/* + * This source file was generated by the Gradle 'init' task + */ +package postit; + +public class App { + public String getGreeting() { + return "Hello World!"; + } + + public static void main(String[] args) { + System.out.println(new App().getGreeting()); + } +} diff --git a/src/test/java/postit/AppTest.java b/src/test/java/postit/AppTest.java new file mode 100644 index 0000000..c02e3a3 --- /dev/null +++ b/src/test/java/postit/AppTest.java @@ -0,0 +1,14 @@ +/* + * This source file was generated by the Gradle 'init' task + */ +package postit; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class AppTest { + @Test void appHasAGreeting() { + App classUnderTest = new App(); + assertNotNull(classUnderTest.getGreeting(), "app should have a greeting"); + } +} -- 2.52.0