diff --git a/config/emojis.conf b/config/emojis.conf index 7cb37a00..b7df4803 100644 --- a/config/emojis.conf +++ b/config/emojis.conf @@ -55,3 +55,5 @@ coin = <:coin:975880967485022239> task_checked = :🟢: task_unchecked = :⚫: +task_new = :➕: +task_save = :💾: diff --git a/scripts/maketestlang.py b/scripts/maketestlang.py index 2ce09bd3..02798eb2 100644 --- a/scripts/maketestlang.py +++ b/scripts/maketestlang.py @@ -62,13 +62,16 @@ def process_pot(domain, path): po.save(targetpo) po.save_as_mofile(targetmo) print(f"Processed {entries} from POT {domain}.") + return entries def process_all(): + total = 0 for file in os.scandir(templates): if file.name.endswith('pot'): print(f"Processing pot: {file.name[:-4]}") - process_pot(file.name[:-4], file.path) + total += process_pot(file.name[:-4], file.path) + print(f"Total strings: {total}") if __name__ == '__main__': diff --git a/src/modules/meta/help_sections.py b/src/modules/meta/help_sections.py index 279d6426..f20e5edd 100644 --- a/src/modules/meta/help_sections.py +++ b/src/modules/meta/help_sections.py @@ -21,9 +21,9 @@ cmd_map = { "cmd_shop": "shop open", "cmd_room": "room rent", "cmd_reminders": "remindme in", - "cmd_tasklist": "tasklist open", + "cmd_tasklist": "tasklist", "cmd_timers": "timers list", - "cmd_schedule": "schedule book", + "cmd_schedule": "schedule", "cmd_dashboard": "dashboard" } diff --git a/src/modules/schedule/cog.py b/src/modules/schedule/cog.py index 4883117a..6607ee7e 100644 --- a/src/modules/schedule/cog.py +++ b/src/modules/schedule/cog.py @@ -238,7 +238,7 @@ class ScheduleCog(LionCog): slot = self.active_slots.get(slotid, None) session = slot.sessions.get(guildid, None) if slot else None - member = session.pop(userid, None) if session else None + member = session.members.pop(userid, None) if session else None if member is not None: if slot.closing.is_set(): # Don't try to cancel a booking for a closing active slot. diff --git a/src/modules/schedule/core/timeslot.py b/src/modules/schedule/core/timeslot.py index d7bee672..42f8173f 100644 --- a/src/modules/schedule/core/timeslot.py +++ b/src/modules/schedule/core/timeslot.py @@ -210,16 +210,16 @@ class TimeSlot: await batchrun_per_second(coros, 5) # Save messageids - if sessions: - tmptable = TemporaryTable( - '_gid', '_sid', '_mid', - types=('BIGINT', 'INTEGER', 'BIGINT') - ) - tmptable.values = [ - (sg.data.guildid, sg.data.slotid, sg.messageid) - for sg in sessions - if sg.messageid is not None - ] + tmptable = TemporaryTable( + '_gid', '_sid', '_mid', + types=('BIGINT', 'INTEGER', 'BIGINT') + ) + tmptable.values = [ + (sg.data.guildid, sg.data.slotid, sg.messageid) + for sg in sessions + if sg.messageid is not None + ] + if tmptable.values: await Data.ScheduleSession.table.update_where( guildid=tmptable['_gid'], slotid=tmptable['_sid'] ).set( diff --git a/src/modules/tasklist/cog.py b/src/modules/tasklist/cog.py index c5483494..2f447571 100644 --- a/src/modules/tasklist/cog.py +++ b/src/modules/tasklist/cog.py @@ -1,4 +1,5 @@ from typing import Optional +from collections import defaultdict import datetime as dt import discord @@ -9,7 +10,7 @@ from discord.app_commands.transformers import AppCommandOptionType as cmdopt from meta import LionBot, LionCog, LionContext from meta.errors import UserInputError from utils.lib import utc_now, error_embed -from utils.ui import ChoicedEnum, Transformed +from utils.ui import ChoicedEnum, Transformed, AButton from data import Condition, NULL from wards import low_management_ward @@ -17,10 +18,10 @@ from wards import low_management_ward from . import babel, logger from .data import TasklistData from .tasklist import Tasklist -from .ui import TasklistUI, SingleEditor, BulkEditor +from .ui import TasklistUI, SingleEditor, BulkEditor, TasklistCaller from .settings import TasklistSettings, TasklistConfigUI -_p = babel._p +_p, _np = babel._p, babel._np MAX_LENGTH = 100 @@ -128,13 +129,14 @@ class TasklistCog(LionCog): self.babel = babel self.settings = TasklistSettings() + self.live_tasklists = TasklistUI._live_ + async def cog_load(self): await self.data.init() self.bot.core.guild_config.register_model_setting(self.settings.task_reward) self.bot.core.guild_config.register_model_setting(self.settings.task_reward_limit) + self.bot.add_view(TasklistCaller(self.bot)) - # TODO: Better method for getting single load - # Or better, unloading crossloaded group configcog = self.bot.get_cog('ConfigCog') self.crossload_group(self.configure_group, configcog.configure_group) @@ -164,30 +166,164 @@ class TasklistCog(LionCog): f"'{amount}' coins for completing '{count}' tasks." ) + async def is_tasklist_channel(self, channel) -> bool: + if not channel.guild: + return True + channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value + return (channel in channels) or (channel.category in channels) + + async def call_tasklist(self, interaction: discord.Interaction): + await interaction.response.defer(thinking=True, ephemeral=True) + channel = interaction.channel + guild = channel.guild + userid = interaction.user.id + + tasklist = await Tasklist.fetch(self.bot, self.data, userid) + + if await self.is_tasklist_channel(channel): + tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=None) + await tasklistui.summon(force=True) + await interaction.delete_original_response() + else: + # Note that this will also close any existing listening tasklists in this channel (for this user) + tasklistui = TasklistUI.fetch(tasklist, channel, guild, timeout=600) + await tasklistui.run(interaction) + + @LionCog.listener('on_tasklist_update') + async def update_listening_tasklists(self, userid, channel=None, summon=True): + """ + Propagate a tasklist update to all persistent tasklist UIs for this user. + + If channel is given, also summons the UI if the channel is a tasklist channel. + """ + # Do the given channel first, and summon if requested + if channel and (tui := TasklistUI._live_[userid].get(channel.id, None)) is not None: + try: + if summon and await self.is_tasklist_channel(channel): + await tui.summon() + else: + await tui.refresh() + await tui.redraw() + except discord.HTTPException: + await tui.close() + + # Now do the rest of the listening channels + listening = TasklistUI._live_[userid] + for cid, ui in listening.items(): + if channel and channel.id == cid: + continue + try: + await ui.refresh() + await ui.redraw() + except discord.HTTPException: + await tui.close() + + @cmds.hybrid_command( + name=_p('cmd:tasklist', "tasklist"), + description=_p( + 'cmd:tasklist|desc', + "Open your tasklist." + ) + ) + async def tasklist_cmd(self, ctx: LionContext): + if not ctx.interaction: + return + await self.call_tasklist(ctx.interaction) + @cmds.hybrid_group( - name=_p('group:tasklist', "tasklist") + name=_p('group:tasks', "tasks"), + description=_p('group:tasks|desc', "Base command group for tasklist commands.") ) async def tasklist_group(self, ctx: LionContext): raise NotImplementedError - async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + async def _task_acmpl(self, userid: int, partial: str, multi=False) -> list[appcmds.Choice]: + """ + Generate a list of task Choices matching a given partial string. + + Supports single and multiple task matching. + """ t = self.bot.translator.t # Should usually be cached, so this won't trigger repetitive db access - tasklist = await Tasklist.fetch(self.bot, self.data, interaction.user.id) + tasklist = await Tasklist.fetch(self.bot, self.data, userid) + + # Special case for an empty tasklist + if not tasklist.tasklist: + return [ + appcmds.Choice( + name=t(_p( + 'argtype:taskid|error:no_tasks', + "Tasklist empty! No matching tasks." + )), + value=partial + ) + ] labels = [] + idmap = {} for label, task in tasklist.labelled.items(): labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1) taskstring = f"{labelstring} {task.content}" + idmap[task.taskid] = labelstring labels.append((labelstring, taskstring)) + # Assume user is typing a label matching = [(label, task) for label, task in labels if label.startswith(partial)] + # If partial does match any labels, search for partial in task content if not matching: matching = [(label, task) for label, task in labels if partial.lower() in task.lower()] - if not matching: + if matching: + # If matches were found, assume user wants one of the matches + options = [ + appcmds.Choice(name=task_string, value=label) + for label, task_string in matching + ] + elif multi and (',' in partial or '-' in partial): + # Try parsing input as a multi-list + try: + parsed = tasklist.parse_labels(partial) + multi_name = ', '.join(idmap[tid] for tid in parsed) + if len(multi_name) > 100: + multi_name = multi_name[:96] + multi_name, _ = multi_name.rsplit(',', maxsplit=1) + multi_name = multi_name + ', ...' + except UserInputError as e: + parsed = [] + error = t(_p( + 'argtype:taskid|error:parse_multi', + "(Warning: {error})" + )).format( + error=e.msg + ) + remaining = 100 - len(error) + multi_name = f"{partial[:remaining-1]} {error}" + + multi_option = appcmds.Choice( + name=multi_name, + value=partial + ) + options = [multi_option] + # Regardless of parse status, show matches with last split, if they exist. + if ',' in partial: + _, last_split = partial.rsplit(',', maxsplit=1) + else: + last_split = partial + if '-' in last_split: + _, last_split = last_split.rsplit('-', maxsplit=1) + last_split = last_split.strip(' ') + else: + last_split = last_split.strip(' ') + matching = [(label, task) for label, task in labels if label.startswith(last_split)] + if not matching: + matching = [(label, task) for label, task in labels if last_split.lower() in task.lower()] + options.extend( + appcmds.Choice(name=task_string, value=label) + for label, task_string in matching + ) + else: options = [ appcmds.Choice( name=t(_p( @@ -197,73 +333,34 @@ class TasklistCog(LionCog): value=partial ) ] - else: - options = [ - appcmds.Choice(name=task_string, value=label) - for label, task_string in matching - ] return options[:25] - async def is_tasklist_channel(self, channel) -> bool: - if not channel.guild: - return True - channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value - return (not channels) or (channel in channels) or (channel.category in channels) + async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + """ + Shared autocomplete for single task parameters. + """ + return await self._task_acmpl(interaction.user.id, partial, multi=False) + + async def tasks_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]: + """ + Shared autocomplete for multiple task parameters. + """ + return await self._task_acmpl(interaction.user.id, partial, multi=True) @tasklist_group.command( - name=_p('cmd:tasklist_open', "open"), + name=_p('cmd:tasks_new', "new"), description=_p( - 'cmd:tasklist_open|desc', - "Open your tasklist." - ) - ) - async def tasklist_open_cmd(self, ctx: LionContext): - # TODO: Further arguments for style, e.g. gui/block/text - if await self.is_tasklist_channel(ctx.channel): - await ctx.interaction.response.defer(thinking=True, ephemeral=True) - tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id) - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - await ctx.interaction.delete_original_response() - else: - t = self.bot.translator.t - channels = (await self.settings.tasklist_channels.get(ctx.guild.id)).value - viewable = [ - channel for channel in channels - if (channel.permissions_for(ctx.author).send_messages - or channel.permissions_for(ctx.author).send_messages_in_threads) - ] - embed = discord.Embed( - title=t(_p('cmd:tasklist_open|error:tasklist_channel|title', "Sorry, I can't do that here")), - colour=discord.Colour.brand_red() - ) - if viewable: - embed.description = t(_p( - 'cmd:tasklist_open|error:tasklist_channel|desc', - "Please use direct messages or one of the following channels " - "or categories for managing your tasks:\n{channels}" - )).format(channels='\n'.join(channel.mention for channel in viewable)) - else: - embed.description = t(_p( - 'cmd:tasklist_open|error:tasklist_channel|desc', - "There are no channels available here where you may open your tasklist!" - )) - await ctx.reply(embed=embed, ephemeral=True) - - @tasklist_group.command( - name=_p('cmd:tasklist_new', "new"), - description=_p( - 'cmd:tasklist_new|desc', + 'cmd:tasks_new|desc', "Add a new task to your tasklist." ) ) @appcmds.rename( - content=_p('cmd:tasklist_new|param:content', "task"), - parent=_p('cmd:tasklist_new|param:parent', 'parent') + content=_p('cmd:tasks_new|param:content', "task"), + parent=_p('cmd:tasks_new|param:parent', 'parent') ) @appcmds.describe( - content=_p('cmd:tasklist_new|param:content|desc', "Content of your new task."), - parent=_p('cmd:tasklist_new|param:parent', 'Parent of this task.') + content=_p('cmd:tasks_new|param:content|desc', "Content of your new task."), + parent=_p('cmd:tasks_new|param:parent', 'Parent of this task.') ) async def tasklist_new_cmd(self, ctx: LionContext, content: appcmds.Range[str, 1, MAX_LENGTH], @@ -282,52 +379,49 @@ class TasklistCog(LionCog): await ctx.interaction.edit_original_response( embed=error_embed( t(_p( - 'cmd:tasklist_new|error:parse_parent', - "Could not find task number `{input}` in your tasklist." + 'cmd:tasks_new|error:parse_parent', + "Could not find parent task number `{input}` in your tasklist." )).format(input=parent) ), ) return # Create task - await tasklist.create_task(content, parentid=pid) + task = await tasklist.create_task(content, parentid=pid) - if await self.is_tasklist_channel(ctx.interaction.channel): - # summon tasklist - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - await ctx.interaction.delete_original_response() - else: - # ack creation - embed = discord.Embed( - colour=discord.Colour.brand_green(), - description=t(_p( - 'cmd:tasklist_new|resp:success', - "{tick} Task created successfully." - )).format(tick=self.bot.config.emojis.tick) - ) - await ctx.interaction.edit_original_response(embed=embed) + # Ack creation + label = tasklist.labelid(task.taskid) + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_p( + 'cmd:tasks_new|resp:success', + "{tick} Created task `{label}`." + )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(label)) + ) + await ctx.interaction.edit_original_response( + embed=embed, + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + ) + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) - @tasklist_new_cmd.autocomplete('parent') - async def tasklist_new_cmd_parent_acmpl(self, interaction: discord.Interaction, partial: str): - return await self.task_acmpl(interaction, partial) + tasklist_new_cmd.autocomplete('parent')(task_acmpl) @tasklist_group.command( - name=_p('cmd:tasklist_edit', "edit"), + name=_p('cmd:tasks_edit', "edit"), description=_p( - 'cmd:tasklist_edit|desc', - "Edit tasks in your tasklist." + 'cmd:tasks_edit|desc', + "Edit a task in your tasklist." ) ) @appcmds.rename( - taskstr=_p('cmd:tasklist_edit|param:taskstr', "task"), - new_content=_p('cmd:tasklist_edit|param:new_content', "new_task"), - new_parent=_p('cmd:tasklist_edit|param:new_parent', "new_parent"), + taskstr=_p('cmd:tasks_edit|param:taskstr', "task"), + new_content=_p('cmd:tasks_edit|param:new_content', "new_task"), + new_parent=_p('cmd:tasks_edit|param:new_parent', "new_parent"), ) @appcmds.describe( - taskstr=_p('cmd:tasklist_edit|param:taskstr|desc', "Which task do you want to update?"), - new_content=_p('cmd:tasklist_edit|param:new_content|desc', "What do you want to change the task to?"), - new_parent=_p('cmd:tasklist_edit|param:new_parent|desc', "Which task do you want to be the new parent?"), + taskstr=_p('cmd:tasks_edit|param:taskstr|desc', "Which task do you want to update?"), + new_content=_p('cmd:tasks_edit|param:new_content|desc', "What do you want to change the task to?"), + new_parent=_p('cmd:tasks_edit|param:new_parent|desc', "Which task do you want to be the new parent?"), ) async def tasklist_edit_cmd(self, ctx: LionContext, taskstr: str, @@ -345,8 +439,8 @@ class TasklistCog(LionCog): await ctx.interaction.response.send_message( embed=error_embed( t(_p( - 'cmd:tasklist_edit|error:parse_taskstr', - "Could not find task number `{input}` in your tasklist." + 'cmd:tasks_edit|error:parse_taskstr', + "Could not find target task number `{input}` in your tasklist." )).format(input=taskstr) ), ephemeral=True, @@ -361,8 +455,8 @@ class TasklistCog(LionCog): await interaction.response.send_message( embed=error_embed( t(_p( - 'cmd:tasklist_edit|error:parse_parent', - "Could not find task number `{input}` in your tasklist." + 'cmd:tasks_edit|error:parse_parent', + "Could not find new parent task number `{input}` in your tasklist." )).format(input=new_parent) ), ephemeral=True @@ -377,25 +471,22 @@ class TasklistCog(LionCog): if args: await tasklist.update_tasks(tid, **args) - if await self.is_tasklist_channel(ctx.channel): - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - else: - embed = discord.Embed( - colour=discord.Color.brand_green(), - description=t(_p( - 'cmd:tasklist_edit|resp:success|desc', - "{tick} Task updated successfully." - )).format(tick=self.bot.config.emojis.tick), - ) - await interaction.response.send_message(embed=embed, ephemeral=True) + embed = discord.Embed( + colour=discord.Color.brand_green(), + description=t(_p( + 'cmd:tasks_edit|resp:success|desc', + "{tick} Task `{label}` updated." + )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(tasklist.labelid(tid))), + ) + await ctx.interaction.edit_original_response( + embed=embed, + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + ) + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) if new_content or new_parent: # Manual edit route await handle_update(ctx.interaction, new_content, new_parent) - if not ctx.interaction.response.is_done(): - await ctx.interaction.response.defer(thinking=True, ephemeral=True) - await ctx.interaction.delete_original_response() else: # Modal edit route task = tasklist.tasklist[tid] @@ -410,22 +501,15 @@ class TasklistCog(LionCog): @editor.submit_callback() async def update_task(interaction: discord.Interaction): await handle_update(interaction, editor.task.value, editor.parent.value) - if not interaction.response.is_done(): - await interaction.response.defer() await ctx.interaction.response.send_modal(editor) - @tasklist_edit_cmd.autocomplete('taskstr') - async def tasklist_edit_cmd_taskstr_acmpl(self, interaction: discord.Interaction, partial: str): - return await self.task_acmpl(interaction, partial) - - @tasklist_edit_cmd.autocomplete('new_parent') - async def tasklist_edit_cmd_new_parent_acmpl(self, interaction: discord.Interaction, partial: str): - return await self.task_acmpl(interaction, partial) + tasklist_edit_cmd.autocomplete('taskstr')(task_acmpl) + tasklist_edit_cmd.autocomplete('new_parent')(task_acmpl) @tasklist_group.command( - name=_p('cmd:tasklist_clear', "clear"), - description=_p('cmd:tasklist_clear|desc', "Clear your tasklist.") + name=_p('cmd:tasks_clear', "clear"), + description=_p('cmd:tasks_clear|desc', "Clear your tasklist.") ) async def tasklist_clear_cmd(self, ctx: LionContext): t = ctx.bot.translator.t @@ -434,47 +518,47 @@ class TasklistCog(LionCog): await tasklist.update_tasklist(deleted_at=utc_now()) await ctx.reply( t(_p( - 'cmd:tasklist_clear|resp:success', + 'cmd:tasks_clear|resp:success', "Your tasklist has been cleared." )), + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot), ephemeral=True ) - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) @tasklist_group.command( - name=_p('cmd:tasklist_remove', "remove"), + name=_p('cmd:tasks_remove', "remove"), description=_p( - 'cmd:tasklist_remove|desc', + 'cmd:tasks_remove|desc', "Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)." ) ) @appcmds.rename( - taskidstr=_p('cmd:tasklist_remove|param:taskidstr', "tasks"), - created_before=_p('cmd:tasklist_remove|param:created_before', "created_before"), - updated_before=_p('cmd:tasklist_remove|param:updated_before', "updated_before"), - completed=_p('cmd:tasklist_remove|param:completed', "completed"), - cascade=_p('cmd:tasklist_remove|param:cascade', "cascade") + taskidstr=_p('cmd:tasks_remove|param:taskidstr', "tasks"), + created_before=_p('cmd:tasks_remove|param:created_before', "created_before"), + updated_before=_p('cmd:tasks_remove|param:updated_before', "updated_before"), + completed=_p('cmd:tasks_remove|param:completed', "completed"), + cascade=_p('cmd:tasks_remove|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( - 'cmd:tasklist_remove|param:taskidstr|desc', + 'cmd:tasks_remove|param:taskidstr|desc', "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." ), created_before=_p( - 'cmd:tasklist_remove|param:created_before|desc', + 'cmd:tasks_remove|param:created_before|desc', "Only delete tasks created before the selected time." ), updated_before=_p( - 'cmd:tasklist_remove|param:updated_before|desc', + 'cmd:tasks_remove|param:updated_before|desc', "Only deleted tasks update (i.e. completed or edited) before the selected time." ), completed=_p( - 'cmd:tasklist_remove|param:completed', + 'cmd:tasks_remove|param:completed', "Only delete tasks which are (not) complete." ), cascade=_p( - 'cmd:tasklist_remove|param:cascade', + 'cmd:tasks_remove|param:cascade', "Whether to recursively remove subtasks of removed tasks." ) ) @@ -506,7 +590,7 @@ class TasklistCog(LionCog): # Explicitly error if none of the ranges matched await ctx.interaction.edit_original_response( embed=error_embed( - 'cmd:tasklist_remove_cmd|error:no_matching', + 'cmd:tasks_remove_cmd|error:no_matching', "No tasks on your tasklist match `{input}`" ).format(input=taskidstr) ) @@ -515,8 +599,7 @@ class TasklistCog(LionCog): conditions.append(self.data.Task.taskid == taskids) if created_before is not None or updated_before is not None: - # TODO: Extract timezone from user settings - timezone = None + timezone = ctx.alion.timezone if created_before is not None: conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone)) if updated_before is not None: @@ -531,46 +614,52 @@ class TasklistCog(LionCog): if not tasks: await ctx.interaction.edit_original_response( embed=error_embed( - 'cmd:tasklist_remove_cmd|error:no_matching', + 'cmd:tasks_remove_cmd|error:no_matching', "No tasks on your tasklist matching all the given conditions!" ).format(input=taskidstr) ) return taskids = [task.taskid for task in tasks] + label = tasklist.format_label(tasklist.labelid(taskids[0])) await tasklist.update_tasks(*taskids, cascade=cascade, deleted_at=utc_now()) - # Ack changes or summon tasklist - if await self.is_tasklist_channel(ctx.channel): - # Summon tasklist - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - await ctx.interaction.delete_original_response() - else: - # Ack deletion - embed = discord.Embed( - colour=discord.Colour.brand_green(), - description=t(_p( - 'cmd:tasklist_remove|resp:success', - "{tick} tasks deleted." - )).format(tick=self.bot.config.emojis.tick) + # Ack changes and summon tasklist + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_np( + 'cmd:tasks_remove|resp:success', + "{tick} Deleted task `{label}`", + "{tick} Deleted `{count}` tasks from your tasklist.", + len(taskids) + )).format( + tick=self.bot.config.emojis.tick, + label=label, + count=len(taskids) ) - await ctx.interaction.edit_original_response(embed=embed) + ) + await ctx.interaction.edit_original_response( + embed=embed, + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + ) + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + + tasklist_remove_cmd.autocomplete('taskidstr')(tasks_acmpl) @tasklist_group.command( - name=_p('cmd:tasklist_tick', "tick"), - description=_p('cmd:tasklist_tick|desc', "Mark the given tasks as completed.") + name=_p('cmd:tasks_tick', "tick"), + description=_p('cmd:tasks_tick|desc', "Mark the given tasks as completed.") ) @appcmds.rename( - taskidstr=_p('cmd:tasklist_tick|param:taskidstr', "tasks"), - cascade=_p('cmd:tasklist_tick|param:cascade', "cascade") + taskidstr=_p('cmd:tasks_tick|param:taskidstr', "tasks"), + cascade=_p('cmd:tasks_tick|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( - 'cmd:tasklist_tick|param:taskidstr|desc', + 'cmd:tasks_tick|param:taskidstr|desc', "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." ), cascade=_p( - 'cmd:tasklist_tick|param:cascade|desc', + 'cmd:tasks_tick|param:cascade|desc', "Whether to also mark all subtasks as complete." ) ) @@ -596,7 +685,7 @@ class TasklistCog(LionCog): # Explicitly error if none of the ranges matched await ctx.interaction.edit_original_response( embed=error_embed( - 'cmd:tasklist_remove_cmd|error:no_matching', + 'cmd:tasks_remove_cmd|error:no_matching', "No tasks on your tasklist match `{input}`" ).format(input=taskidstr) ) @@ -610,38 +699,43 @@ class TasklistCog(LionCog): if ctx.guild: self.bot.dispatch('tasks_completed', ctx.author, *taskids) - # Ack changes or summon tasklist - if await self.is_tasklist_channel(ctx.channel): - # Summon tasklist - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - await ctx.interaction.delete_original_response() - else: - # Ack edit - embed = discord.Embed( - colour=discord.Colour.brand_green(), - description=t(_p( - 'cmd:tasklist_tick|resp:success', - "{tick} tasks marked as complete." - )).format(tick=self.bot.config.emojis.tick) + # Ack changes and summon tasklist + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_np( + 'cmd:tasks_tick|resp:success', + "{tick} Marked `{label}` as complete.", + "{tick} Marked `{count}` tasks as complete.", + len(taskids) + )).format( + tick=self.bot.config.emojis.tick, + count=len(taskids), + label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-' ) - await ctx.interaction.edit_original_response(embed=embed) + ) + await ctx.interaction.edit_original_response( + embed=embed, + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + ) + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + + tasklist_tick_cmd.autocomplete('taskidstr')(tasks_acmpl) @tasklist_group.command( - name=_p('cmd:tasklist_untick', "untick"), - description=_p('cmd:tasklist_untick|desc', "Mark the given tasks as incomplete.") + name=_p('cmd:tasks_untick', "untick"), + description=_p('cmd:tasks_untick|desc', "Mark the given tasks as incomplete.") ) @appcmds.rename( - taskidstr=_p('cmd:tasklist_untick|param:taskidstr', "taskids"), - cascade=_p('cmd:tasklist_untick|param:cascade', "cascade") + taskidstr=_p('cmd:tasks_untick|param:taskidstr', "taskids"), + cascade=_p('cmd:tasks_untick|param:cascade', "cascade") ) @appcmds.describe( taskidstr=_p( - 'cmd:tasklist_untick|param:taskidstr|desc', + 'cmd:tasks_untick|param:taskidstr|desc', "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)." ), cascade=_p( - 'cmd:tasklist_untick|param:cascade|desc', + 'cmd:tasks_untick|param:cascade|desc', "Whether to also mark all subtasks as incomplete." ) ) @@ -666,7 +760,7 @@ class TasklistCog(LionCog): # Explicitly error if none of the ranges matched await ctx.interaction.edit_original_response( embed=error_embed( - 'cmd:tasklist_remove_cmd|error:no_matching', + 'cmd:tasks_remove_cmd|error:no_matching', "No tasks on your tasklist match `{input}`" ).format(input=taskidstr) ) @@ -678,22 +772,27 @@ class TasklistCog(LionCog): if taskids: await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=None) - # Ack changes or summon tasklist - if await self.is_tasklist_channel(ctx.channel): - # Summon tasklist - tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) - await tasklistui.summon() - await ctx.interaction.delete_original_response() - else: - # Ack edit - embed = discord.Embed( - colour=discord.Colour.brand_green(), - description=t(_p( - 'cmd:tasklist_untick|resp:success', - "{tick} tasks marked as incomplete." - )).format(tick=self.bot.config.emojis.tick) + # Ack changes and summon tasklist + embed = discord.Embed( + colour=discord.Colour.brand_green(), + description=t(_np( + 'cmd:tasks_untick|resp:success', + "{tick} Marked `{label}` as incomplete.", + "{tick} Marked `{count}` tasks as incomplete.", + len(taskids) + )).format( + tick=self.bot.config.emojis.tick, + count=len(taskids), + label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-' ) - await ctx.interaction.edit_original_response(embed=embed) + ) + await ctx.interaction.edit_original_response( + embed=embed, + view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) + ) + self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) + + tasklist_untick_cmd.autocomplete('taskidstr')(tasks_acmpl) # Setting Commands @LionCog.placeholder_group diff --git a/src/modules/tasklist/settings.py b/src/modules/tasklist/settings.py index a4933cf9..449c4f9f 100644 --- a/src/modules/tasklist/settings.py +++ b/src/modules/tasklist/settings.py @@ -113,12 +113,14 @@ class TasklistSettings(SettingGroup): _display_name = _p('guildset:tasklist_channels', "tasklist_channels") _desc = _p( 'guildset:tasklist_channels|desc', - "Channels in which to allow the tasklist." + "Channels in which to publicly display member tasklists." ) _long_desc = _p( 'guildset:tasklist_channels|long_desc', - "If set, members will only be able to open their tasklist in these channels.\n" - "If a category is selected, this will allow all channels under that category." + "A member's tasklist (from {cmds[tasklist]}) is usually only visible to the member themselves. " + "If set, tasklists opened in `tasklist_channels` will be visible to all members, " + "and the interface will have a much longer expiry period. " + "If a category is provided, this will apply to all channels under the category." ) _accepts = _p( 'guildset:tasklist_channels|accepts', @@ -139,12 +141,12 @@ class TasklistSettings(SettingGroup): if self.data: resp = t(_p( 'guildset:tasklist_channels|set_response|set', - "Members may now open their tasklist in the following channels: {channels}" + "Tasklists will now be publicly displayed in the following channels: {channels}" )).format(channels=self.formatted) else: resp = t(_p( 'guildset:tasklist_channels|set_response|unset', - "Members may now open their tasklist in any channel." + "Member tasklists will never be publicly displayed." )) return resp diff --git a/src/modules/tasklist/tasklist.py b/src/modules/tasklist/tasklist.py index 1c1db406..2e48bc4e 100644 --- a/src/modules/tasklist/tasklist.py +++ b/src/modules/tasklist/tasklist.py @@ -6,9 +6,13 @@ from meta import LionBot from meta.errors import UserInputError from utils.lib import utc_now +from . import babel from .data import TasklistData +_p = babel._p + + class Tasklist: """ Class representing a single user's tasklist. @@ -207,7 +211,7 @@ class Tasklist: """ return '.'.join(map(str, label)) + '.' * (len(label) == 1) - def parse_labels(self, labelstr: str) -> Optional[list[str]]: + def parse_labels(self, labelstr: str) -> Optional[list[int]]: """ Parse a comma separated list of labels and label ranges into a list of labels. @@ -239,7 +243,13 @@ class Tasklist: if len(end_label) > 1 and head != end_label[:-1]: # Error: Parents don't match in range ... - raise UserInputError("Parents don't match in range `{range}`") + t = self.bot.translator.t + raise UserInputError( + t(_p( + 'tasklist|parse:multi-range|error:parents_match', + "Parents don't match in range `{range}`" + )).format(range=split) + ) for tail in range(max(start_tail, 1), end_tail + 1): label = (*head, tail) @@ -255,5 +265,11 @@ class Tasklist: taskids.add(labelmap[start_label]) else: # Error - raise UserInputError("Could not parse `{range}` as a task number or range.") + t = self.bot.translator.t + raise UserInputError( + t(_p( + 'tasklist|parse:multi-range|error:parse', + "Could not parse `{range}` as a task number or range." + )).format(range=split) + ) return list(taskids) diff --git a/src/modules/tasklist/ui.py b/src/modules/tasklist/ui.py index cecf0a05..a9a91b06 100644 --- a/src/modules/tasklist/ui.py +++ b/src/modules/tasklist/ui.py @@ -1,5 +1,9 @@ from typing import Optional +from collections import defaultdict +from enum import Enum +import asyncio import re +from io import StringIO import discord from discord.ui.select import select, Select, SelectOption @@ -24,6 +28,22 @@ checked_emoji = conf.emojis.task_checked unchecked_emoji = conf.emojis.task_unchecked +class TasklistCaller(LeoUI): + def __init__(self, bot, **kwargs): + kwargs.setdefault('timeout', None) + super().__init__(**kwargs) + self.bot = bot + self.tasklist_callback.label = bot.translator.t(_p( + 'ui:tasklist_caller|button:tasklist|label', + "Open Tasklist" + )) + + @button(label='TASKLIST_PLACEHOLDER', custom_id='open_tasklist', style=ButtonStyle.blurple) + async def tasklist_callback(self, press: discord.Interaction, pressed: Button): + cog = self.bot.get_cog('TasklistCog') + await cog.call_tasklist(press) + + class SingleEditor(FastModal): task: TextInput = TextInput( label='', @@ -37,7 +57,7 @@ class SingleEditor(FastModal): parent: TextInput = TextInput( label='', - max_length=10, + max_length=120, required=False ) @@ -96,6 +116,7 @@ class BulkEditor(LeoModal): self.tasklist = tasklist self.bot = tasklist.bot + self.labelled = tasklist.labelled self.userid = tasklist.userid self.lines = self.format_tasklist() @@ -118,7 +139,7 @@ class BulkEditor(LeoModal): """ Format the tasklist into lines of editable text. """ - labelled = self.tasklist.labelled + labelled = self.labelled lines = {} total_len = 0 for label, task in labelled.items(): @@ -236,22 +257,64 @@ class BulkEditor(LeoModal): break +class UIMode(Enum): + TOGGLE = ( + _p( + 'ui:tasklist|menu:main|mode:toggle|placeholder', + "Select to Toggle" + ), + _p( + 'ui:tasklist|menu:sub|mode:toggle|placeholder', + "Task '{label}' subtasks:" + ), + ) + EDIT = ( + _p( + 'ui:tasklist|menu:main|mode:edit|placeholder', + "Select to Edit" + ), + _p( + 'ui:tasklist|menu:sub|mode:edit|placeholder', + "Task '{label}' subtasks:" + ), + ) + DELETE = ( + _p( + 'ui:tasklist|menu:main|mode:delete|placeholder', + "Select to Delete" + ), + _p( + 'ui:tasklist|menu:sub|mode:delete|placeholder', + "Task '{label}' subtasks:" + ), + ) + + @property + def main_placeholder(self): + return self.value[0] + + @property + def sub_placeholder(self): + return self.value[1] + + class TasklistUI(BasePager): """ Paged UI panel for managing the tasklist. """ # Cache of live tasklist widgets - # (channelid, userid) -> Tasklist - _live_ = {} + # userid -> channelid -> TasklistUI + _live_ = defaultdict(dict) def __init__(self, tasklist: Tasklist, channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs): - kwargs.setdefault('timeout', 3600) + kwargs.setdefault('timeout', 600) super().__init__(**kwargs) - self.tasklist = tasklist self.bot = tasklist.bot + self.tasklist = tasklist + self.labelled = tasklist.labelled self.userid = tasklist.userid self.channel = channel self.guild = guild @@ -263,21 +326,508 @@ class TasklistUI(BasePager): self._channelid = channel.id self.current_page = None - self._deleting = False - + self.mode: UIMode = UIMode.TOGGLE self._message: Optional[discord.Message] = None + self._last_parentid: Optional[int] = None + self._subtree_root: Optional[int] = None - self.button_labels() self.set_active() + # ----- UI API ----- @classmethod - async def fetch(cls, tasklist, channel, *args, **kwargs): - key = (channel.id, tasklist.userid) - if key not in cls._live_: + def fetch(cls, tasklist, channel, *args, **kwargs): + userid = tasklist.userid + channelid = channel.id + if channelid not in cls._live_[userid]: self = cls(tasklist, channel, *args, **kwargs) - cls._live_[key] = self - return cls._live_[key] + cls._live_[userid][channelid] = self + return cls._live_[userid][channelid] + async def run(self, interaction: discord.Interaction): + await self.refresh() + await self.redraw(interaction) + + async def summon(self, force=False): + """ + Delete, refresh, and redisplay the tasklist widget as a non-ephemeral message in the current channel. + + May raise `discord.HTTPException` (from `redraw`) if something goes wrong with the send. + """ + await self.refresh() + + resend = force or not await self._check_recent() + if resend and self._message: + # Delete our current message if possible + try: + await self._message.delete() + except discord.HTTPException: + # If we cannot delete, it has probably already been deleted + # Or we don't have permission somehow + pass + self._message = None + + # Redraw + try: + await self.redraw() + except discord.HTTPException: + if self._message: + self._message = None + await self.redraw() + + async def page_cmd(self, interaction: discord.Interaction, value: str): + return await Pager.page_cmd(self, interaction, value) + + async def page_acmpl(self, interaction: discord.Interaction, partial: str): + return await Pager.page_acmpl(self, interaction, partial) + + # ----- Utilities / Workers ------ + async def _check_recent(self) -> bool: + """ + Check whether the tasklist message is a "recent" message in the channel. + """ + if self._message is not None: + height = 0 + async for message in self.channel.history(limit=5): + if message.id == self._message.id: + return True + if message.id < self._message.id: + return False + if message.attachments or message.embeds or height > 20: + return False + height += message.content.count('\n') + return False + return False + + def _format_page(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: + """ + Format a single block of page data into the task codeblock. + """ + lines = [] + numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) + for label, task in page: + label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) + number = f"**`{label_string}`**" + if len(label) > 1: + depth = sum(len(str(c)) + 1 for c in label[:-1]) * ' ' + depth = f"`{depth}`" + else: + depth = '' + task_string = "{depth}{cross}{number} {content}{cross}".format( + depth=depth, + number=number, + emoji=unchecked_emoji if task.completed_at is None else checked_emoji, + content=task.content, + cross='~~' if task.completed_at is not None else '' + ) + lines.append(task_string) + return '\n'.join(lines) + + def _format_page_text(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: + """ + Format a single block of page data into the task codeblock. + """ + lines = [] + numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) + for label, task in page: + box = '[ ]' if task.completed_at is None else f"[{checkmark}]" + task_string = "{prepad} {depth} {content}".format( + prepad=' ' * numpad, + depth=(len(label) - 1) * ' ', + content=task.content + ) + label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) + taskline = box + ' ' + label_string + task_string[len(label_string):] + lines.append(taskline) + return "```md\n{}```".format('\n'.join(lines)) + + def _format_options(self, task_block) -> list[SelectOption]: + options = [] + for lbl, task in task_block: + value = str(task.taskid) + lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1) + name = f"{lblstr} {task.content[:100 - len(lblstr) - 1]}" + emoji = unchecked_emoji if task.completed_at is None else checked_emoji + options.append(SelectOption(label=name, value=value, emoji=emoji)) + return options + + def _format_parent(self, parentid) -> str: + parentstr = '' + if parentid is not None: + task = self.tasklist.tasklist.get(parentid, None) + if task: + parent_label = self.tasklist.format_label(self.tasklist.labelid(parentid)).strip('.') + parentstr = f"{parent_label}: {task.content}" + return parentstr + + def _parse_parent(self, provided: str) -> Optional[int]: + """ + Parse a provided parent field. + + May raise UserInputError if parsing fails. + """ + t = self.bot.translator.t + provided = provided.strip() + + if provided.split(':', maxsplit=1)[0].replace('.', '').strip().isdigit(): + # Assume task label + label, _, _ = provided.partition(':') + label = label.strip() + pid = self.tasklist.parse_label(label) + if pid is None: + raise UserInputError( + t(_p( + 'ui:tasklist_single_editor|field:parent|error:parse_id', + "Could not find the given parent task number `{input}` in your tasklist." + )).format(input=label) + ) + elif provided: + # Search for matching tasks + matching = [ + task.taskid + for task in self.tasklist.tasklist.values() + if provided.lower() in task.content.lower() + ] + if len(matching) > 1: + raise UserInputError( + t(_p( + 'ui:tasklist_single_editor|field:parent|error:multiple_matching', + "Multiple tasks matching given parent task `{input}`. Please use a task number instead!" + )).format(input=provided) + ) + elif not matching: + raise UserInputError( + t(_p( + 'ui:tasklist_single_editor|field:parent|error:no_matching', + "No tasks matching given parent task `{input}`." + )).format(input=provided) + ) + pid = matching[0] + else: + pid = None + + return pid + + # ----- Components ----- + async def _toggle_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool): + await interaction.response.defer() + taskids = list(map(int, selected.values)) + tasks = await self.tasklist.fetch_tasks(*taskids) + to_complete = [task for task in tasks if task.completed_at is None] + to_uncomplete = [task for task in tasks if task.completed_at is not None] + if to_complete: + await self.tasklist.update_tasks( + *(t.taskid for t in to_complete), + cascade=True, + completed_at=utc_now() + ) + if self.guild: + if (member := self.guild.get_member(self.userid)): + self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete)) + if to_uncomplete: + await self.tasklist.update_tasks( + *(t.taskid for t in to_uncomplete), + completed_at=None + ) + + # If the selected tasks share a parent, and we are not in the subtree menu, change the subtree root + if taskids and not subtree: + labelled = self.labelled + mapper = {t.taskid: label for label, t in labelled.items()} + shared_root = None + for task in tasks: + pid = task.parentid + plabel = mapper[pid] if pid else () + if shared_root: + shared_root = tuple(i for i, j in zip(shared_root, plabel) if i == j) + else: + shared_root = plabel + if not shared_root: + break + if shared_root: + self._subtree_root = labelled[shared_root].taskid + + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + async def _delete_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool): + await interaction.response.defer() + taskids = list(map(int, selected.values)) + if taskids: + await self.tasklist.update_tasks( + *taskids, + cascade=True, + deleted_at=utc_now() + ) + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + async def _edit_menu(self, interaction: discord.Interaction, selected: Select, subtree: bool): + if not selected.values: + await interaction.response.defer() + else: + t = self.bot.translator.t + + taskid = int(selected.values[0]) + task = self.tasklist.tasklist[taskid] + + editor = SingleEditor( + title=t(_p('ui:tasklist|menu:edit|modal:title', "Edit task")) + ) + editor.parent.default = self._format_parent(task.parentid) + editor.task.default = task.content + + @editor.submit_callback() + async def create_task(interaction): + new_task = editor.task.value + new_parentid = self._parse_parent(editor.parent.value) + await interaction.response.defer() + if task.content != new_task or task.parentid != new_parentid: + await task.update(content=new_task, parentid=new_parentid) + self._last_parentid = new_parentid + if not subtree: + self._subtree_root = new_parentid + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + await interaction.response.send_modal(editor) + + @select(placeholder="MAIN_MENU_PLACEHOLDER") + async def main_menu(self, interaction: discord.Interaction, selected: Select): + if self.mode is UIMode.TOGGLE: + await self._toggle_menu(interaction, selected, False) + elif self.mode is UIMode.DELETE: + await self._delete_menu(interaction, selected, False) + elif self.mode is UIMode.EDIT: + await self._edit_menu(interaction, selected, False) + + async def main_menu_refresh(self): + t = self.bot.translator.t + menu = self.main_menu + menu.placeholder = t(self.mode.main_placeholder) + + block = self._pages[self.page_num % len(self._pages)] + options = self._format_options(block) + + menu.options = options + menu.min_values = 0 + menu.max_values = len(options) if self.mode is not UIMode.EDIT else 1 + + @select(placeholder="SUB_MENU_PLACEHOLDER") + async def sub_menu(self, interaction: discord.Interaction, selected: Select): + if self.mode is UIMode.TOGGLE: + await self._toggle_menu(interaction, selected, True) + elif self.mode is UIMode.DELETE: + await self._delete_menu(interaction, selected, True) + elif self.mode is UIMode.EDIT: + await self._edit_menu(interaction, selected, True) + + async def sub_menu_refresh(self): + t = self.bot.translator.t + menu = self.sub_menu + + options = [] + if self._subtree_root: + labelled = self.labelled + mapper = {t.taskid: label for label, t in labelled.items()} + rootid = self._subtree_root + rootlabel = mapper.get(rootid, ()) + if rootlabel: + menu.placeholder = t(self.mode.sub_placeholder).format( + label=self.tasklist.format_label(rootlabel).strip('.'), + ) + children = { + label: taskid + for label, taskid in labelled.items() + if all(i == j for i, j in zip(label, rootlabel)) + } + this_page = self._pages[self.page_num % len(self._pages)] + if len(children) <= 25: + # Show all the children even if they don't display on the page + block = list(children.items()) + else: + # Only show the children which display + page_children = [ + (label, tid) for label, tid in this_page if label in children and tid != rootid + ][:24] + if page_children: + block = [(rootlabel, rootid), *page_children] + else: + block = [] + # Special case if the subtree is exactly the same as the page + if not (len(block) == len(this_page) and all(i[0] == j[0] for i, j in zip(block, this_page))): + options = self._format_options(block) + + menu.options = options + menu.min_values = 0 + menu.max_values = len(options) if self.mode is not UIMode.EDIT else 1 + + @button(label='NEW_BUTTON_PLACEHOLDER', style=ButtonStyle.green, emoji=conf.emojis.task_new) + async def new_button(self, press: discord.Interaction, pressed: Button): + t = self.bot.translator.t + editor = SingleEditor( + title=t(_p('ui:tasklist_single_editor|title', "Add task")) + ) + editor.parent.default = self._format_parent(self._last_parentid) + + @editor.submit_callback() + async def create_task(interaction): + new_task = editor.task.value + parent = editor.parent.value + pid = self._parse_parent(parent) + self._last_parentid = pid + self._subtree_root = pid + await interaction.response.defer() + await self.tasklist.create_task(new_task, parentid=pid) + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + await press.response.send_modal(editor) + + async def new_button_refresh(self): + self.new_button.label = "" + + @button(label="EDIT_MODE_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_mode_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.mode = UIMode.EDIT + await self.redraw() + + async def edit_mode_button_refresh(self): + t = self.bot.translator.t + button = self.edit_mode_button + + button.style = ButtonStyle.blurple if (self.mode is UIMode.EDIT) else ButtonStyle.grey + button.label = t(_p( + 'ui:tasklist|button:edit_mode|label', + "Edit" + )) + + @button(label="DELETE_MODE_PLACEHOLDER", style=ButtonStyle.blurple) + async def delete_mode_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.mode = UIMode.DELETE + await self.redraw() + + async def delete_mode_button_refresh(self): + t = self.bot.translator.t + button = self.delete_mode_button + + button.style = ButtonStyle.blurple if (self.mode is UIMode.DELETE) else ButtonStyle.grey + button.label = t(_p( + 'ui:tasklist|button:delete_mode|label', + "Delete" + )) + + @button(label="TOGGLE_MODE_PLACEHOLDER", style=ButtonStyle.blurple) + async def toggle_mode_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + self.mode = UIMode.TOGGLE + await self.redraw() + + async def toggle_mode_button_refresh(self): + t = self.bot.translator.t + button = self.toggle_mode_button + + button.style = ButtonStyle.blurple if (self.mode is UIMode.TOGGLE) else ButtonStyle.grey + button.label = t(_p( + 'ui:tasklist|button:toggle_mode|label', + "Toggle" + )) + + @button(label="EDIT_BULK_PLACEHOLDER", style=ButtonStyle.blurple) + async def edit_bulk_button(self, press: discord.Interaction, pressed: Button): + editor = BulkEditor(self.tasklist) + + @editor.add_callback + async def editor_callback(interaction: discord.Interaction): + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + await press.response.send_modal(editor) + + async def edit_bulk_button_refresh(self): + t = self.bot.translator.t + button = self.edit_bulk_button + button.disabled = (len(self.labelled) == 0) + button.label = t(_p( + 'ui:tasklist|button:edit_bulk|label', + "Bulk Edit" + )) + + @button(label='CLEAR_PLACEHOLDER', style=ButtonStyle.red) + async def clear_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + await self.tasklist.update_tasklist( + deleted_at=utc_now(), + ) + self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) + + async def clear_button_refresh(self): + self.clear_button.label = self.bot.translator.t(_p( + 'ui:tasklist|button:clear|label', "Clear Tasklist" + )) + self.clear_button.disabled = (len(self.labelled) == 0) + + @button(label="SAVE_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.task_save) + async def save_button(self, press: discord.Interaction, pressed: Button): + """ + Send the tasklist to the user as a markdown file. + """ + t = self.bot.translator.t + await press.response.defer(thinking=True, ephemeral=True) + + # Build the tasklist file + # Lazy way of getting the tasklist + contents = BulkEditor(self.tasklist).tasklist_editor.default + with StringIO(contents) as fp: + fp.seek(0) + file = discord.File(fp, filename='tasklist.md') + contents = t(_p( + 'ui:tasklist|button:save|dm:contents', + "Your tasklist as of {now} is attached. Click here to jump back: {jump}" + )).format( + now=discord.utils.format_dt(utc_now()), + jump=press.message.jump_url + ) + try: + await press.user.send(contents, file=file, silent=True) + except discord.HTTPClient: + fp.seek(0) + file = discord.File(fp, filename='tasklist.md') + await press.followup.send( + t(_p( + 'ui:tasklist|button:save|error:dms', + "Could not DM you! Do you have me blocked? Tasklist attached below." + )), + file=file + ) + else: + fp.seek(0) + file = discord.File(fp, filename='tasklist.md') + await press.followup.send(file=file) + + async def save_button_refresh(self): + self.save_button.disabled = (len(self.labelled) == 0) + self.save_button.label = '' + + @button(label="REFRESH_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.refresh, custom_id='open_tasklist') + async def refresh_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + await self.refresh() + await self.redraw() + + async def refresh_button_refresh(self): + self.refresh_button.label = '' + + @button(label="QUIT_PLACEHOLDER", style=ButtonStyle.grey, emoji=conf.emojis.cancel) + async def quit_button(self, press: discord.Interaction, pressed: Button): + await press.response.defer() + if self._message is not None: + try: + await self._message.delete() + except discord.HTTPException: + pass + await self.close() + + async def quit_button_refresh(self): + self.quit_button.label = '' + + # ----- UI Flow ----- def access_check(self, userid): return userid == self.userid @@ -298,7 +848,7 @@ class TasklistUI(BasePager): async def cleanup(self): self.set_inactive() - self._live_.pop((self.channel.id, self.userid), None) + self._live_[self.userid].pop(self.channel.id, None) if self._message is not None: try: @@ -313,48 +863,6 @@ class TasklistUI(BasePager): except discord.HTTPException: pass - async def summon(self): - """ - Refresh and re-display the tasklist widget as required. - """ - await self.refresh() - - resend = not await self._check_recent() - if resend and self._message: - # Delete our current message if possible - try: - await self._message.delete() - except discord.HTTPException: - # If we cannot delete, it has probably already been deleted - # Or we don't have permission somehow - pass - self._message = None - - # Redraw - try: - await self.redraw() - except discord.HTTPException: - if self._message: - self._message = None - await self.redraw() - - async def _check_recent(self) -> bool: - """ - Check whether the tasklist message is a "recent" message in the channel. - """ - if self._message is not None: - height = 0 - async for message in self.channel.history(limit=5): - if message.id == self._message.id: - return True - if message.id < self._message.id: - return False - if message.attachments or message.embeds or height > 20: - return False - height += message.content.count('\n') - return False - return False - async def get_page(self, page_id) -> MessageArgs: t = self.bot.translator.t @@ -400,56 +908,8 @@ class TasklistUI(BasePager): page_args = MessageArgs(embed=embed) return page_args - async def page_cmd(self, interaction: discord.Interaction, value: str): - return await Pager.page_cmd(self, interaction, value) - - async def page_acmpl(self, interaction: discord.Interaction, partial: str): - return await Pager.page_acmpl(self, interaction, partial) - - def _format_page(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: - """ - Format a single block of page data into the task codeblock. - """ - lines = [] - numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) - for label, task in page: - label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) - number = f"**`{label_string}`**" - if len(label) > 1: - depth = sum(len(str(c)) + 1 for c in label[:-1]) * ' ' - depth = f"`{depth}`" - else: - depth = '' - task_string = "{depth}{cross}{number} {content}{cross}".format( - depth=depth, - number=number, - emoji=unchecked_emoji if task.completed_at is None else checked_emoji, - content=task.content, - cross='~~' if task.completed_at is not None else '' - ) - lines.append(task_string) - return '\n'.join(lines) - - def _format_page_text(self, page: list[tuple[tuple[int, ...], TasklistData.Task]]) -> str: - """ - Format a single block of page data into the task codeblock. - """ - lines = [] - numpad = max(sum(len(str(counter)) - 1 for counter in label) for label, _ in page) - for label, task in page: - box = '[ ]' if task.completed_at is None else f"[{checkmark}]" - task_string = "{prepad} {depth} {content}".format( - prepad=' ' * numpad, - depth=(len(label) - 1) * ' ', - content=task.content - ) - label_string = '.'.join(map(str, label)) + '.' * (len(label) == 1) - taskline = box + ' ' + label_string + task_string[len(label_string):] - lines.append(taskline) - return "```md\n{}```".format('\n'.join(lines)) - def refresh_pages(self): - labelled = list(self.tasklist.labelled.items()) + labelled = list(self.labelled.items()) count = len(labelled) pages = [] @@ -478,181 +938,76 @@ class TasklistUI(BasePager): self._pages = pages return pages - @select(placeholder="TOGGLE_PLACEHOLDER") - async def toggle_selector(self, interaction: discord.Interaction, selected: Select): - await interaction.response.defer() - taskids = list(map(int, selected.values)) - tasks = await self.tasklist.fetch_tasks(*taskids) - to_complete = [task for task in tasks if task.completed_at is None] - to_uncomplete = [task for task in tasks if task.completed_at is not None] - if to_complete: - await self.tasklist.update_tasks( - *(t.taskid for t in to_complete), - cascade=True, - completed_at=utc_now() - ) - if self.guild: - if (member := self.guild.get_member(self.userid)): - self.bot.dispatch('tasks_completed', member, *(t.taskid for t in to_complete)) - if to_uncomplete: - await self.tasklist.update_tasks( - *(t.taskid for t in to_uncomplete), - completed_at=None - ) - await self.refresh() - await self.redraw() - - async def toggle_selector_refresh(self): - t = self.bot.translator.t - self.toggle_selector.placeholder = t(_p( - 'ui:tasklist|menu:toggle_selector|placeholder', - "Select to Toggle" - )) - options = [] - block = self._pages[self.page_num % len(self._pages)] - colwidth = max(sum(len(str(c)) + 1 for c in lbl) for lbl, _ in block) - for lbl, task in block: - value = str(task.taskid) - lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1) - name = f"{lblstr:<{colwidth}} {task.content}" - emoji = unchecked_emoji if task.completed_at is None else checked_emoji - options.append(SelectOption(label=name, value=value, emoji=emoji)) - - self.toggle_selector.options = options - self.toggle_selector.min_values = 0 - self.toggle_selector.max_values = len(options) - - @button(label="NEW_PLACEHOLDER", style=ButtonStyle.green) - async def new_pressed(self, interaction: discord.Interaction, pressed: Button): - t = self.bot.translator.t - editor = SingleEditor( - title=t(_p('ui:tasklist_single_editor|title', "Add task")) - ) - - @editor.submit_callback() - async def create_task(interaction): - new_task = editor.task.value - parent = editor.parent.value - pid = self.tasklist.parse_label(parent) if parent else None - if parent and pid is None: - # Could not parse - raise UserInputError( - t(_p( - 'ui:tasklist_single_editor|error:parse_parent', - "Could not find the given parent task number `{input}` in your tasklist." - )).format(input=parent) - ) - await interaction.response.defer() - await self.tasklist.create_task(new_task, parentid=pid) - await self.refresh() - await self.redraw() - - await interaction.response.send_modal(editor) - - @button(label="EDITOR_PLACEHOLDER", style=ButtonStyle.blurple) - async def edit_pressed(self, interaction: discord.Interaction, pressed: Button): - editor = BulkEditor(self.tasklist) - - @editor.add_callback - async def editor_callback(interaction: discord.Interaction): - await self.refresh() - await self.redraw() - - await interaction.response.send_modal(editor) - - @button(label="DELETE_PLACEHOLDER", style=ButtonStyle.red) - async def del_pressed(self, interaction: discord.Interaction, pressed: Button): - self._deleting = 1 - self._deleting - await interaction.response.defer() - await self.refresh() - await self.redraw() - - @select(placeholder="DELETE_SELECT_PLACEHOLDER") - async def delete_selector(self, interaction: discord.Interaction, selected: Select): - await interaction.response.defer() - taskids = list(map(int, selected.values)) - if taskids: - await self.tasklist.update_tasks( - *taskids, - cascade=True, - deleted_at=utc_now() - ) - await self.refresh() - await self.redraw() - - async def delete_selector_refresh(self): - t = self.bot.translator.t - self.delete_selector.placeholder = t(_p('ui:tasklist|menu:delete|placeholder', "Select to Delete")) - self.delete_selector.options = self.toggle_selector.options - self.delete_selector.max_values = len(self.toggle_selector.options) - - @button(label="ClOSE_PLACEHOLDER", style=ButtonStyle.red) - async def close_pressed(self, interaction: discord.Interaction, pressed: Button): - await interaction.response.defer() - if self._message is not None: - try: - await self._message.delete() - except discord.HTTPException: - pass - await self.close() - - @button(label="CLEAR_PLACEHOLDER", style=ButtonStyle.red) - async def clear_pressed(self, interaction: discord.Interaction, pressed: Button): - await interaction.response.defer() - await self.tasklist.update_tasklist( - deleted_at=utc_now(), - ) - await self.refresh() - await self.redraw() - - def button_labels(self): - t = self.bot.translator.t - self.new_pressed.label = t(_p('ui:tasklist|button:new', "New")) - self.edit_pressed.label = t(_p('ui:tasklist|button:edit', "Edit")) - self.del_pressed.label = t(_p('ui:tasklist|button:delete', "Delete")) - self.clear_pressed.label = t(_p('ui:tasklist|button:clear', "Clear")) - self.close_pressed.label = t(_p('ui:tasklist|button:close', "Close")) - async def refresh(self): # Refresh data await self.tasklist.refresh() + self.labelled = self.tasklist.labelled self.refresh_pages() - async def redraw(self): - self.current_page = await self.get_page(self.page_num) + async def refresh_components(self): + await asyncio.gather( + self.main_menu_refresh(), + self.sub_menu_refresh(), + self.new_button_refresh(), + self.edit_mode_button_refresh(), + self.delete_mode_button_refresh(), + self.toggle_mode_button_refresh(), + self.edit_bulk_button_refresh(), + self.clear_button_refresh(), + self.save_button_refresh(), + self.refresh_button_refresh(), + self.quit_button_refresh(), + ) + + action_row = [ + self.new_button, self.toggle_mode_button, self.edit_mode_button, self.delete_mode_button, + ] + if self.mode is UIMode.EDIT: + action_row.append(self.edit_bulk_button) + elif self.mode is UIMode.DELETE: + action_row.append(self.clear_button) + + main_row = (self.main_menu,) if self.main_menu.options else () + sub_row = (self.sub_menu,) if self.sub_menu.options else () - # Refresh the layout if len(self._pages) > 1: - # Paged layout - await self.toggle_selector_refresh() - self._layout = [ - (self.new_pressed, self.edit_pressed, self.del_pressed), - (self.toggle_selector,), - (self.prev_page_button, self.close_pressed, self.next_page_button) - ] - if self._deleting: - await self.delete_selector_refresh() - self._layout.append((self.delete_selector,)) - self._layout[0] = (*self._layout[0], self.clear_pressed) + # Multi paged layout + self._layout = ( + action_row, + main_row, + sub_row, + (self.prev_page_button, self.save_button, + self.refresh_button, self.quit_button, self.next_page_button) + + ) elif len(self.tasklist.tasklist) > 0: - # Single page, with tasks - await self.toggle_selector_refresh() - self._layout = [ - (self.new_pressed, self.edit_pressed, self.del_pressed, self.close_pressed), - (self.toggle_selector,), - ] - if self._deleting: - await self.delete_selector_refresh() - self._layout[0] = (*self._layout[0], self.clear_pressed) - self._layout.append((self.delete_selector,)) + # Single page, but still at least one task + self._layout = ( + action_row, + main_row, + sub_row, + (self.save_button, self.refresh_button, self.quit_pressed) + ) else: - # With no tasks, nothing to select - self._layout = [ - (self.new_pressed, self.edit_pressed, self.close_pressed) - ] + # No tasks + self._layout = ( + action_row, + (self.refresh_button, self.quit_pressed) + ) + + async def redraw(self, interaction: Optional[discord.Interaction] = None): + self.current_page = await self.get_page(self.page_num) + await self.refresh_components() # Resend - if not self._message: - self._message = await self.channel.send(**self.current_page.send_args, view=self) - else: + if interaction is not None: + if self._message: + try: + await self._message.delete() + except discord.HTTPException: + pass + self._message = await interaction.followup.send(**self.current_page.send_args, view=self) + elif self._message: await self._message.edit(**self.current_page.edit_args, view=self) + else: + self._message = await self.channel.send(**self.current_page.send_args, view=self) diff --git a/src/settings/ui.py b/src/settings/ui.py index b14b596c..f70e017c 100644 --- a/src/settings/ui.py +++ b/src/settings/ui.py @@ -200,7 +200,11 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): @property def long_desc(self): t = ctx_translator.get().t - return t(self._long_desc) + bot = ctx_bot.get() + return t(self._long_desc).format( + bot=bot, + cmds=bot.core.mention_cache + ) @property def display_name(self): @@ -210,12 +214,20 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]): @property def desc(self): t = ctx_translator.get().t - return t(self._desc) + bot = ctx_bot.get() + return t(self._desc).format( + bot=bot, + cmds=bot.core.mention_cache + ) @property def accepts(self): t = ctx_translator.get().t - return t(self._accepts) + bot = ctx_bot.get() + return t(self._accepts).format( + bot=bot, + cmds=bot.core.mention_cache + ) async def write(self, **kwargs) -> None: await super().write(**kwargs)