rewrite: Restructure to include GUI.

This commit is contained in:
2022-12-23 06:44:32 +02:00
parent 2b93354248
commit f328324747
224 changed files with 8 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import logging
from babel.translator import LocalBabel
babel = LocalBabel('tasklist')
logger = logging.getLogger(__name__)
async def setup(bot):
from .cog import TasklistCog
await bot.add_cog(TasklistCog(bot))

762
src/modules/tasklist/cog.py Normal file
View File

@@ -0,0 +1,762 @@
from typing import Optional
import datetime as dt
import discord
from discord.ext import commands as cmds
from discord import app_commands as appcmds
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 data import Condition, NULL
from wards import low_management
from . import babel, logger
from .data import TasklistData
from .tasklist import Tasklist
from .ui import TasklistUI, SingleEditor, BulkEditor
from .settings import TasklistSettings, TasklistConfigUI
_p = babel._p
MAX_LENGTH = 100
class BeforeSelection(ChoicedEnum):
"""
Set of choices for the before arguments of `remove`.
"""
HOUR = _p('argtype:Before|opt:HOUR', "The last hour")
HALFDAY = _p('argtype:Before|opt:HALFDAY', "The last 12 hours")
DAY = _p('argtype:Before|opt:DAY', "The last 24 hours")
TODAY = _p('argtype:Before|opt:TODAY', "Today")
YESTERDAY = _p('argtype:Before|opt:YESTERDAY', "Yesterday")
MONDAY = _p('argtype:Before|opt:Monday', "This Monday")
THISMONTH = _p('argtype:Before|opt:THISMONTH', "This Month")
@property
def choice_name(self):
return self.value
@property
def choice_value(self):
return self.name
def needs_timezone(self):
return self in (
BeforeSelection.TODAY,
BeforeSelection.YESTERDAY,
BeforeSelection.MONDAY,
BeforeSelection.THISMONTH
)
def cutoff(self, timezone):
"""
Cut-off datetime for this period, in the given timezone.
"""
now = dt.datetime.now(tz=timezone)
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if self is BeforeSelection.HOUR:
return now - dt.timedelta(hours=1)
elif self is BeforeSelection.HALFDAY:
return now - dt.timedelta(hours=12)
elif self is BeforeSelection.DAY:
return now - dt.timedelta(hours=24)
elif self is BeforeSelection.TODAY:
time = day_start
elif self is BeforeSelection.YESTERDAY:
time = day_start - dt.timedelta(days=1)
elif self is BeforeSelection.MONDAY:
time = day_start - dt.timedelta(days=now.weekday)
elif self is BeforeSelection.THISMONTH:
time = day_start.replace(day=0)
return time
class TasklistCog(LionCog):
"""
Command cog for the tasklist module.
All tasklist modification commands will summon the
member's TasklistUI, if currently in a tasklist-enabled channel,
or in a rented room channel (TODO).
Commands
--------
/tasklist open
Summon the TasklistUI panel for the current member.
/tasklist new <task> [parent:str]
Create a new task and add it to the tasklist.
/tasklist edit [taskid:int] [content:str] [parent:str]
With no arguments, opens up the task editor modal.
With only `taskid` given, opens up a single modal editor for that task.
With both `taskid` and `content` given, updates the given task content.
If only `content` is given, errors.
/tasklist clear
Clears the tasklist, after confirmation.
/tasklist remove [taskids:ranges] [created_before:dur] [updated_before:dur] [completed:bool] [cascade:bool]
Remove tasks described by a sequence of conditions.
Duration arguments use a time selector menu rather than a Duration type.
With no arguments, acts like `clear`.
/tasklist tick <taskids:ranges> [cascade:bool]
Tick a selection of taskids, accepting ranges.
/tasklist untick <taskids:ranges> [cascade:bool]
Untick a selection of taskids, accepting ranges.
Interface
---------
This cog does not expose a public interface.
Attributes
----------
bot: LionBot
The client which owns this Cog.
data: TasklistData
The tasklist data registry.
babel: LocalBabel
The LocalBabel instance for this module.
"""
depends = {'CoreCog', 'ConfigCog', 'Economy'}
def __init__(self, bot: LionBot):
self.bot = bot
self.data = bot.db.load_registry(TasklistData())
self.babel = babel
self.settings = TasklistSettings()
async def cog_load(self):
await self.data.init()
self.bot.core.guild_settings.attach(self.settings.task_reward)
self.bot.core.guild_settings.attach(self.settings.task_reward_limit)
# 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)
@LionCog.listener('on_tasks_completed')
async def reward_tasks_completed(self, member: discord.Member, *taskids: int):
conn = await self.bot.db.get_connection()
async with conn.transaction():
tasklist = await Tasklist.fetch(self.bot, self.data, member.id)
tasks = await tasklist.fetch_tasks(*taskids)
unrewarded = [task for task in tasks if not task.rewarded]
if unrewarded:
reward = (await self.settings.task_reward.get(member.guild.id)).value
limit = (await self.settings.task_reward_limit.get(member.guild.id)).value
ecog = self.bot.get_cog('Economy')
recent = await ecog.data.TaskTransaction.count_recent_for(member.id, member.guild.id) or 0
max_to_reward = max(limit-recent, 0)
to_reward = unrewarded[:max_to_reward]
count = len(to_reward)
amount = count * reward
await ecog.data.TaskTransaction.reward_completed(member.id, member.guild.id, count, amount)
await tasklist.update_tasks(*(task.taskid for task in to_reward), rewarded=True)
logger.debug(
f"Rewarded <uid: {member.id}> in <gid: {member.guild.id}> "
f"'{amount}' coins for completing '{count}' tasks."
)
@cmds.hybrid_group(
name=_p('group:tasklist', "tasklist")
)
async def tasklist_group(self, ctx: LionContext):
raise NotImplementedError
async def task_acmpl(self, interaction: discord.Interaction, partial: str) -> list[appcmds.Choice]:
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)
labels = []
for label, task in tasklist.labelled.items():
labelstring = '.'.join(map(str, label)) + '.' * (len(label) == 1)
taskstring = f"{labelstring} {task.content}"
labels.append((labelstring, taskstring))
matching = [(label, task) for label, task in labels if label.startswith(partial)]
if not matching:
matching = [(label, task) for label, task in labels if partial.lower() in task.lower()]
if not matching:
options = [
appcmds.Choice(
name=t(_p(
'argtype:taskid|error:no_matching',
"No tasks matching {partial}!",
)).format(partial=partial),
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)
@tasklist_group.command(
name=_p('cmd:tasklist_open', "open"),
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',
"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')
)
@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.')
)
async def tasklist_new_cmd(self, ctx: LionContext,
content: appcmds.Range[str, 1, MAX_LENGTH],
parent: Optional[str] = None):
t = self.bot.translator.t
if not ctx.interaction:
return
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
# Fetch parent task if required
pid = tasklist.parse_label(parent) if parent else None
if parent and pid is None:
# Could not parse
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."
)).format(input=parent)
),
)
return
# Create 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)
@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_group.command(
name=_p('cmd:tasklist_edit', "edit"),
description=_p(
'cmd:tasklist_edit|desc',
"Edit tasks 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"),
)
@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?"),
)
async def tasklist_edit_cmd(self, ctx: LionContext,
taskstr: str,
new_content: Optional[appcmds.Range[str, 1, MAX_LENGTH]] = None,
new_parent: Optional[str] = None):
t = self.bot.translator.t
if not ctx.interaction:
return
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
# Fetch task to edit
tid = tasklist.parse_label(taskstr) if taskstr else None
if tid is None:
# Could not parse
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."
)).format(input=taskstr)
),
ephemeral=True,
)
return
async def handle_update(interaction, new_content, new_parent):
# Parse new parent if given
pid = tasklist.parse_label(new_parent) if new_parent else None
if new_parent and not pid:
# Could not parse
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."
)).format(input=new_parent)
),
ephemeral=True
)
return
args = {}
if new_content:
args['content'] = new_content
if new_parent:
args['parentid'] = pid
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)
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]
parent_label = tasklist.labelid(task.parentid) if task.parentid else None
editor = SingleEditor(
title=t(_p('ui:tasklist_single_editor|title', "Edit Task"))
)
editor.task.default = task.content
editor.parent.default = tasklist.format_label(parent_label) if parent_label else None
@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_group.command(
name=_p('cmd:tasklist_clear', "clear"),
description=_p('cmd:tasklist_clear|desc', "Clear your tasklist.")
)
async def tasklist_clear_cmd(self, ctx: LionContext):
t = ctx.bot.translator.t
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
await tasklist.update_tasklist(deleted_at=utc_now())
await ctx.reply(
t(_p(
'cmd:tasklist_clear|resp:success',
"Your tasklist has been cleared."
)),
ephemeral=True
)
tasklistui = await TasklistUI.fetch(tasklist, ctx.channel, ctx.guild)
await tasklistui.summon()
@tasklist_group.command(
name=_p('cmd:tasklist_remove', "remove"),
description=_p(
'cmd:tasklist_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")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_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',
"Only delete tasks created before the selected time."
),
updated_before=_p(
'cmd:tasklist_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',
"Only delete tasks which are (not) complete."
),
cascade=_p(
'cmd:tasklist_remove|param:cascade',
"Whether to recursively remove subtasks of removed tasks."
)
)
async def tasklist_remove_cmd(self, ctx: LionContext,
taskidstr: str,
created_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None,
updated_before: Optional[Transformed[BeforeSelection, cmdopt.string]] = None,
completed: Optional[bool] = None,
cascade: bool = True):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
conditions = []
if taskidstr:
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
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
if created_before is not None:
conditions.append(self.data.Task.created_at <= created_before.cutoff(timezone))
if updated_before is not None:
conditions.append(self.data.Task.last_updated_at <= updated_before.cutoff(timezone))
if completed is True:
conditions.append(self.data.Task.completed_at != NULL)
elif completed is False:
conditions.append(self.data.Task.completed_at == NULL)
tasks = await self.data.Task.fetch_where(*conditions, userid=ctx.author.id)
if not tasks:
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_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]
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)
)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_group.command(
name=_p('cmd:tasklist_tick', "tick"),
description=_p('cmd:tasklist_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")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_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',
"Whether to also mark all subtasks as complete."
)
)
async def tasklist_tick_cmd(self, ctx: LionContext, taskidstr: str, cascade: bool = True):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
tasks = [tasklist.tasklist[taskid] for taskid in taskids]
tasks = [task for task in tasks if task.completed_at is None]
taskids = [task.taskid for task in tasks]
if taskids:
await tasklist.update_tasks(*taskids, cascade=cascade, completed_at=utc_now())
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)
)
await ctx.interaction.edit_original_response(embed=embed)
@tasklist_group.command(
name=_p('cmd:tasklist_untick', "untick"),
description=_p('cmd:tasklist_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")
)
@appcmds.describe(
taskidstr=_p(
'cmd:tasklist_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',
"Whether to also mark all subtasks as incomplete."
)
)
async def tasklist_untick_cmd(self, ctx: LionContext, taskidstr: str, cascade: Optional[bool] = False):
t = self.bot.translator.t
if not ctx.interaction:
return
await ctx.interaction.response.defer(thinking=True, ephemeral=True)
tasklist = await Tasklist.fetch(self.bot, self.data, ctx.author.id)
try:
taskids = tasklist.parse_labels(taskidstr)
except UserInputError as error:
await ctx.interaction.edit_original_response(
embed=error_embed(error.msg)
)
return
if not taskids:
# Explicitly error if none of the ranges matched
await ctx.interaction.edit_original_response(
embed=error_embed(
'cmd:tasklist_remove_cmd|error:no_matching',
"No tasks on your tasklist match `{input}`"
).format(input=taskidstr)
)
return
tasks = [tasklist.tasklist[taskid] for taskid in taskids]
tasks = [task for task in tasks if task.completed_at is not None]
taskids = [task.taskid for task in tasks]
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)
)
await ctx.interaction.edit_original_response(embed=embed)
# Setting Commands
@LionCog.placeholder_group
@cmds.hybrid_group('configure', with_app_command=False)
async def configure_group(self, ctx: LionContext):
...
@configure_group.command(
name=_p('cmd:configure_tasklist', "tasklist"),
description=_p('cmd:configure_tasklist|desc', "Configuration panel")
)
@appcmds.rename(
reward=_p('cmd:configure_tasklist|param:reward', "reward"),
reward_limit=_p('cmd:configure_tasklist|param:reward_limit', "reward_limit")
)
@appcmds.describe(
reward=TasklistSettings.task_reward._desc,
reward_limit=TasklistSettings.task_reward_limit._desc
)
@appcmds.check(low_management)
async def configure_tasklist_cmd(self, ctx: LionContext,
reward: Optional[int] = None,
reward_limit: Optional[int] = None):
t = self.bot.translator.t
if not ctx.guild:
return
if not ctx.interaction:
return
task_reward = await self.settings.task_reward.get(ctx.guild.id)
task_reward_limit = await self.settings.task_reward_limit.get(ctx.guild.id)
# TODO: Batch properly
updated = False
if reward is not None:
task_reward.data = reward
await task_reward.write()
updated = True
if reward_limit is not None:
task_reward_limit.data = reward_limit
await task_reward_limit.write()
updated = True
# Send update ack if required
if updated:
description = t(_p(
'cmd:configure_tasklist|resp:success|desc',
"Members will now be rewarded {coin}**{amount}** for "
"each task they complete up to a maximum of `{limit}` tasks per 24h."
)).format(
coin=self.bot.config.emojis.coin,
amount=task_reward.data,
limit=task_reward_limit.data
)
await ctx.reply(
embed=discord.Embed(
colour=discord.Colour.brand_green(),
description=description
)
)
if ctx.channel.id not in TasklistConfigUI._listening or not ctx.interaction.response.is_done():
# Launch setting group UI
configui = TasklistConfigUI(self.bot, self.settings, ctx.guild.id, ctx.channel.id)
await configui.run(ctx.interaction)
await configui.wait()

View File

@@ -0,0 +1,45 @@
from psycopg import sql
from data import RowModel, Registry, Table
from data.columns import Integer, String, Timestamp, Bool
class TasklistData(Registry):
class Task(RowModel):
"""
Row model describing a single task in a tasklist.
Schema
------
CREATE TABLE tasklist(
taskid SERIAL PRIMARY KEY,
userid BIGINT NOT NULL REFERENCES user_config ON DELETE CASCADE,
parentid INTEGER REFERENCES tasklist (taskid) ON DELETE SET NULL,
content TEXT NOT NULL,
rewarded BOOL DEFAULT FALSE,
deleted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
last_updated_at TIMESTAMPTZ
);
CREATE INDEX tasklist_users ON tasklist (userid);
CREATE TABLE tasklist_channels(
guildid BIGINT NOT NULL REFERENCES guild_config (guildid) ON DELETE CASCADE,
channelid BIGINT NOT NULL
);
CREATE INDEX tasklist_channels_guilds ON tasklist_channels (guildid);
"""
_tablename_ = "tasklist"
taskid = Integer(primary=True)
userid = Integer()
parentid = Integer()
rewarded = Bool()
content = String()
completed_at = Timestamp()
created_at = Timestamp()
deleted_at = Timestamp()
last_updated_at = Timestamp()
channels = Table('tasklist_channels')

View File

@@ -0,0 +1,280 @@
from typing import Optional
import discord
from discord.ui.select import select, Select, SelectOption, ChannelSelect
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from settings import ListData, ModelData
from settings.setting_types import StringSetting, BoolSetting, ChannelListSetting, IntegerSetting
from settings.groups import SettingGroup
from meta import conf, LionBot
from utils.lib import tabulate
from utils.ui import LeoUI, FastModal, error_handler_for, ModalRetryUI
from core.data import CoreData
from babel.translator import ctx_translator
from . import babel
from .data import TasklistData
_p = babel._p
class TasklistSettings(SettingGroup):
class task_reward(ModelData, IntegerSetting):
"""
Guild configuration for the task completion economy award.
Exposed via `/configure tasklist`, and the standard configuration interface.
"""
setting_id = 'task_reward'
_display_name = _p('guildset:task_reward', "task_reward")
_desc = _p(
'guildset:task_reward|desc',
"Number of LionCoins given for each completed task."
)
_long_desc = _p(
'guildset:task_reward|long_desc',
"The number of coins members will be rewarded each time they complete a task on their tasklist."
)
_default = 50
_model = CoreData.Guild
_column = CoreData.Guild.task_reward.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:task_reward|response',
"Members will now be rewarded {coin}**{amount}** for each completed task."
)).format(coin=conf.emojis.coin, amount=self.data)
@property
def set_str(self):
return '</configure tasklist:1038560947666694144>'
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
if data is not None:
return "{coin}**{amount}** per task.".format(
coin=conf.emojis.coin,
amount=data
)
class task_reward_limit(ModelData, IntegerSetting):
setting_id = 'task_reward_limit'
_display_name = _p('guildset:task_reward_limit', "task_reward_limit")
_desc = _p(
'guildset:task_reward_limit|desc',
"Maximum number of task rewards given per 24h."
)
_long_desc = _p(
'guildset:task_reward_limit|long_desc',
"Maximum number of times in each 24h period that members will be rewarded "
"for completing a task."
)
_default = 10
_model = CoreData.Guild
_column = CoreData.Guild.task_reward_limit.name
@property
def update_message(self):
t = ctx_translator.get().t
return t(_p(
'guildset:task_reward_limit|response',
"Members will now be rewarded for task completion at most **{amount}** times per 24h."
)).format(amount=self.data)
@property
def set_str(self):
return '</configure tasklist:1038560947666694144>'
@classmethod
def _format_data(cls, parent_id, data, **kwargs):
if data is not None:
return "`{number}` per 24 hours.".format(
number=data
)
class tasklist_channels(ListData, ChannelListSetting):
setting_id = 'tasklist_channels'
_display_name = _p('guildset:tasklist_channels', "tasklist_channels")
_desc = _p(
'guildset:tasklist_channels|desc',
"Channels in which to allow the tasklist."
)
_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."
)
_default = None
_table_interface = TasklistData.channels
_id_column = 'guildid'
_data_column = 'channelid'
_order_column = 'channelid'
_cache = {}
@property
def set_str(self):
return "Channel selector below."
class TasklistConfigUI(LeoUI):
# TODO: Back option to globall guild config
# TODO: Cohesive edit
_listening = {}
def __init__(self, bot: LionBot, settings: TasklistSettings, guildid: int, channelid: int,**kwargs):
super().__init__(**kwargs)
self.bot = bot
self.settings = settings
self.guildid = guildid
self.channelid = channelid
# Original interaction, used when the UI runs as an initial interaction response
self._original: Optional[discord.Interaction] = None
# UI message, used when UI run as a followup message
self._message: Optional[discord.Message] = None
self.task_reward = None
self.task_reward_limit = None
self.tasklist_channels = None
self.embed: Optional[discord.Embed] = None
self.set_labels()
@property
def instances(self):
return (self.task_reward, self.task_reward_limit, self.tasklist_channels)
@button(label='CLOSE_PLACEHOLDER')
async def close_pressed(self, interaction: discord.Interaction, pressed):
"""
Close the configuration UI.
"""
try:
if self._message:
await self._message.delete()
self._message = None
elif self._original:
await self._original.delete_original_response()
self._original = None
except discord.HTTPException:
pass
await self.close()
@button(label='RESET_PLACEHOLDER')
async def reset_pressed(self, interaction: discord.Interaction, pressed):
"""
Reset the tasklist configuration.
"""
await interaction.response.defer()
self.task_reward.data = None
await self.task_reward.write()
self.task_reward_limit.data = None
await self.task_reward_limit.write()
self.tasklist_channels.data = None
await self.tasklist_channels.write()
await self.refresh()
await self.redraw()
@select(cls=ChannelSelect, placeholder="CHANNEL_SELECTOR_PLACEHOLDER", min_values=0, max_values=25)
async def channels_selected(self, interaction: discord.Interaction, selected: Select):
"""
Multi-channel selector to select the tasklist channels in the Guild.
Allows any channel type.
Selected category channels will apply to their children.
"""
await interaction.response.defer()
self.tasklist_channels.value = selected.values
await self.tasklist_channels.write()
await self.refresh()
await self.redraw()
async def cleanup(self):
self._listening.pop(self.channelid, None)
self.task_reward.deregister_callback(self.id)
self.task_reward_limit.deregister_callback(self.id)
try:
if self._original is not None:
await self._original.delete_original_response()
self._original = None
if self._message is not None:
await self._message.delete()
self._message = None
except discord.HTTPException:
pass
async def run(self, interaction: discord.Interaction):
if old := self._listening.get(self.channelid, None):
await old.close()
await self.refresh()
if interaction.response.is_done():
# Use followup
self._message = await interaction.followup.send(embed=self.embed, view=self)
else:
# Use interaction response
self._original = interaction
await interaction.response.send_message(embed=self.embed, view=self)
self.task_reward.register_callback(self.id)(self.reload)
self.task_reward_limit.register_callback(self.id)(self.reload)
self._listening[self.channelid] = self
async def reload(self, *args, **kwargs):
await self.refresh()
await self.redraw()
async def refresh(self):
self.task_reward = await self.settings.task_reward.get(self.guildid)
self.task_reward_limit = await self.settings.task_reward_limit.get(self.guildid)
self.tasklist_channels = await self.settings.tasklist_channels.get(self.guildid)
self._layout = [
(self.channels_selected,),
(self.reset_pressed, self.close_pressed)
]
self.embed = await self.make_embed()
def set_labels(self):
t = self.bot.translator.t
self.close_pressed.label = t(_p('ui:tasklist_config|button:close|label', "Close"))
self.reset_pressed.label = t(_p('ui:tasklist_config|button:reset|label', "Reset"))
self.channels_selected.placeholder = t(_p(
'ui:tasklist_config|menu:channels|placeholder',
"Set Tasklist Channels"
))
async def redraw(self):
try:
if self._message:
await self._message.edit(embed=self.embed, view=self)
elif self._original:
await self._original.edit_original_response(embed=self.embed, view=self)
except discord.HTTPException:
pass
async def make_embed(self):
t = self.bot.translator.t
embed = discord.Embed(
colour=discord.Colour.orange(),
title=t(_p(
'ui:tasklist_config|embed|title',
"Tasklist Configuration Panel"
))
)
for setting in self.instances:
embed.add_field(**setting.embed_field, inline=False)
return embed

View File

@@ -0,0 +1,259 @@
from typing import Optional
from weakref import WeakValueDictionary
import re
from meta import LionBot
from meta.errors import UserInputError
from utils.lib import utc_now
from .data import TasklistData
class Tasklist:
"""
Class representing a single user's tasklist.
Attributes
----------
bot: LionBot
Client which controls this tasklist.
data: TasklistData
Initialised tasklist data registry.
userid: int
The user who owns this tasklist.
tasklist: dict[int, TasklistData.Task]
A local cache map of tasks the user owns.
May or may not contain deleted tasks.
"""
_cache_ = WeakValueDictionary()
label_range_re = re.compile(
r"^(?P<start>(\d+\.)*\d+)\.?((\s*(?P<range>-)\s*)(?P<end>(\d+\.)*\d*\.?))?$"
)
def __init__(self, bot: LionBot, data: TasklistData, userid: int):
self.bot = bot
self.data = data
self.userid = userid
self.tasklist: dict[int, TasklistData.Task] = {}
@classmethod
async def fetch(cls, bot: LionBot, data: TasklistData, userid: int) -> 'Tasklist':
"""
Fetch and initialise a Tasklist, using cache where possible.
"""
if userid not in cls._cache_:
cls = cls(bot, data, userid)
await cls.refresh()
cls._cache_[userid] = cls
return cls._cache_[userid]
def _label(self, task, taskmap, labels, counters) -> tuple[int, ...]:
tid = task.taskid
if tid in labels:
label = labels[tid]
else:
pid = task.parentid
counters[pid] = i = counters.get(pid, 0) + 1
if pid is not None and (parent := taskmap.get(pid, None)) is not None:
plabel = self._label(parent, taskmap, labels, counters)
else:
plabel = ()
labels[tid] = label = (*plabel, i)
return label
@property
def labelled(self) -> dict[tuple[int, ...], TasklistData.Task]:
"""
A sorted map of task string ids to tasks.
This is the tasklist that is visible to the user.
"""
taskmap = {
task.taskid: task
for task in sorted(self.tasklist.values(), key=lambda t: t.taskid)
if task.deleted_at is None
}
labels = {}
counters = {}
for task in taskmap.values():
self._label(task, taskmap, labels, counters)
labelmap = {
label: taskmap[taskid]
for taskid, label in sorted(labels.items(), key=lambda lt: lt[1])
}
return labelmap
def labelid(self, taskid) -> Optional[tuple[int, ...]]:
"""
Relatively expensive method to get the label for a given task, if it exists.
"""
task = self.tasklist.get(taskid, None)
if task is None:
return None
labelled = self.labelled
mapper = {t.taskid: label for label, t in labelled.items()}
return mapper[taskid]
async def refresh(self):
"""
Update the `tasklist` from data.
"""
tasks = await self.data.Task.fetch_where(userid=self.userid, deleted_at=None)
self.tasklist = {task.taskid: task for task in tasks}
async def _owner_check(self, *taskids: int) -> bool:
"""
Check whether all of the given tasks are owned by this tasklist user.
Applies cache where possible.
"""
missing = [tid for tid in taskids if tid not in self.tasklist]
if missing:
missing = [tid for tid in missing if (tid, ) not in self.data.Task._cache_]
if missing:
tasks = await self.data.Task.fetch_where(taskid=missing)
missing = [task.taskid for task in tasks if task.userid != self.userid]
return not bool(missing)
async def fetch_tasks(self, *taskids: int) -> list[TasklistData.Task]:
"""
Fetch the tasks from the tasklist with the given taskids.
Raises a ValueError if the tasks are not owned by the tasklist user.
"""
# Check the tasklist user owns all the tasks
# Also ensures the Row objects are in cache
if not await self._owner_check(*taskids):
raise ValueError("The given tasks are not in this tasklist!")
return [await self.data.Task.fetch(tid) for tid in taskids]
async def create_task(self, content: str, **kwargs) -> TasklistData.Task:
"""
Create a new task with the given content.
"""
task = await self.data.Task.create(userid=self.userid, content=content, **kwargs)
self.tasklist[task.taskid] = task
return task
async def update_tasks(self, *taskids: int, cascade=False, **kwargs):
"""
Update the given taskids with the provided new values.
If `cascade` is True, also applies the updates to all children.
"""
if not taskids:
raise ValueError("No tasks provided to update.")
if cascade:
taskids = self.children_cascade(*taskids)
# Ensure the taskids exist and belong to this user
await self.fetch_tasks(*taskids)
# Update the tasks
kwargs.setdefault('last_updated_at', utc_now())
tasks = await self.data.Task.table.update_where(
userid=self.userid,
taskid=taskids,
).set(**kwargs)
# Return the updated tasks
return tasks
async def update_tasklist(self, **kwargs):
"""
Update every task in the tasklist, regardless of cache.
"""
kwargs.setdefault('last_updated_at', utc_now())
tasks = await self.data.Task.table.update_where(userid=self.userid).set(**kwargs)
return tasks
def children_cascade(self, *taskids) -> list[int]:
"""
Return the provided taskids with all their descendants.
Only checks the current tasklist cache for descendants.
"""
taskids = set(taskids)
added = True
while added:
added = False
for task in self.tasklist.values():
if task.deleted_at is None and task.taskid not in taskids and task.parentid in taskids:
taskids.add(task.taskid)
added = True
return list(taskids)
def parse_label(self, labelstr: str) -> Optional[int]:
"""
Parse a provided label string into a taskid, if it can be found.
Returns None if no matching taskids are found.
"""
splits = [s for s in labelstr.split('.') if s]
if all(split.isdigit() for split in splits):
tasks = self.labelled
label = tuple(map(int, splits))
if label in tasks:
return tasks[label].taskid
def format_label(self, label: tuple[int, ...]) -> str:
"""
Format the provided label tuple into the standard number format.
"""
return '.'.join(map(str, label)) + '.' * (len(label) == 1)
def parse_labels(self, labelstr: str) -> Optional[list[str]]:
"""
Parse a comma separated list of labels and label ranges into a list of labels.
E.g. `1, 2, 3`, `1, 2-5, 7`, `1, 2.1, 3`, `1, 2.1-3`, `1, 2.1-`
May raise `UserInputError`.
"""
labelmap = {label: task.taskid for label, task in self.labelled.items()}
splits = labelstr.split(',')
splits = [split.strip(' ,.') for split in splits]
splits = [split for split in splits if split]
taskids = set()
for split in splits:
match = self.label_range_re.match(split)
if match:
start = match['start']
ranged = match['range']
end = match['end']
start_label = tuple(map(int, start.split('.')))
head = start_label[:-1]
start_tail = start_label[-1]
if end:
end_label = tuple(map(int, end.split('.')))
end_tail = end_label[-1]
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}`")
for tail in range(max(start_tail, 1), end_tail + 1):
label = (*head, tail)
if label not in labelmap:
break
taskids.add(labelmap[label])
elif ranged:
# No end but still ranged
for label, taskid in labelmap.items():
if (label[:-1] == head) and (label[-1] >= start_tail):
taskids.add(taskid)
elif start_label in labelmap:
taskids.add(labelmap[start_label])
else:
# Error
raise UserInputError("Could not parse `{range}` as a task number or range.")
return list(taskids)

656
src/modules/tasklist/ui.py Normal file
View File

@@ -0,0 +1,656 @@
from typing import Optional
import re
import discord
from discord.ui.select import select, Select, SelectOption
from discord.ui.button import button, Button, ButtonStyle
from discord.ui.text_input import TextInput, TextStyle
from meta import conf
from meta.errors import UserInputError
from utils.lib import MessageArgs, utc_now
from utils.ui import LeoUI, LeoModal, FastModal, error_handler_for, ModalRetryUI
from utils.ui.pagers import BasePager, Pager
from babel.translator import ctx_translator
from . import babel, logger
from .tasklist import Tasklist
from .data import TasklistData
_p = babel._p
checkmark = ""
checked_emoji = conf.emojis.task_checked
unchecked_emoji = conf.emojis.task_unchecked
class SingleEditor(FastModal):
task: TextInput = TextInput(
label='',
max_length=100,
required=True
)
def setup_task(self):
t = ctx_translator.get().t
self.task.label = t(_p('modal:tasklist_single_editor|field:task|label', "Task content"))
parent: TextInput = TextInput(
label='',
max_length=10,
required=False
)
def setup_parent(self):
t = ctx_translator.get().t
self.parent.label = t(_p(
'modal:tasklist_single_editor|field:parent|label',
"Parent Task"
))
self.parent.placeholder = t(_p(
'modal:tasklist_single_editor|field:parent|placeholder',
"Enter a task number, e.g. 2.1"
))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setup()
def setup(self):
self.setup_task()
self.setup_parent()
@error_handler_for(UserInputError)
async def rerequest(self, interaction: discord.Interaction, error: UserInputError):
await ModalRetryUI(self, error.msg).respond_to(interaction)
class BulkEditor(LeoModal):
"""
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(
label='',
style=TextStyle.long,
max_length=4000,
required=False
)
def setup_tasklist_editor(self):
t = ctx_translator.get().t
self.tasklist_editor.label = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|label', "Tasklist"
))
self.tasklist_editor.placeholder = t(_p(
'modal:tasklist_bulk_editor|field:tasklist|placeholder',
"- [ ] This is task 1, unfinished.\n"
"- [x] This is task 2, finished.\n"
" - [ ] This is subtask 2.1."
))
def __init__(self, tasklist: Tasklist, **kwargs):
self.setup()
super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot
self.userid = tasklist.userid
self.lines = self.format_tasklist()
self.tasklist_editor.default = '\n'.join(self.lines.values())
self._callbacks = []
def setup(self):
t = ctx_translator.get().t
self.title = t(_p(
'modal:tasklist_bulk_editor', "Tasklist Editor"
))
self.setup_tasklist_editor()
def add_callback(self, coro):
self._callbacks.append(coro)
return coro
def format_tasklist(self):
"""
Format the tasklist into lines of editable text.
"""
labelled = self.tasklist.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):
try:
await self.parse_editor()
for coro in self._callbacks:
await coro(interaction)
await interaction.response.defer()
except UserInputError as error:
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):
# First parse each line
new_lines = self.tasklist_editor.value.splitlines()
taskinfo = self._parser(new_lines)
old_info = self._parser(self.lines.values())
same_layout = (
len(old_info) == len(taskinfo)
and all(info[:2] == oldinfo[:2] for (info, oldinfo) in zip(taskinfo, old_info))
)
# TODO: Incremental/diff editing
conn = await self.bot.db.get_connection()
async with conn.transaction():
now = utc_now()
if same_layout:
# if the layout has not changed, just edit the tasks
for taskid, (oldinfo, newinfo) in zip(self.lines.keys(), zip(old_info, taskinfo)):
args = {}
if oldinfo[2] != newinfo[2]:
args['completed_at'] = now if newinfo[2] else None
if oldinfo[3] != newinfo[3]:
args['content'] = newinfo[3]
if args:
await self.tasklist.update_tasks(taskid, **args)
else:
# Naive implementation clearing entire tasklist
# Clear tasklist
await self.tasklist.update_tasklist(deleted_at=now)
# Create 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] = (
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 TasklistUI(BasePager):
"""
Paged UI panel for managing the tasklist.
"""
# Cache of live tasklist widgets
# (channelid, userid) -> Tasklist
_live_ = {}
def __init__(self,
tasklist: Tasklist,
channel: discord.abc.Messageable, guild: Optional[discord.Guild] = None, **kwargs):
kwargs.setdefault('timeout', 3600)
super().__init__(**kwargs)
self.tasklist = tasklist
self.bot = tasklist.bot
self.userid = tasklist.userid
self.channel = channel
self.guild = guild
# List of lists of (label, task) pairs
self._pages = []
self.page_num = -1
self._channelid = channel.id
self.current_page = None
self._deleting = False
self._message: Optional[discord.Message] = None
self.button_labels()
self.set_active()
@classmethod
async def fetch(cls, tasklist, channel, *args, **kwargs):
key = (channel.id, tasklist.userid)
if key not in cls._live_:
self = cls(tasklist, channel, *args, **kwargs)
cls._live_[key] = self
return cls._live_[key]
def access_check(self, userid):
return userid == self.userid
async def interaction_check(self, interaction: discord.Interaction):
t = self.bot.translator.t
if not self.access_check(interaction.user.id):
embed = discord.Embed(
description=t(_p(
'ui:tasklist|error:wrong_user',
"This is not your tasklist!"
)),
colour=discord.Colour.brand_red()
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return False
else:
return True
async def cleanup(self):
self.set_inactive()
self._live_.pop((self.channel.id, self.userid), None)
if self._message is not None:
try:
await self._message.edit(view=None)
except discord.HTTPException:
pass
self._message = None
try:
if self._message is not None:
await self._message.edit(view=None)
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 += len(message.content.count('\n'))
return False
return False
async def get_page(self, page_id) -> MessageArgs:
t = self.bot.translator.t
tasks = [t for t in self.tasklist.tasklist.values() if t.deleted_at is None]
total = len(tasks)
completed = sum(t.completed_at is not None for t in tasks)
if self.guild:
user = self.guild.get_member(self.userid)
else:
user = self.bot.get_user(self.userid)
user_name = user.name if user else str(self.userid)
user_colour = user.colour if user else discord.Color.orange()
author = t(_p(
'ui:tasklist|embed|author',
"{name}'s tasklist ({completed}/{total} complete)"
)).format(
name=user_name,
completed=completed,
total=total
)
embed = discord.Embed(
colour=user_colour,
)
embed.set_author(
name=author,
icon_url=user.avatar if user else None
)
if self._pages:
page = self._pages[page_id % len(self._pages)]
block = self._format_page(page)
embed.description = "{task_block}".format(task_block=block)
else:
embed.description = t(_p(
'ui:tasklist|embed|description',
"**You have no tasks on your tasklist!**\n"
"Add a task with `/tasklist new`, or by pressing the `New` button below."
))
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())
count = len(labelled)
pages = []
if count > 0:
# Break into pages
edges = [0]
line_ptr = 0
while line_ptr < count:
line_ptr += 20
if line_ptr < count:
# Seek backwards to find the best parent
i = line_ptr - 5
minlabel = (i, len(labelled[i][0]))
while i < line_ptr:
i += 1
ilen = len(labelled[i][0])
if ilen <= minlabel[1]:
minlabel = (i, ilen)
line_ptr = minlabel[0]
else:
line_ptr = count
edges.append(line_ptr)
pages = [labelled[edges[i]:edges[i+1]] for i in range(len(edges) - 1)]
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):
self.delete_selector.placeholder = t(_p('ui:tasklist|menu:delete|placeholder', "Select to Delete"))
self.delete_selector.options = 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.refresh_pages()
async def redraw(self):
self.current_page = await self.get_page(self.page_num)
# 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)
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,))
else:
# With no tasks, nothing to select
self._layout = [
(self.new_pressed, self.edit_pressed, self.close_pressed)
]
# Resend
if not self._message:
self._message = await self.channel.send(**self.current_page.send_args, view=self)
else:
await self._message.edit(**self.current_page.edit_args, view=self)