fix (tasklist): Various bugfixes.

Fix issue where empty tasklist would raise ZeroDivisionError.
Fix issue where empty tasklist UI would error.
Fix issue where >4000 char tasklists would error on bulk edit.
Fix `/tasks edit` not responding to interaction.
Fix task acmpl going over 100 chars.
Fix empty tasklist text and UI layout.
Add `/tasks upload` for editing long tasklist.
Small UI tweaks.
Make `LionContext.error_reply` always ephemeral if possible.
This commit is contained in:
2023-07-07 22:53:34 +03:00
parent 6d6a3346e5
commit c6f462d131
4 changed files with 263 additions and 104 deletions

View File

@@ -122,6 +122,8 @@ class LionContext(Context['LionBot']):
# Expect this may be run in highly unusual circumstances. # Expect this may be run in highly unusual circumstances.
# This should never error, or at least handle all errors. # This should never error, or at least handle all errors.
if self.interaction:
kwargs.setdefault('ephemeral', True)
try: try:
await self.reply(content=content, **kwargs) await self.reply(content=content, **kwargs)
except discord.HTTPException: except discord.HTTPException:

View File

@@ -18,7 +18,7 @@ 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, TasklistCaller from .ui import TasklistUI, SingleEditor, TasklistCaller
from .settings import TasklistSettings, TasklistConfigUI from .settings import TasklistSettings, TasklistConfigUI
_p, _np = babel._p, babel._np _p, _np = babel._p, babel._np
@@ -264,7 +264,12 @@ class TasklistCog(LionCog):
idmap = {} 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}" remaining_width = 100 - len(labelstring) - 1
if len(task.content) > remaining_width:
content = task.content[:remaining_width - 3] + '...'
else:
content = task.content
taskstring = f"{labelstring} {content}"
idmap[task.taskid] = labelstring idmap[task.taskid] = labelstring
labels.append((labelstring, taskstring)) labels.append((labelstring, taskstring))
@@ -406,6 +411,126 @@ class TasklistCog(LionCog):
tasklist_new_cmd.autocomplete('parent')(task_acmpl) tasklist_new_cmd.autocomplete('parent')(task_acmpl)
@tasklist_group.command(
name=_p('cmd:tasks_upload', "upload"),
description=_p(
'cmd:tasks_upload|desc',
"Upload a list of tasks to append to or replace your tasklist."
)
)
@appcmds.rename(
tasklist_file=_p('cmd:tasks_upload|param:tasklist', "tasklist"),
append=_p('cmd:tasks_upload|param:append', "append")
)
@appcmds.describe(
tasklist_file=_p(
'cmd:tasks_upload|param:tasklist|desc',
"Text file containing a (standard markdown formatted) checklist of tasks to add or append."
),
append=_p(
'cmd:tasks_upload|param:append|desc',
"Whether to append the given tasks or replace your entire tasklist. Defaults to True."
)
)
async def tasklist_upload_cmd(self, ctx: LionContext,
tasklist_file: discord.Attachment,
append: bool = True):
t = self.bot.translator.t
if not ctx.interaction:
return
error = None
if tasklist_file.content_type and not tasklist_file.content_type.startswith('text'):
# Not a text file
error = t(_p(
'cmd:tasks_upload|error:not_text',
"The attached tasklist must be a text file!"
))
raise UserInputError(error)
if tasklist_file.size > 1000000:
# Too large
error = t(_p(
'cmd:tasks_upload|error:too_large',
"The attached tasklist was too large!"
))
raise UserInputError(error)
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
try:
content = (await tasklist_file.read()).decode(encoding='UTF-8')
lines = content.splitlines()
if len(lines) > 1000:
error = t(_p(
'cmd:tasks_upload|error:too_many_lines',
"Too many tasks! Refusing to process a tasklist with more than `1000` lines."
))
raise UserInputError(error)
except UnicodeDecodeError:
error = t(_p(
'cmd:tasks_upload|error:decoding',
"Could not decode attached tasklist. Please make sure it is saved with the `UTF-8` encoding."
))
raise UserInputError(error)
# Contents successfully parsed, update the tasklist.
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
# Lazily using the editor because it has a good parser
taskinfo = tasklist.parse_tasklist(lines)
conn = await self.bot.db.get_connection()
async with conn.transaction():
now = utc_now()
# Delete tasklist if required
if not append:
await tasklist.update_tasklist(deleted_at=now)
# Create tasklist
# TODO: Refactor into common method with parse tasklist
created = {}
target_depth = 0
while True:
to_insert = {}
for i, (parent, truedepth, ticked, content) in enumerate(taskinfo):
if truedepth == target_depth:
to_insert[i] = (
tasklist.userid,
content,
created[parent] if parent is not None else None,
now if ticked else None
)
if to_insert:
# Batch insert
tasks = await tasklist.data.Task.table.insert_many(
('userid', 'content', 'parentid', 'completed_at'),
*to_insert.values()
)
for i, task in zip(to_insert.keys(), tasks):
created[i] = task['taskid']
target_depth += 1
else:
# Reached maximum depth
break
# Ack modifications
embed = discord.Embed(
colour=discord.Colour.brand_green(),
description=t(_p(
'cmd:tasks_upload|resp:success',
"{tick} Updated your tasklist.",
)).format(
tick=self.bot.config.emojis.tick,
)
)
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_group.command( @tasklist_group.command(
name=_p('cmd:tasks_edit', "edit"), name=_p('cmd:tasks_edit', "edit"),
description=_p( description=_p(
@@ -478,9 +603,13 @@ class TasklistCog(LionCog):
"{tick} Task `{label}` updated." "{tick} Task `{label}` updated."
)).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(tasklist.labelid(tid))), )).format(tick=self.bot.config.emojis.tick, label=tasklist.format_label(tasklist.labelid(tid))),
) )
await ctx.interaction.edit_original_response( await interaction.response.send_message(
embed=embed, embed=embed,
view=None if ctx.channel.id in TasklistUI._live_[ctx.author.id] else TasklistCaller(self.bot) view=(
discord.utils.MISSING if ctx.channel.id in TasklistUI._live_[ctx.author.id]
else TasklistCaller(self.bot)
),
ephemeral=True
) )
self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel) self.bot.dispatch('tasklist_update', userid=ctx.author.id, channel=ctx.channel)

View File

@@ -34,6 +34,7 @@ class Tasklist:
label_range_re = re.compile( label_range_re = re.compile(
r"^(?P<start>(\d+\.)*\d+)\.?((\s*(?P<range>-)\s*)(?P<end>(\d+\.)*\d*\.?))?$" r"^(?P<start>(\d+\.)*\d+)\.?((\s*(?P<range>-)\s*)(?P<end>(\d+\.)*\d*\.?))?$"
) )
line_regex = re.compile(r"(?P<depth>\s*)-?\s*(\[\s*(?P<check>[^]]?)\s*\]\s*)?(?P<content>.*)")
def __init__(self, bot: LionBot, data: TasklistData, userid: int): def __init__(self, bot: LionBot, data: TasklistData, userid: int):
self.bot = bot self.bot = bot
@@ -273,3 +274,89 @@ class Tasklist:
)).format(range=split) )).format(range=split)
) )
return list(taskids) return list(taskids)
def flatten(self):
"""
Flatten the tasklist to a map of readable strings parseable by `parse_tasklist`.
"""
labelled = self.labelled
lines = {}
total_len = 0
for label, task in labelled.items():
prefix = ' ' * (len(label) - 1)
box = '- [ ]' if task.completed_at is None else '- [x]'
line = f"{prefix}{box} {task.content}"
if total_len + len(line) > 4000:
break
lines[task.taskid] = line
total_len += len(line)
return lines
def parse_tasklist(self, task_lines):
t = self.bot.translator.t
taskinfo = [] # (parent, truedepth, ticked, content)
depthtree = [] # (depth, index)
for line in task_lines:
match = self.line_regex.match(line)
if not match:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:parse_task',
"Malformed taskline!\n`{input}`"
)).format(input=line)
)
depth = len(match['depth'])
check = bool(match['check'])
content = match['content']
if not content:
continue
if len(content) > 100:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:task_too_long',
"Please keep your tasks under 100 characters!"
))
)
for i in range(len(depthtree)):
lastdepth = depthtree[-1][0]
if lastdepth >= depth:
depthtree.pop()
if lastdepth <= depth:
break
parent = depthtree[-1][1] if depthtree else None
depthtree.append((depth, len(taskinfo)))
taskinfo.append((parent, len(depthtree) - 1, check, content))
print(taskinfo)
return taskinfo
async def write_taskinfo(self, taskinfo):
"""
Create tasks from `taskinfo` (matching the output of `parse_tasklist`).
"""
now = utc_now()
created = {}
target_depth = 0
while True:
to_insert = {}
for i, (parent, truedepth, ticked, content) in enumerate(taskinfo):
if truedepth == target_depth:
to_insert[i] = (
self.userid,
content,
created[parent] if parent is not None else None,
now if ticked else None
)
if to_insert:
# Batch insert
tasks = await self.data.Task.table.insert_many(
('userid', 'content', 'parentid', 'completed_at'),
*to_insert.values()
)
for i, task in zip(to_insert.keys(), tasks):
created[i] = task['taskid']
target_depth += 1
else:
# Reached maximum depth
break

View File

@@ -89,7 +89,6 @@ class BulkEditor(LeoModal):
""" """
Error-handling modal for bulk-editing a tasklist. Error-handling modal for bulk-editing a tasklist.
""" """
line_regex = re.compile(r"(?P<depth>\s*)-?\s*(\[\s*(?P<check>[^]]?)\s*\]\s*)?(?P<content>.*)")
tasklist_editor: TextInput = TextInput( tasklist_editor: TextInput = TextInput(
label='', label='',
@@ -119,7 +118,7 @@ class BulkEditor(LeoModal):
self.labelled = tasklist.labelled self.labelled = tasklist.labelled
self.userid = tasklist.userid self.userid = tasklist.userid
self.lines = self.format_tasklist() self.lines = tasklist.flatten()
self.tasklist_editor.default = '\n'.join(self.lines.values()) self.tasklist_editor.default = '\n'.join(self.lines.values())
self._callbacks = [] self._callbacks = []
@@ -135,23 +134,6 @@ class BulkEditor(LeoModal):
self._callbacks.append(coro) self._callbacks.append(coro)
return coro return coro
def format_tasklist(self):
"""
Format the tasklist into lines of editable text.
"""
labelled = self.labelled
lines = {}
total_len = 0
for label, task in labelled.items():
prefix = ' ' * (len(label) - 1)
box = '- [ ]' if task.completed_at is None else '- [x]'
line = f"{prefix}{box} {task.content}"
if total_len + len(line) > 4000:
break
lines[task.taskid] = line
total_len += len(line)
return lines
async def on_submit(self, interaction: discord.Interaction): async def on_submit(self, interaction: discord.Interaction):
try: try:
await self.parse_editor() await self.parse_editor()
@@ -161,50 +143,12 @@ class BulkEditor(LeoModal):
except UserInputError as error: except UserInputError as error:
await ModalRetryUI(self, error.msg).respond_to(interaction) await ModalRetryUI(self, error.msg).respond_to(interaction)
def _parser(self, task_lines):
t = ctx_translator.get().t
taskinfo = [] # (parent, truedepth, ticked, content)
depthtree = [] # (depth, index)
for line in task_lines:
match = self.line_regex.match(line)
if not match:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:parse_task',
"Malformed taskline!\n`{input}`"
)).format(input=line)
)
depth = len(match['depth'])
check = bool(match['check'])
content = match['content']
if not content:
continue
if len(content) > 100:
raise UserInputError(
t(_p(
'modal:tasklist_bulk_editor|error:task_too_long',
"Please keep your tasks under 100 characters!"
))
)
for i in range(len(depthtree)):
lastdepth = depthtree[-1][0]
if lastdepth >= depth:
depthtree.pop()
if lastdepth <= depth:
break
parent = depthtree[-1][1] if depthtree else None
depthtree.append((depth, len(taskinfo)))
taskinfo.append((parent, len(depthtree) - 1, check, content))
return taskinfo
async def parse_editor(self): async def parse_editor(self):
# First parse each line # First parse each line
new_lines = self.tasklist_editor.value.splitlines() new_lines = self.tasklist_editor.value.splitlines()
taskinfo = self._parser(new_lines) taskinfo = self.tasklist.parse_tasklist(new_lines)
old_info = self._parser(self.lines.values()) old_info = self.tasklist.parse_tasklist(self.lines.values())
same_layout = ( same_layout = (
len(old_info) == len(taskinfo) len(old_info) == len(taskinfo)
and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info)) and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info))
@@ -231,30 +175,7 @@ class BulkEditor(LeoModal):
await self.tasklist.update_tasklist(deleted_at=now) await self.tasklist.update_tasklist(deleted_at=now)
# Create tasklist # Create tasklist
created = {} await self.tasklist.write_taskinfo(taskinfo)
target_depth = 0
while True:
to_insert = {}
for i, (parent, truedepth, ticked, content) in enumerate(taskinfo):
if truedepth == target_depth:
to_insert[i] = (
self.tasklist.userid,
content,
created[parent] if parent is not None else None,
now if ticked else None
)
if to_insert:
# Batch insert
tasks = await self.tasklist.data.Task.table.insert_many(
('userid', 'content', 'parentid', 'completed_at'),
*to_insert.values()
)
for i, task in zip(to_insert.keys(), tasks):
created[i] = task['taskid']
target_depth += 1
else:
# Reached maximum depth
break
class UIMode(Enum): class UIMode(Enum):
@@ -265,7 +186,7 @@ class UIMode(Enum):
), ),
_p( _p(
'ui:tasklist|menu:sub|mode:toggle|placeholder', 'ui:tasklist|menu:sub|mode:toggle|placeholder',
"Task '{label}' subtasks:" "Toggle from {label}.*"
), ),
) )
EDIT = ( EDIT = (
@@ -275,7 +196,7 @@ class UIMode(Enum):
), ),
_p( _p(
'ui:tasklist|menu:sub|mode:edit|placeholder', 'ui:tasklist|menu:sub|mode:edit|placeholder',
"Task '{label}' subtasks:" "Edit from {label}.*"
), ),
) )
DELETE = ( DELETE = (
@@ -285,7 +206,7 @@ class UIMode(Enum):
), ),
_p( _p(
'ui:tasklist|menu:sub|mode:delete|placeholder', 'ui:tasklist|menu:sub|mode:delete|placeholder',
"Task '{label}' subtasks:" "Delete from {label}.*"
), ),
) )
@@ -333,6 +254,10 @@ class TasklistUI(BasePager):
self.set_active() self.set_active()
@property
def this_page(self):
return self._pages[self.page_num % len(self._pages)] if self._pages else []
# ----- UI API ----- # ----- UI API -----
@classmethod @classmethod
def fetch(cls, tasklist, channel, *args, **kwargs): def fetch(cls, tasklist, channel, *args, **kwargs):
@@ -440,14 +365,14 @@ class TasklistUI(BasePager):
lines.append(taskline) lines.append(taskline)
return "```md\n{}```".format('\n'.join(lines)) return "```md\n{}```".format('\n'.join(lines))
def _format_options(self, task_block) -> list[SelectOption]: def _format_options(self, task_block, make_default: Optional[int] = None) -> list[SelectOption]:
options = [] options = []
for lbl, task in task_block: for lbl, task in task_block:
value = str(task.taskid) value = str(task.taskid)
lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1) lblstr = '.'.join(map(str, lbl)) + '.' * (len(lbl) == 1)
name = f"{lblstr} {task.content[:100 - len(lblstr) - 1]}" name = f"{lblstr} {task.content[:100 - len(lblstr) - 1]}"
emoji = unchecked_emoji if task.completed_at is None else checked_emoji emoji = unchecked_emoji if task.completed_at is None else checked_emoji
options.append(SelectOption(label=name, value=value, emoji=emoji)) options.append(SelectOption(label=name, value=value, emoji=emoji, default=(task.taskid == make_default)))
return options return options
def _format_parent(self, parentid) -> str: def _format_parent(self, parentid) -> str:
@@ -602,7 +527,7 @@ class TasklistUI(BasePager):
menu = self.main_menu menu = self.main_menu
menu.placeholder = t(self.mode.main_placeholder) menu.placeholder = t(self.mode.main_placeholder)
block = self._pages[self.page_num % len(self._pages)] block = self.this_page
options = self._format_options(block) options = self._format_options(block)
menu.options = options menu.options = options
@@ -637,7 +562,7 @@ class TasklistUI(BasePager):
for label, taskid in labelled.items() for label, taskid in labelled.items()
if all(i == j for i, j in zip(label, rootlabel)) if all(i == j for i, j in zip(label, rootlabel))
} }
this_page = self._pages[self.page_num % len(self._pages)] this_page = self.this_page
if len(children) <= 25: if len(children) <= 25:
# Show all the children even if they don't display on the page # Show all the children even if they don't display on the page
block = list(children.items()) block = list(children.items())
@@ -738,12 +663,24 @@ class TasklistUI(BasePager):
async def editor_callback(interaction: discord.Interaction): async def editor_callback(interaction: discord.Interaction):
self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False) self.bot.dispatch('tasklist_update', userid=self.userid, channel=self.channel, summon=False)
if sum(len(line) for line in editor.lines.values()) + len(editor.lines) >= 4000:
await press.response.send_message(
embed=discord.Embed(
colour=discord.Colour.brand_red(),
description=self.bot.translator.t(_p(
'ui:tasklist|button:edit_bulk|error:too_long',
"Your tasklist is too long to be edited in a Discord text input! "
"Use the save button and {cmds[tasks upload]} instead."
)).format(cmds=self.bot.core.mention_cache)
),
ephemeral=True
)
else:
await press.response.send_modal(editor) await press.response.send_modal(editor)
async def edit_bulk_button_refresh(self): async def edit_bulk_button_refresh(self):
t = self.bot.translator.t t = self.bot.translator.t
button = self.edit_bulk_button button = self.edit_bulk_button
button.disabled = (len(self.labelled) == 0)
button.label = t(_p( button.label = t(_p(
'ui:tasklist|button:edit_bulk|label', 'ui:tasklist|button:edit_bulk|label',
"Bulk Edit" "Bulk Edit"
@@ -772,8 +709,7 @@ class TasklistUI(BasePager):
await press.response.defer(thinking=True, ephemeral=True) await press.response.defer(thinking=True, ephemeral=True)
# Build the tasklist file # Build the tasklist file
# Lazy way of getting the tasklist contents = '\n'.join(self.tasklist.flatten().values())
contents = BulkEditor(self.tasklist).tasklist_editor.default
with StringIO(contents) as fp: with StringIO(contents) as fp:
fp.seek(0) fp.seek(0)
file = discord.File(fp, filename='tasklist.md') file = discord.File(fp, filename='tasklist.md')
@@ -895,15 +831,18 @@ class TasklistUI(BasePager):
) )
if self._pages: if self._pages:
page = self._pages[page_id % len(self._pages)] page = self.this_page
block = self._format_page(page) block = self._format_page(page)
embed.description = "{task_block}".format(task_block=block) embed.description = "{task_block}".format(task_block=block)
else: else:
embed.description = t(_p( embed.description = t(_p(
'ui:tasklist|embed|description', 'ui:tasklist|embed|description',
"**You have no tasks on your tasklist!**\n" "**You have no tasks on your tasklist!**\n"
"Add a task with `/tasklist new`, or by pressing the `New` button below." "Add a task with {cmds[tasks new]}, or by pressing the {new_button} button below."
)) )).format(
cmds=self.bot.core.mention_cache,
new_button=conf.emojis.task_new
)
page_args = MessageArgs(embed=embed) page_args = MessageArgs(embed=embed)
return page_args return page_args
@@ -945,6 +884,9 @@ class TasklistUI(BasePager):
self.refresh_pages() self.refresh_pages()
async def refresh_components(self): async def refresh_components(self):
if not self.labelled:
self.mode = UIMode.TOGGLE
await asyncio.gather( await asyncio.gather(
self.main_menu_refresh(), self.main_menu_refresh(),
self.sub_menu_refresh(), self.sub_menu_refresh(),
@@ -986,13 +928,12 @@ class TasklistUI(BasePager):
action_row, action_row,
main_row, main_row,
sub_row, sub_row,
(self.save_button, self.refresh_button, self.quit_pressed) (self.save_button, self.refresh_button, self.quit_button)
) )
else: else:
# No tasks # No tasks
self._layout = ( self._layout = (
action_row, (self.new_button, self.edit_bulk_button, self.refresh_button, self.quit_button),
(self.refresh_button, self.quit_pressed)
) )
async def redraw(self, interaction: Optional[discord.Interaction] = None): async def redraw(self, interaction: Optional[discord.Interaction] = None):