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:
parent
9bb625afe6
commit
47b1e6ca82
14 changed files with 373 additions and 14 deletions
|
|
@ -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.
|
||||
|
|
@ -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('/')
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
]
|
||||
|
|
|
|||
50
src/taskflower/sanitize/bleach_allowlist_md.py
Normal file
50
src/taskflower/sanitize/bleach_allowlist_md.py
Normal 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"},
|
||||
}
|
||||
34
src/taskflower/sanitize/markdown.py
Normal file
34
src/taskflower/sanitize/markdown.py
Normal 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 < and &rt;,
|
||||
respectively.
|
||||
'''
|
||||
return raw.replace(
|
||||
'<',
|
||||
'<'
|
||||
).replace(
|
||||
'>',
|
||||
'>'
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
53
src/taskflower/static/delete.svg
Normal file
53
src/taskflower/static/delete.svg
Normal 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 |
76
src/taskflower/static/edit.svg
Normal file
76
src/taskflower/static/edit.svg
Normal 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 |
|
|
@ -63,7 +63,6 @@
|
|||
|
||||
.list .detail-view-elem .detail-view-header {
|
||||
display: flex;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.list .detail-view-elem .detail-view-header::before{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
20
src/taskflower/templates/task/edit.html
Normal file
20
src/taskflower/templates/task/edit.html
Normal 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 %}
|
||||
|
|
@ -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'))
|
||||
Loading…
Add table
Add a link
Reference in a new issue