Add markdown support (and sanitization) for task descriptions

This also includes some initial work toward implementing task editing and deleting.

Closes #12
Related to #2
This commit is contained in:
digimint 2025-11-18 21:47:35 -06:00
parent 9bb625afe6
commit 47b1e6ca82
Signed by: digimint
GPG key ID: 8DF1C6FD85ABF748
14 changed files with 373 additions and 14 deletions

View file

@ -6,4 +6,8 @@
- wtforms (for parsing form data)
- psycopg2 (for postgresql)
- pyargon2 (for HashV1)
- humanize (for generating human-readable timedeltas)
- humanize (for generating human-readable timedeltas)
- nh3 (for markdown and general HTML sanitization)
## Already Included
- Certain tag definitions from bleach-allowlist, used in markdown sanitization.

View file

@ -8,6 +8,7 @@ from taskflower.config import SignUpMode, config
from taskflower.db import db
from taskflower.api import APIBase
from taskflower.db.model.user import User
from taskflower.sanitize.markdown import render_and_sanitize
from taskflower.web import web_base
from taskflower.tools.hibp import hibp_bp
@ -69,10 +70,14 @@ def template_utility_fns():
usr.enabled and usr.administrator
)
def render_as_markdown(raw: str):
return render_and_sanitize(raw)
return dict(
literal_call=literal_call,
reltime=reltime,
can_generate_sign_up_codes=can_generate_sign_up_codes
can_generate_sign_up_codes=can_generate_sign_up_codes,
render_as_markdown=render_as_markdown
)
@app.route('/')

View file

@ -34,4 +34,22 @@ class FormCreatesObjectWithUser[T](Form):
``AuthorizationError`` if ``current_user`` is not authorized to
create an object with the specified parameters.
'''
raise NotImplementedError
raise NotImplementedError
class FormEditsObjectWithUser[T](Form):
''' Trait that indicates that this ``Form`` can be used to edit an object
if provided with a ``User`` object.
'''
def edit_object(
self,
current_user: User, # pyright:ignore[reportUnusedParameter]
target_object: T # pyright:ignore[reportUnusedParameter]
) -> Either[Exception, T]:
''' Try to edit ``target_object`` on behalf of ``current_user``.
This function checks for authorization, and will return an
``AuthorizationError`` if ``current_user`` is not authorized to
edit an object with the specified parameters.
'''
raise NotImplementedError()

View file

@ -1,5 +1,5 @@
from typing import override
from wtforms import DateTimeLocalField, SelectField, StringField, ValidationError, validators
from wtforms import DateTimeLocalField, SelectField, StringField, TextAreaField, ValidationError, validators
from taskflower.auth.permission import NamespacePermissionType
from taskflower.auth.permission.checks import assert_user_perms_on_namespace
@ -8,10 +8,35 @@ from taskflower.db import db
from taskflower.db.model.namespace import Namespace
from taskflower.db.model.task import Task
from taskflower.db.model.user import User
from taskflower.form import FormCreatesObjectWithUser
from taskflower.form import FormCreatesObjectWithUser, FormEditsObjectWithUser
from taskflower.types.either import Either, Left, Right
from taskflower.types.option import Option
class TaskEditForm(FormEditsObjectWithUser[Task]):
name: StringField = StringField(
'Task Name',
[
validators.Length(
min=1,
max=64,
message='Task name must be between 1 and 64 characters!'
)
]
)
due: DateTimeLocalField = DateTimeLocalField(
'Due Date',
[
validators.DataRequired()
]
)
description: TextAreaField = TextAreaField(
'Description',
[
validators.Optional()
]
)
def task_form_for_user(
user: User
) -> type[FormCreatesObjectWithUser[Task]]:
@ -35,7 +60,10 @@ def task_form_for_user(
]
)
due: DateTimeLocalField = DateTimeLocalField(
'Due Date'
'Due Date',
[
validators.DataRequired()
]
)
namespace: SelectField = SelectField(
'Select a namespace',
@ -45,8 +73,8 @@ def task_form_for_user(
choices=namespace_choices,
coerce=int
)
description: StringField = StringField(
'Description',
description: TextAreaField = TextAreaField(
'Description (supports markdown)',
[
validators.Optional()
]

View file

@ -0,0 +1,50 @@
'''
These tag lists are adapted from the "Bleach-Allowlist" project, available at
https://github.com/yourcelf/bleach-allowlist.
They are incorporated here under the BSD 2-Clause Simplified license"
BSD 2-Clause License
Copyright (c) 2017, Charlie DeTar
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
# Tags suitable for rendering markdown
markdown_tags = {
"h1", "h2", "h3", "h4", "h5", "h6",
"b", "i", "strong", "em", "tt",
"p", "br",
"span", "div", "blockquote", "code", "pre", "hr",
"ul", "ol", "li", "dd", "dt",
"img",
"a",
"sub", "sup",
}
markdown_attrs = {
"*" : {"id"},
"img": {"src", "alt", "title"},
"a" : {"href", "alt", "title"},
}

View file

@ -0,0 +1,34 @@
import markdown
import nh3
from taskflower.sanitize.bleach_allowlist_md import markdown_attrs, markdown_tags
SafeHTML=str
def first_pass_escape(raw: str) -> str:
''' Perform basic HTML escaping by replacing < and > with &lt; and &rt;,
respectively.
'''
return raw.replace(
'<',
'&lt;'
).replace(
'>',
'&gt;'
)
def sanitize_markdown_render_result(render_res: str) -> SafeHTML:
safe: SafeHTML = nh3.clean(
render_res,
markdown_tags,
attributes=markdown_attrs
)
return safe
def render_and_sanitize(raw: str) -> SafeHTML:
return sanitize_markdown_render_result(
markdown.markdown(
first_pass_escape(raw)
)
)

View file

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32mm"
height="32mm"
viewBox="0 0 32 32"
version="1.1"
id="svg4219"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="delete.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview4221"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:snap-page="true"
inkscape:zoom="6.0007856"
inkscape:cx="60.492079"
inkscape:cy="59.992145"
inkscape:window-width="1920"
inkscape:window-height="1018"
inkscape:window-x="1600"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs4216" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 4.3442615,4.3442615 27.655737,27.655737"
id="path4313" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 27.655737,4.3442615 4.3442615,27.655737"
id="path4428" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="32mm"
height="32mm"
viewBox="0 0 32 32"
version="1.1"
id="svg5"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
sodipodi:docname="edit.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview7"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:pageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:document-units="mm"
showgrid="false"
inkscape:snap-bbox="true"
inkscape:snap-bbox-midpoints="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="true"
inkscape:snap-page="true"
inkscape:zoom="4.2431962"
inkscape:cx="59.860536"
inkscape:cy="57.03248"
inkscape:window-width="1680"
inkscape:window-height="988"
inkscape:window-x="3520"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1827"
showguides="false"
inkscape:snap-center="true"
inkscape:object-paths="true" />
<defs
id="defs2" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1827"
transform="matrix(0.97090565,0,0,0.95328037,1.2130415,-0.2769029)"
style="stroke-width:3.11833246;stroke-miterlimit:4;stroke-dasharray:none">
<path
id="rect1114"
style="fill:none;stroke:#ffffff;stroke-width:3.11833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none"
d="M 30.150425,6.2476261 25.752374,1.8495753 9.7345348,17.867413 6.6776561,25.788471 14.132586,22.265464 Z"
sodipodi:nodetypes="cccccc" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.11833;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 20.912356,6.5215018 25.17026,10.953753"
id="path1229" />
</g>
<path
style="fill:none;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 19.207329,2.6958367 -16.5114923,0 V 29.304162 H 29.304162 l 10e-7,-17.319006"
id="path1933"
sodipodi:nodetypes="ccccc" />
<path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 9.8263677,20.024836 8.4242261,23.592142 11.986204,21.939401 Z"
id="path3583"
sodipodi:nodetypes="cccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -63,7 +63,6 @@
.list .detail-view-elem .detail-view-header {
display: flex;
margin-bottom: 1rem;
}
.list .detail-view-elem .detail-view-header::before{

View file

@ -22,6 +22,13 @@
--form-bg-1: #331826;
--form-bg-2: #462837;
--on-form: #ffc4d1;
--btn-1: #7a3053;
--btn-1-border: #d77faa;
--on-btn-1: #ffffff;
--btn-1-hlt: var(--btn-1-border);
--on-btn-1-hlt: #462837;
}
html {
@ -134,4 +141,51 @@ h2 {
h3 {
font-size: large;
font-style: italic;
}
.link-tray {
display: flex;
flex-direction: row;
}
.link-btn {
display: flex;
flex-direction: row;
flex: 0 0;
background-color: var(--btn-1);
color: var(--on-btn-1);
width: max-content;
padding: 0.2rem 0.5rem;
border: 2px solid var(--btn-1-border);
border-radius: 0.25rem;
margin: 0.5rem;
transition: all 0.2s;
text-decoration: none;
&:hover {
background-color: var(--btn-1-hlt);
color: var(--on-btn-1-hlt);
}
&:first-child {
margin-left: 0;
}
&:last-child {
margin-right: 0;
}
img {
flex: 0 0;
max-height: 1rem;
margin: auto;
}
span {
flex: 1 0;
margin: auto;
font-weight: bold;
margin-left: 0.5rem;
width: max-content;
}
}

View file

@ -1,17 +1,21 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}Create Task{% endblock %}
{% block main_content %}
<h1>Create Task</h1>
<form id="create-task-form" method="POST">
<form class="default-form" id="create-task-form" method="POST">
<h1>Create Task</h1>
<dl>
{{ render_field(form.name) }}
{{ render_field(form.due) }}
{{ render_field(form.description) }}
{{ render_field(form.namespace) }}
</dl>
<p><input type="submit">Create Task</input></p>
<p><button type="submit">Create Task</button></p>
</form>
{% endblock %}

View file

@ -39,13 +39,17 @@
<div class="detail-view-header">
<h1>Task Details: {{ task.name }}</h1>
</div>
<div class="link-tray">
<a class="link-btn" href="{{url_for('web.task.edit', id=task.id, next=request.path)}}"><img src="{{url_for('static', filename='edit.svg')}}"/><span>Edit Task</span></a>
<a class="link-btn" href="{{url_for('web.task.delete', id=task.id, next=request.path)}}"><img src="{{url_for('static', filename='delete.svg')}}"/><span>Delete Task</span></a>
</div>
<p class="small-details">
Task ID: {{ task.id }}
<br/>Created: {{ task.created }} in namespace {{ task.namespace_id }}
<br/>Due: {{ task.due }}
</p>
<hr/>
<p class="main-description">{{ task.description }}</p>
<div class="main-description">{{ render_as_markdown(task.description)|safe }}</div>
</td>
</tr>
{% endmacro %}

View file

@ -0,0 +1,20 @@
{% extends "main.html" %}
{% from "_formhelpers.html" import render_field %}
{% block head_extras %}
<link rel="stylesheet" href={{ url_for("static", filename="forms.css") }} />
{% endblock %}
{% block title %}Edit Task{% endblock %}
{% block main_content %}
<form class="default-form" id="edit-task-form" method="POST">
<h1>Edit Task</h1>
<dl>
{{ render_field(form.name) }}
{{ render_field(form.due) }}
{{ render_field(form.description) }}
</dl>
<button type="submit">Create Task</button>
</form>
{% endblock %}

View file

@ -190,4 +190,14 @@ def uncomplete(id: int):
).and_then(
lambda usr_tsk: redirect(next),
lambda err: response_from_exception(err)
)
)
@web_tasks.route('/<int:id>/edit')
@login_required
def edit(id: int):
return redirect(url_for('web.task.all'))
@web_tasks.route('/<int:id>/delete')
@login_required
def delete(id: int):
return redirect(url_for('web.task.all'))