refactor (tasklist): Various UI adjustments.

This commit is contained in:
2023-07-06 00:19:55 +03:00
parent 65c17f11b2
commit ce68813788
10 changed files with 989 additions and 500 deletions

View File

@@ -55,3 +55,5 @@ coin = <:coin:975880967485022239>
task_checked = :🟢: task_checked = :🟢:
task_unchecked = :⚫: task_unchecked = :⚫:
task_new = ::
task_save = :💾:

View File

@@ -62,13 +62,16 @@ def process_pot(domain, path):
po.save(targetpo) po.save(targetpo)
po.save_as_mofile(targetmo) po.save_as_mofile(targetmo)
print(f"Processed {entries} from POT {domain}.") print(f"Processed {entries} from POT {domain}.")
return entries
def process_all(): def process_all():
total = 0
for file in os.scandir(templates): for file in os.scandir(templates):
if file.name.endswith('pot'): if file.name.endswith('pot'):
print(f"Processing pot: {file.name[:-4]}") 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__': if __name__ == '__main__':

View File

@@ -21,9 +21,9 @@ cmd_map = {
"cmd_shop": "shop open", "cmd_shop": "shop open",
"cmd_room": "room rent", "cmd_room": "room rent",
"cmd_reminders": "remindme in", "cmd_reminders": "remindme in",
"cmd_tasklist": "tasklist open", "cmd_tasklist": "tasklist",
"cmd_timers": "timers list", "cmd_timers": "timers list",
"cmd_schedule": "schedule book", "cmd_schedule": "schedule",
"cmd_dashboard": "dashboard" "cmd_dashboard": "dashboard"
} }

View File

@@ -238,7 +238,7 @@ class ScheduleCog(LionCog):
slot = self.active_slots.get(slotid, None) slot = self.active_slots.get(slotid, None)
session = slot.sessions.get(guildid, None) if slot else 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 member is not None:
if slot.closing.is_set(): if slot.closing.is_set():
# Don't try to cancel a booking for a closing active slot. # Don't try to cancel a booking for a closing active slot.

View File

@@ -210,16 +210,16 @@ class TimeSlot:
await batchrun_per_second(coros, 5) await batchrun_per_second(coros, 5)
# Save messageids # Save messageids
if sessions: tmptable = TemporaryTable(
tmptable = TemporaryTable( '_gid', '_sid', '_mid',
'_gid', '_sid', '_mid', types=('BIGINT', 'INTEGER', 'BIGINT')
types=('BIGINT', 'INTEGER', 'BIGINT') )
) tmptable.values = [
tmptable.values = [ (sg.data.guildid, sg.data.slotid, sg.messageid)
(sg.data.guildid, sg.data.slotid, sg.messageid) for sg in sessions
for sg in sessions if sg.messageid is not None
if sg.messageid is not None ]
] if tmptable.values:
await Data.ScheduleSession.table.update_where( await Data.ScheduleSession.table.update_where(
guildid=tmptable['_gid'], slotid=tmptable['_sid'] guildid=tmptable['_gid'], slotid=tmptable['_sid']
).set( ).set(

View File

@@ -1,4 +1,5 @@
from typing import Optional from typing import Optional
from collections import defaultdict
import datetime as dt import datetime as dt
import discord import discord
@@ -9,7 +10,7 @@ from discord.app_commands.transformers import AppCommandOptionType as cmdopt
from meta import LionBot, LionCog, LionContext from meta import LionBot, LionCog, LionContext
from meta.errors import UserInputError from meta.errors import UserInputError
from utils.lib import utc_now, error_embed 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 data import Condition, NULL
from wards import low_management_ward from wards import low_management_ward
@@ -17,10 +18,10 @@ from wards import low_management_ward
from . import babel, logger from . import babel, logger
from .data import TasklistData from .data import TasklistData
from .tasklist import Tasklist from .tasklist import Tasklist
from .ui import TasklistUI, SingleEditor, BulkEditor from .ui import TasklistUI, SingleEditor, BulkEditor, TasklistCaller
from .settings import TasklistSettings, TasklistConfigUI from .settings import TasklistSettings, TasklistConfigUI
_p = babel._p _p, _np = babel._p, babel._np
MAX_LENGTH = 100 MAX_LENGTH = 100
@@ -128,13 +129,14 @@ class TasklistCog(LionCog):
self.babel = babel self.babel = babel
self.settings = TasklistSettings() self.settings = TasklistSettings()
self.live_tasklists = TasklistUI._live_
async def cog_load(self): async def cog_load(self):
await self.data.init() 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)
self.bot.core.guild_config.register_model_setting(self.settings.task_reward_limit) 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') configcog = self.bot.get_cog('ConfigCog')
self.crossload_group(self.configure_group, configcog.configure_group) self.crossload_group(self.configure_group, configcog.configure_group)
@@ -164,30 +166,164 @@ class TasklistCog(LionCog):
f"'{amount}' coins for completing '{count}' tasks." 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( @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): async def tasklist_group(self, ctx: LionContext):
raise NotImplementedError 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 t = self.bot.translator.t
# Should usually be cached, so this won't trigger repetitive db access # 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 = [] labels = []
idmap = {}
for label, task in tasklist.labelled.items(): for label, task in tasklist.labelled.items():
labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1) labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1)
taskstring = f"{labelstring} {task.content}" taskstring = f"{labelstring} {task.content}"
idmap[task.taskid] = labelstring
labels.append((labelstring, taskstring)) labels.append((labelstring, taskstring))
# Assume user is typing a label
matching = [(label, task) for label, task in labels if label.startswith(partial)] 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: if not matching:
matching = [(label, task) for label, task in labels if partial.lower() in task.lower()] 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 = [ options = [
appcmds.Choice( appcmds.Choice(
name=t(_p( name=t(_p(
@@ -197,73 +333,34 @@ class TasklistCog(LionCog):
value=partial value=partial
) )
] ]
else:
options = [
appcmds.Choice(name=task_string, value=label)
for label, task_string in matching
]
return options[:25] return options[:25]
async def is_tasklist_channel(self, channel) -> bool: async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]:
if not channel.guild: """
return True Shared autocomplete for single task parameters.
channels = (await self.settings.tasklist_channels.get(channel.guild.id)).value """
return (not channels) or (channel in channels) or (channel.category in channels) 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( @tasklist_group.command(
name=_p('cmd:tasklist_open', "open"), name=_p('cmd:tasks_new', "new"),
description=_p( description=_p(
'cmd:tasklist_open|desc', 'cmd:tasks_new|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',
"Add a new task to your tasklist." "Add a new task to your tasklist."
) )
) )
@appcmds.rename( @appcmds.rename(
content=_p('cmd:tasklist_new|param:content', "task"), content=_p('cmd:tasks_new|param:content', "task"),
parent=_p('cmd:tasklist_new|param:parent', 'parent') parent=_p('cmd:tasks_new|param:parent', 'parent')
) )
@appcmds.describe( @appcmds.describe(
content=_p('cmd:tasklist_new|param:content|desc', "Content of your new task."), content=_p('cmd:tasks_new|param:content|desc', "Content of your new task."),
parent=_p('cmd:tasklist_new|param:parent', 'Parent of this task.') parent=_p('cmd:tasks_new|param:parent', 'Parent of this task.')
) )
async def tasklist_new_cmd(self, ctx: LionContext, async def tasklist_new_cmd(self, ctx: LionContext,
content: appcmds.Range[str, 1, MAX_LENGTH], content: appcmds.Range[str, 1, MAX_LENGTH],
@@ -282,52 +379,49 @@ class TasklistCog(LionCog):
await ctx.interaction.edit_original_response( await ctx.interaction.edit_original_response(
embed=error_embed( embed=error_embed(
t(_p( t(_p(
'cmd:tasklist_new|error:parse_parent', 'cmd:tasks_new|error:parse_parent',
"Could not find task number `{input}` in your tasklist." "Could not find parent task number `{input}` in your tasklist."
)).format(input=parent) )).format(input=parent)
), ),
) )
return return
# Create task # 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): # Ack creation
# summon tasklist label = tasklist.labelid(task.taskid)
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) embed = discord.Embed(
await tasklistui.summon() colour=discord.Colour.brand_green(),
await ctx.interaction.delete_original_response() description=t(_p(
else: 'cmd:tasks_new|resp:success',
# ack creation "{tick} Created task `{label}`."
embed = discord.Embed( )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(label))
colour=discord.Colour.brand_green(), )
description=t(_p( await ctx.interaction.edit_original_response(
'cmd:tasklist_new|resp:success', embed=embed,
"{tick} Task created successfully." view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot)
)).format(tick=self.bot.config.emojis.tick) )
) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_new_cmd.autocomplete('parent') tasklist_new_cmd.autocomplete('parent')(task_acmpl)
async def tasklist_new_cmd_parent_acmpl(self, interaction: discord.Interaction, partial: str):
return await self.task_acmpl(interaction, partial)
@tasklist_group.command( @tasklist_group.command(
name=_p('cmd:tasklist_edit', "edit"), name=_p('cmd:tasks_edit', "edit"),
description=_p( description=_p(
'cmd:tasklist_edit|desc', 'cmd:tasks_edit|desc',
"Edit tasks in your tasklist." "Edit a task in your tasklist."
) )
) )
@appcmds.rename( @appcmds.rename(
taskstr=_p('cmd:tasklist_edit|param:taskstr', "task"), taskstr=_p('cmd:tasks_edit|param:taskstr', "task"),
new_content=_p('cmd:tasklist_edit|param:new_content', "new_task"), new_content=_p('cmd:tasks_edit|param:new_content', "new_task"),
new_parent=_p('cmd:tasklist_edit|param:new_parent', "new_parent"), new_parent=_p('cmd:tasks_edit|param:new_parent', "new_parent"),
) )
@appcmds.describe( @appcmds.describe(
taskstr=_p('cmd:tasklist_edit|param:taskstr|desc', "Which task do you want to update?"), taskstr=_p('cmd:tasks_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_content=_p('cmd:tasks_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?"), 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, async def tasklist_edit_cmd(self, ctx: LionContext,
taskstr: str, taskstr: str,
@@ -345,8 +439,8 @@ class TasklistCog(LionCog):
await ctx.interaction.response.send_message( await ctx.interaction.response.send_message(
embed=error_embed( embed=error_embed(
t(_p( t(_p(
'cmd:tasklist_edit|error:parse_taskstr', 'cmd:tasks_edit|error:parse_taskstr',
"Could not find task number `{input}` in your tasklist." "Could not find target task number `{input}` in your tasklist."
)).format(input=taskstr) )).format(input=taskstr)
), ),
ephemeral=True, ephemeral=True,
@@ -361,8 +455,8 @@ class TasklistCog(LionCog):
await interaction.response.send_message( await interaction.response.send_message(
embed=error_embed( embed=error_embed(
t(_p( t(_p(
'cmd:tasklist_edit|error:parse_parent', 'cmd:tasks_edit|error:parse_parent',
"Could not find task number `{input}` in your tasklist." "Could not find new parent task number `{input}` in your tasklist."
)).format(input=new_parent) )).format(input=new_parent)
), ),
ephemeral=True ephemeral=True
@@ -377,25 +471,22 @@ class TasklistCog(LionCog):
if args: if args:
await tasklist.update_tasks(tid, **args) await tasklist.update_tasks(tid, **args)
if await self.is_tasklist_channel(ctx.channel): embed = discord.Embed(
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) colour=discord.Color.brand_green(),
await tasklistui.summon() description=t(_p(
else: 'cmd:tasks_edit|resp:success|desc',
embed = discord.Embed( "{tick} Task `{label}` updated."
colour=discord.Color.brand_green(), )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(tasklist.labelid(tid))),
description=t(_p( )
'cmd:tasklist_edit|resp:success|desc', await ctx.interaction.edit_original_response(
"{tick} Task updated successfully." embed=embed,
)).format(tick=self.bot.config.emojis.tick), view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot)
) )
await interaction.response.send_message(embed=embed, ephemeral=True) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel)
if new_content or new_parent: if new_content or new_parent:
# Manual edit route # Manual edit route
await handle_update(ctx.interaction, new_content, new_parent) 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: else:
# Modal edit route # Modal edit route
task = tasklist.tasklist[tid] task = tasklist.tasklist[tid]
@@ -410,22 +501,15 @@ class TasklistCog(LionCog):
@editor.submit_callback() @editor.submit_callback()
async def update_task(interaction: discord.Interaction): async def update_task(interaction: discord.Interaction):
await handle_update(interaction, editor.task.value, editor.parent.value) 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) await ctx.interaction.response.send_modal(editor)
@tasklist_edit_cmd.autocomplete('taskstr') tasklist_edit_cmd.autocomplete('taskstr')(task_acmpl)
async def tasklist_edit_cmd_taskstr_acmpl(self, interaction: discord.Interaction, partial: str): tasklist_edit_cmd.autocomplete('new_parent')(task_acmpl)
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_group.command( @tasklist_group.command(
name=_p('cmd:tasklist_clear', "clear"), name=_p('cmd:tasks_clear', "clear"),
description=_p('cmd:tasklist_clear|desc', "Clear your tasklist.") description=_p('cmd:tasks_clear|desc', "Clear your tasklist.")
) )
async def tasklist_clear_cmd(self, ctx: LionContext): async def tasklist_clear_cmd(self, ctx: LionContext):
t = ctx.bot.translator.t t = ctx.bot.translator.t
@@ -434,47 +518,47 @@ class TasklistCog(LionCog):
await tasklist.update_tasklist(deleted_at=utc_now()) await tasklist.update_tasklist(deleted_at=utc_now())
await ctx.reply( await ctx.reply(
t(_p( t(_p(
'cmd:tasklist_clear|resp:success', 'cmd:tasks_clear|resp:success',
"Your tasklist has been cleared." "Your tasklist has been cleared."
)), )),
view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot),
ephemeral=True ephemeral=True
) )
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel)
await tasklistui.summon()
@tasklist_group.command( @tasklist_group.command(
name=_p('cmd:tasklist_remove', "remove"), name=_p('cmd:tasks_remove', "remove"),
description=_p( description=_p(
'cmd:tasklist_remove|desc', 'cmd:tasks_remove|desc',
"Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)." "Remove tasks matching all the provided conditions. (E.g. remove tasks completed before today)."
) )
) )
@appcmds.rename( @appcmds.rename(
taskidstr=_p('cmd:tasklist_remove|param:taskidstr', "tasks"), taskidstr=_p('cmd:tasks_remove|param:taskidstr', "tasks"),
created_before=_p('cmd:tasklist_remove|param:created_before', "created_before"), created_before=_p('cmd:tasks_remove|param:created_before', "created_before"),
updated_before=_p('cmd:tasklist_remove|param:updated_before', "updated_before"), updated_before=_p('cmd:tasks_remove|param:updated_before', "updated_before"),
completed=_p('cmd:tasklist_remove|param:completed', "completed"), completed=_p('cmd:tasks_remove|param:completed', "completed"),
cascade=_p('cmd:tasklist_remove|param:cascade', "cascade") cascade=_p('cmd:tasks_remove|param:cascade', "cascade")
) )
@appcmds.describe( @appcmds.describe(
taskidstr=_p( 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-)." "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
), ),
created_before=_p( 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." "Only delete tasks created before the selected time."
), ),
updated_before=_p( 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." "Only deleted tasks update (i.e. completed or edited) before the selected time."
), ),
completed=_p( completed=_p(
'cmd:tasklist_remove|param:completed', 'cmd:tasks_remove|param:completed',
"Only delete tasks which are (not) complete." "Only delete tasks which are (not) complete."
), ),
cascade=_p( cascade=_p(
'cmd:tasklist_remove|param:cascade', 'cmd:tasks_remove|param:cascade',
"Whether to recursively remove subtasks of removed tasks." "Whether to recursively remove subtasks of removed tasks."
) )
) )
@@ -506,7 +590,7 @@ class TasklistCog(LionCog):
# Explicitly error if none of the ranges matched # Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response( await ctx.interaction.edit_original_response(
embed=error_embed( embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching', 'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`" "No tasks on your tasklist match `{input}`"
).format(input=taskidstr) ).format(input=taskidstr)
) )
@@ -515,8 +599,7 @@ class TasklistCog(LionCog):
conditions.append(self.data.Task.taskid == taskids) conditions.append(self.data.Task.taskid == taskids)
if created_before is not None or updated_before is not None: if created_before is not None or updated_before is not None:
# TODO: Extract timezone from user settings timezone = ctx.alion.timezone
timezone = None
if created_before is not None: if created_before is not None:
conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone)) conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone))
if updated_before is not None: if updated_before is not None:
@@ -531,46 +614,52 @@ class TasklistCog(LionCog):
if not tasks: if not tasks:
await ctx.interaction.edit_original_response( await ctx.interaction.edit_original_response(
embed=error_embed( 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!" "No tasks on your tasklist matching all the given conditions!"
).format(input=taskidstr) ).format(input=taskidstr)
) )
return return
taskids = [task.taskid for task in tasks] 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()) await tasklist.update_tasks(*taskids, cascade=cascade, deleted_at=utc_now())
# Ack changes or summon tasklist # Ack changes and summon tasklist
if await self.is_tasklist_channel(ctx.channel): embed = discord.Embed(
# Summon tasklist colour=discord.Colour.brand_green(),
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) description=t(_np(
await tasklistui.summon() 'cmd:tasks_remove|resp:success',
await ctx.interaction.delete_original_response() "{tick} Deleted task `{label}`",
else: "{tick} Deleted `{count}` tasks from your tasklist.",
# Ack deletion len(taskids)
embed = discord.Embed( )).format(
colour=discord.Colour.brand_green(), tick=self.bot.config.emojis.tick,
description=t(_p( label=label,
'cmd:tasklist_remove|resp:success', count=len(taskids)
"{tick} tasks deleted."
)).format(tick=self.bot.config.emojis.tick)
) )
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( @tasklist_group.command(
name=_p('cmd:tasklist_tick', "tick"), name=_p('cmd:tasks_tick', "tick"),
description=_p('cmd:tasklist_tick|desc', "Mark the given tasks as completed.") description=_p('cmd:tasks_tick|desc', "Mark the given tasks as completed.")
) )
@appcmds.rename( @appcmds.rename(
taskidstr=_p('cmd:tasklist_tick|param:taskidstr', "tasks"), taskidstr=_p('cmd:tasks_tick|param:taskidstr', "tasks"),
cascade=_p('cmd:tasklist_tick|param:cascade', "cascade") cascade=_p('cmd:tasks_tick|param:cascade', "cascade")
) )
@appcmds.describe( @appcmds.describe(
taskidstr=_p( 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-)." "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
), ),
cascade=_p( cascade=_p(
'cmd:tasklist_tick|param:cascade|desc', 'cmd:tasks_tick|param:cascade|desc',
"Whether to also mark all subtasks as complete." "Whether to also mark all subtasks as complete."
) )
) )
@@ -596,7 +685,7 @@ class TasklistCog(LionCog):
# Explicitly error if none of the ranges matched # Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response( await ctx.interaction.edit_original_response(
embed=error_embed( embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching', 'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`" "No tasks on your tasklist match `{input}`"
).format(input=taskidstr) ).format(input=taskidstr)
) )
@@ -610,38 +699,43 @@ class TasklistCog(LionCog):
if ctx.guild: if ctx.guild:
self.bot.dispatch('tasks_completed', ctx.author, *taskids) self.bot.dispatch('tasks_completed', ctx.author, *taskids)
# Ack changes or summon tasklist # Ack changes and summon tasklist
if await self.is_tasklist_channel(ctx.channel): embed = discord.Embed(
# Summon tasklist colour=discord.Colour.brand_green(),
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) description=t(_np(
await tasklistui.summon() 'cmd:tasks_tick|resp:success',
await ctx.interaction.delete_original_response() "{tick} Marked `{label}` as complete.",
else: "{tick} Marked `{count}` tasks as complete.",
# Ack edit len(taskids)
embed = discord.Embed( )).format(
colour=discord.Colour.brand_green(), tick=self.bot.config.emojis.tick,
description=t(_p( count=len(taskids),
'cmd:tasklist_tick|resp:success', label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-'
"{tick} tasks marked as complete."
)).format(tick=self.bot.config.emojis.tick)
) )
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( @tasklist_group.command(
name=_p('cmd:tasklist_untick', "untick"), name=_p('cmd:tasks_untick', "untick"),
description=_p('cmd:tasklist_untick|desc', "Mark the given tasks as incomplete.") description=_p('cmd:tasks_untick|desc', "Mark the given tasks as incomplete.")
) )
@appcmds.rename( @appcmds.rename(
taskidstr=_p('cmd:tasklist_untick|param:taskidstr', "taskids"), taskidstr=_p('cmd:tasks_untick|param:taskidstr', "taskids"),
cascade=_p('cmd:tasklist_untick|param:cascade', "cascade") cascade=_p('cmd:tasks_untick|param:cascade', "cascade")
) )
@appcmds.describe( @appcmds.describe(
taskidstr=_p( 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-)." "List of task numbers or ranges to remove (e.g. 1, 2, 5-7, 8.1-3, 9-)."
), ),
cascade=_p( cascade=_p(
'cmd:tasklist_untick|param:cascade|desc', 'cmd:tasks_untick|param:cascade|desc',
"Whether to also mark all subtasks as incomplete." "Whether to also mark all subtasks as incomplete."
) )
) )
@@ -666,7 +760,7 @@ class TasklistCog(LionCog):
# Explicitly error if none of the ranges matched # Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response( await ctx.interaction.edit_original_response(
embed=error_embed( embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching', 'cmd:tasks_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`" "No tasks on your tasklist match `{input}`"
).format(input=taskidstr) ).format(input=taskidstr)
) )
@@ -678,22 +772,27 @@ class TasklistCog(LionCog):
if taskids: if taskids:
await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=None) await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=None)
# Ack changes or summon tasklist # Ack changes and summon tasklist
if await self.is_tasklist_channel(ctx.channel): embed = discord.Embed(
# Summon tasklist colour=discord.Colour.brand_green(),
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild) description=t(_np(
await tasklistui.summon() 'cmd:tasks_untick|resp:success',
await ctx.interaction.delete_original_response() "{tick} Marked `{label}` as incomplete.",
else: "{tick} Marked `{count}` tasks as incomplete.",
# Ack edit len(taskids)
embed = discord.Embed( )).format(
colour=discord.Colour.brand_green(), tick=self.bot.config.emojis.tick,
description=t(_p( count=len(taskids),
'cmd:tasklist_untick|resp:success', label=tasklist.format_label(tasklist.labelid(taskids[0])) if taskids else '-'
"{tick} tasks marked as incomplete."
)).format(tick=self.bot.config.emojis.tick)
) )
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 # Setting Commands
@LionCog.placeholder_group @LionCog.placeholder_group

View File

@@ -113,12 +113,14 @@ class TasklistSettings(SettingGroup):
_display_name = _p('guildset:tasklist_channels', "tasklist_channels") _display_name = _p('guildset:tasklist_channels', "tasklist_channels")
_desc = _p( _desc = _p(
'guildset:tasklist_channels|desc', 'guildset:tasklist_channels|desc',
"Channels in which to allow the tasklist." "Channels in which to publicly display member tasklists."
) )
_long_desc = _p( _long_desc = _p(
'guildset:tasklist_channels|long_desc', 'guildset:tasklist_channels|long_desc',
"If set, members will only be able to open their tasklist in these channels.\n" "A member's tasklist (from {cmds[tasklist]}) is usually only visible to the member themselves. "
"If a category is selected, this will allow all channels under that category." "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( _accepts = _p(
'guildset:tasklist_channels|accepts', 'guildset:tasklist_channels|accepts',
@@ -139,12 +141,12 @@ class TasklistSettings(SettingGroup):
if self.data: if self.data:
resp = t(_p( resp = t(_p(
'guildset:tasklist_channels|set_response|set', '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) )).format(channels=self.formatted)
else: else:
resp = t(_p( resp = t(_p(
'guildset:tasklist_channels|set_response|unset', 'guildset:tasklist_channels|set_response|unset',
"Members may now open their tasklist in any channel." "Member tasklists will never be publicly displayed."
)) ))
return resp return resp

View File

@@ -6,9 +6,13 @@ from meta import LionBot
from meta.errors import UserInputError from meta.errors import UserInputError
from utils.lib import utc_now from utils.lib import utc_now
from . import babel
from .data import TasklistData from .data import TasklistData
_p = babel._p
class Tasklist: class Tasklist:
""" """
Class representing a single user's tasklist. Class representing a single user's tasklist.
@@ -207,7 +211,7 @@ class Tasklist:
""" """
return '.'.join(map(str, label)) + '.' * (len(label) == 1) 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. 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]: if len(end_label) > 1 and head != end_label[:-1]:
# Error: Parents don't match in range ... # 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): for tail in range(max(start_tail, 1), end_tail + 1):
label = (*head, tail) label = (*head, tail)
@@ -255,5 +265,11 @@ class Tasklist:
taskids.add(labelmap[start_label]) taskids.add(labelmap[start_label])
else: else:
# Error # 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) return list(taskids)

View File

@@ -1,5 +1,9 @@
from typing import Optional from typing import Optional
from collections import defaultdict
from enum import Enum
import asyncio
import re import re
from io import StringIO
import discord import discord
from discord.ui.select import select, Select, SelectOption from discord.ui.select import select, Select, SelectOption
@@ -24,6 +28,22 @@ checked_emoji = conf.emojis.task_checked
unchecked_emoji = conf.emojis.task_unchecked 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): class SingleEditor(FastModal):
task: TextInput = TextInput( task: TextInput = TextInput(
label='', label='',
@@ -37,7 +57,7 @@ class SingleEditor(FastModal):
parent: TextInput = TextInput( parent: TextInput = TextInput(
label='', label='',
max_length=10, max_length=120,
required=False required=False
) )
@@ -96,6 +116,7 @@ class BulkEditor(LeoModal):
self.tasklist = tasklist self.tasklist = tasklist
self.bot = tasklist.bot self.bot = tasklist.bot
self.labelled = tasklist.labelled
self.userid = tasklist.userid self.userid = tasklist.userid
self.lines = self.format_tasklist() self.lines = self.format_tasklist()
@@ -118,7 +139,7 @@ class BulkEditor(LeoModal):
""" """
Format the tasklist into lines of editable text. Format the tasklist into lines of editable text.
""" """
labelled = self.tasklist.labelled labelled = self.labelled
lines = {} lines = {}
total_len = 0 total_len = 0
for label, task in labelled.items(): for label, task in labelled.items():
@@ -236,22 +257,64 @@ class BulkEditor(LeoModal):
break 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): class TasklistUI(BasePager):
""" """
Paged UI panel for managing the tasklist. Paged UI panel for managing the tasklist.
""" """
# Cache of live tasklist widgets # Cache of live tasklist widgets
# (channelid, userid) -> Tasklist # userid -> channelid -> TasklistUI
_live_ = {} _live_ = defaultdict(dict)
def __init__(self, def __init__(self,
tasklist: Tasklist, tasklist: Tasklist,
channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs): channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs):
kwargs.setdefault('timeout', 3600) kwargs.setdefault('timeout', 600)
super().__init__(**kwargs) super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot self.bot = tasklist.bot
self.tasklist = tasklist
self.labelled = tasklist.labelled
self.userid = tasklist.userid self.userid = tasklist.userid
self.channel = channel self.channel = channel
self.guild = guild self.guild = guild
@@ -263,21 +326,508 @@ class TasklistUI(BasePager):
self._channelid = channel.id self._channelid = channel.id
self.current_page = None self.current_page = None
self._deleting = False self.mode: UIMode = UIMode.TOGGLE
self._message: Optional[discord.Message] = None self._message: Optional[discord.Message] = None
self._last_parentid: Optional[int] = None
self._subtree_root: Optional[int] = None
self.button_labels()
self.set_active() self.set_active()
# ----- UI API -----
@classmethod @classmethod
async def fetch(cls, tasklist, channel, *args, **kwargs): def fetch(cls, tasklist, channel, *args, **kwargs):
key = (channel.id, tasklist.userid) userid = tasklist.userid
if key not in cls._live_: channelid = channel.id
if channelid not in cls._live_[userid]:
self = cls(tasklist, channel, *args, **kwargs) self = cls(tasklist, channel, *args, **kwargs)
cls._live_[key] = self cls._live_[userid][channelid] = self
return cls._live_[key] 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): def access_check(self, userid):
return userid == self.userid return userid == self.userid
@@ -298,7 +848,7 @@ class TasklistUI(BasePager):
async def cleanup(self): async def cleanup(self):
self.set_inactive() 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: if self._message is not None:
try: try:
@@ -313,48 +863,6 @@ class TasklistUI(BasePager):
except discord.HTTPException: except discord.HTTPException:
pass 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: async def get_page(self, page_id) -> MessageArgs:
t = self.bot.translator.t t = self.bot.translator.t
@@ -400,56 +908,8 @@ class TasklistUI(BasePager):
page_args = MessageArgs(embed=embed) page_args = MessageArgs(embed=embed)
return page_args 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): def refresh_pages(self):
labelled = list(self.tasklist.labelled.items()) labelled = list(self.labelled.items())
count = len(labelled) count = len(labelled)
pages = [] pages = []
@@ -478,181 +938,76 @@ class TasklistUI(BasePager):
self._pages = pages self._pages = pages
return 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): async def refresh(self):
# Refresh data # Refresh data
await self.tasklist.refresh() await self.tasklist.refresh()
self.labelled = self.tasklist.labelled
self.refresh_pages() self.refresh_pages()
async def redraw(self): async def refresh_components(self):
self.current_page = await self.get_page(self.page_num) 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: if len(self._pages) > 1:
# Paged layout # Multi paged layout
await self.toggle_selector_refresh() self._layout = (
self._layout = [ action_row,
(self.new_pressed, self.edit_pressed, self.del_pressed), main_row,
(self.toggle_selector,), sub_row,
(self.prev_page_button, self.close_pressed, self.next_page_button) (self.prev_page_button, self.save_button,
] self.refresh_button, self.quit_button, 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)
elif len(self.tasklist.tasklist) > 0: elif len(self.tasklist.tasklist) > 0:
# Single page, with tasks # Single page, but still at least one task
await self.toggle_selector_refresh() self._layout = (
self._layout = [ action_row,
(self.new_pressed, self.edit_pressed, self.del_pressed, self.close_pressed), main_row,
(self.toggle_selector,), sub_row,
] (self.save_button, self.refresh_button, self.quit_pressed)
if self._deleting: )
await self.delete_selector_refresh()
self._layout[0] = (*self._layout[0], self.clear_pressed)
self._layout.append((self.delete_selector,))
else: else:
# With no tasks, nothing to select # No tasks
self._layout = [ self._layout = (
(self.new_pressed, self.edit_pressed, self.close_pressed) 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 # Resend
if not self._message: if interaction is not None:
self._message = await self.channel.send(**self.current_page.send_args, view=self) if self._message:
else: 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) 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)

View File

@@ -200,7 +200,11 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
@property @property
def long_desc(self): def long_desc(self):
t = ctx_translator.get().t 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 @property
def display_name(self): def display_name(self):
@@ -210,12 +214,20 @@ class InteractiveSetting(BaseSetting[ParentID, SettingData, SettingValue]):
@property @property
def desc(self): def desc(self):
t = ctx_translator.get().t 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 @property
def accepts(self): def accepts(self):
t = ctx_translator.get().t 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: async def write(self, **kwargs) -> None:
await super().write(**kwargs) await super().write(**kwargs)