diff options
author | citrons <citrons> | 2021-08-06 03:31:07 +0000 |
---|---|---|
committer | citrons <citrons> | 2021-08-06 03:31:07 +0000 |
commit | 5cf3eaebb1db20d61b88f044dfb2a34512aecd61 (patch) | |
tree | 945a270e59bb6d691e6f20686f2f9c8ec3d6e8b4 | |
parent | 52c63cddb3f7860862af6a2185a728baf7593cc7 (diff) | |
parent | 74a992ca018a69cc1de6225a681ca17c19c74ffa (diff) |
merge the things; poll permissions
-rw-r--r-- | apioforum/__init__.py | 6 | ||||
-rw-r--r-- | apioforum/csscolors.py | 150 | ||||
-rw-r--r-- | apioforum/db.py | 60 | ||||
-rw-r--r-- | apioforum/forum.py | 48 | ||||
-rw-r--r-- | apioforum/mdrender.py | 27 | ||||
-rw-r--r-- | apioforum/static/md-colors.css | 306 | ||||
-rw-r--r-- | apioforum/static/style.css | 37 | ||||
-rw-r--r-- | apioforum/templates/base.html | 3 | ||||
-rw-r--r-- | apioforum/templates/common.html | 49 | ||||
-rw-r--r-- | apioforum/templates/config_thread.html | 25 | ||||
-rw-r--r-- | apioforum/templates/view_forum.html | 43 | ||||
-rw-r--r-- | apioforum/templates/view_post.html | 12 | ||||
-rw-r--r-- | apioforum/templates/view_thread.html | 71 | ||||
-rw-r--r-- | apioforum/thread.py | 198 | ||||
-rw-r--r-- | apioforum/util.py | 16 | ||||
-rw-r--r-- | setup.py | 1 |
16 files changed, 1006 insertions, 46 deletions
diff --git a/apioforum/__init__.py b/apioforum/__init__.py index 1d96d8c..087df81 100644 --- a/apioforum/__init__.py +++ b/apioforum/__init__.py @@ -17,6 +17,9 @@ def create_app(): except OSError: pass + app.jinja_env.trim_blocks = True + app.jinja_env.lstrip_blocks = True + from . import db db.init_app(app) from . import permissions @@ -40,6 +43,9 @@ def create_app(): from .fuzzy import fuzzy app.jinja_env.filters['fuzzy']=fuzzy + from .util import gen_colour + app.jinja_env.filters['gen_colour']=gen_colour + @app.context_processor def path_for_next(): p = request.path diff --git a/apioforum/csscolors.py b/apioforum/csscolors.py new file mode 100644 index 0000000..25bdfdd --- /dev/null +++ b/apioforum/csscolors.py @@ -0,0 +1,150 @@ +csscolors = [ + "black", + "silver", + "gray", + "white", + "maroon", + "red", + "purple", + "fuchsia", + "green", + "lime", + "olive", + "yellow", + "navy", + "blue", + "teal", + "aqua", + "orange", + "aliceblue", + "antiquewhite", + "aquamarine", + "azure", + "beige", + "bisque", + "blanchedalmond", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "greenyellow", + "grey", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "limegreen", + "linen", + "magenta", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "oldlace", + "olivedrab", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "whitesmoke", + "yellowgreen", + "rebeccapurple" +] diff --git a/apioforum/db.py b/apioforum/db.py index 899c6b4..5c3d2eb 100644 --- a/apioforum/db.py +++ b/apioforum/db.py @@ -85,13 +85,46 @@ ALTER TABLE users ADD COLUMN bio TEXT; ALTER TABLE users ADD COLUMN joined TIMESTAMP; """, """ +CREATE TABLE polls ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL +); +ALTER TABLE threads ADD COLUMN poll INTEGER REFERENCES polls(id); + +CREATE TABLE poll_options ( + poll INTEGER NOT NULL REFERENCES polls(id), + text TEXT NOT NULL, + option_idx INTEGER NOT NULL, + PRIMARY KEY ( poll, option_idx ) +); + +CREATE TABLE votes ( + id INTEGER PRIMARY KEY, + user TEXT NOT NULL REFERENCES users(username), + poll INTEGER NOT NULL, + option_idx INTEGER, + time TIMESTAMP NOT NULL, + current INTEGER NOT NULL, + is_retraction INTEGER, + CHECK (is_retraction OR (option_idx NOT NULL)), + FOREIGN KEY ( poll, option_idx ) REFERENCES poll_options(poll, option_idx) +); +ALTER TABLE posts ADD COLUMN vote INTEGER REFERENCES votes(id); +""", +""" +CREATE VIEW vote_counts AS + SELECT poll, option_idx, count(*) AS num FROM votes WHERE current GROUP BY option_idx,poll; +""", +""" CREATE TABLE forums ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, parent INTEGER REFERENCES forums(id), description TEXT ); -INSERT INTO forums (name,parent,description) values ('apioforum',null,'the default root forum'); +INSERT INTO forums (name,parent,description) values ('apioforum',null, + 'welcome to the apioforum\n\n' || + 'forum rules: do not be a bad person. do not do bad things.'); PRAGMA foreign_keys = off; BEGIN TRANSACTION; @@ -101,7 +134,8 @@ CREATE TABLE threads_new ( creator TEXT NOT NULL, created TIMESTAMP NOT NULL, updated TIMESTAMP NOT NULL, - forum NOT NULL REFERENCES forums(id) + forum NOT NULL REFERENCES forums(id), + poll INTEGER REFERENCES polls(id) ); INSERT INTO threads_new (id,title,creator,created,updated,forum) SELECT id,title,creator,created,updated,1 FROM threads; @@ -118,6 +152,26 @@ CREATE VIEW number_of_posts AS SELECT thread, count(*) AS num_replies FROM posts GROUP BY thread; """, """ +CREATE VIEW total_vote_counts AS + SELECT poll, count(*) AS total_votes FROM votes WHERE current AND NOT is_retraction GROUP BY poll; +""", +""" +PRAGMA foreign_keys = off; +BEGIN TRANSACTION; +CREATE TABLE tags_new ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + text_colour TEXT NOT NULL, + bg_colour TEXT NOT NULL, + forum INTEGER NOT NULL REFERENCES forums(id) +); +INSERT INTO tags_new (id,name,text_colour,bg_colour,forum) + SELECT id,name,text_colour,bg_colour,1 FROM tags; +DROP TABLE tags; +ALTER TABLE tags_new RENAME TO tags; +PRAGMA foreign_keys = on; +""", +""" CREATE TABLE role_config ( role TEXT NOT NULL, forum NOT NULL REFERENCES forums(id), @@ -149,7 +203,7 @@ ALTER TABLE posts ADD COLUMN deleted NOT NULL DEFAULT 0; """, """ ALTER TABLE forums ADD COLUMN unlisted NOT NULL DEFAULT 0; -""" +""", ] def init_db(): diff --git a/apioforum/forum.py b/apioforum/forum.py index 410bee5..cae4eab 100644 --- a/apioforum/forum.py +++ b/apioforum/forum.py @@ -19,6 +19,19 @@ bp = Blueprint("forum", __name__, url_prefix="/") def not_actual_index(): return redirect("/1") +def get_avail_tags(forum_id): + db = get_db() + tags = db.execute(""" + WITH RECURSIVE fs AS + (SELECT * FROM forums WHERE id = ? + UNION ALL + SELECT forums.* FROM forums, fs WHERE fs.parent=forums.id) + SELECT * FROM tags + WHERE tags.forum in (SELECT id FROM fs) + ORDER BY id; + """,(forum_id,)).fetchall() + return tags + def forum_path(forum_id): db = get_db() ancestors = db.execute(""" @@ -73,7 +86,7 @@ def view_forum(forum): threads = db.execute( """SELECT threads.id, threads.title, threads.creator, threads.created, - threads.updated, number_of_posts.num_replies, + threads.updated, threads.poll, number_of_posts.num_replies, most_recent_posts.created as mrp_created, most_recent_posts.author as mrp_author, most_recent_posts.id as mrp_id, @@ -86,6 +99,10 @@ def view_forum(forum): ORDER BY threads.updated DESC; """,(forum['id'],)).fetchall() thread_tags = {} + thread_polls = {} + + avail_tags = get_avail_tags(forum['id']) + #todo: somehow optimise this for thread in threads: thread_tags[thread['id']] = db.execute( @@ -95,6 +112,29 @@ def view_forum(forum): ORDER BY tags.id; """,(thread['id'],)).fetchall() + if thread['poll'] is not None: + # todo: make this not be duplicated from thread.py + poll_row= db.execute(""" + SELECT polls.*,total_vote_counts.total_votes FROM polls + LEFT OUTER JOIN total_vote_counts ON polls.id = total_vote_counts.poll + WHERE polls.id = ?; + """,(thread['poll'],)).fetchone() + options = db.execute(""" + SELECT poll_options.*, vote_counts.num + FROM poll_options + LEFT OUTER JOIN vote_counts ON poll_options.poll = vote_counts.poll + AND poll_options.option_idx = vote_counts.option_idx + WHERE poll_options.poll = ? + ORDER BY option_idx asc; + """,(poll_row['id'],)).fetchall() + + poll = {} + poll.update(poll_row) + poll['options'] = options + poll['total_votes']=poll['total_votes'] or 0 + thread_polls[thread['id']]=poll + + subforums_rows = db.execute(""" SELECT max(threads.updated) as updated, forums.* FROM forums LEFT OUTER JOIN threads ON threads.forum=forums.id @@ -121,7 +161,9 @@ def view_forum(forum): subforums=subforums, threads=threads, thread_tags=thread_tags, - bureaucrats=bureaucrats + bureaucrats=bureaucrats, + thread_polls=thread_polls, + avail_tags=avail_tags, ) @forum_route("create_thread",methods=("GET","POST")) @@ -351,7 +393,7 @@ def search(): """, (query,)).fetchall() except OperationalError: flash('your search query was malformed.') - return redirect(url_for("forum.view_forum")) + return redirect(url_for("forum.not_actual_index")) display_thread_id = [ True ] * len(results) last_thread = None diff --git a/apioforum/mdrender.py b/apioforum/mdrender.py index 8c59c42..52a6e6c 100644 --- a/apioforum/mdrender.py +++ b/apioforum/mdrender.py @@ -1,4 +1,5 @@ import bleach +from .csscolors import csscolors allowed_tags = [ 'p', @@ -10,14 +11,28 @@ allowed_tags = [ 'h6', 'pre', 'del', + 'ins', 'mark', 'img', - 'marquee' + 'marquee', + 'pulsate', + 'sup','sub', + 'table','thead','tbody','tr','th','td', + 'details','summary', + 'hr' + + + ] +allowed_tags += csscolors +allowed_tags += ("mark" + c for c in csscolors) + allowed_attributes = bleach.sanitizer.ALLOWED_ATTRIBUTES.copy() allowed_attributes.update( - img='src', + img=['src','alt','title'], + ol=['start'], + details=['open'], ) allowed_tags.extend(bleach.sanitizer.ALLOWED_TAGS) @@ -25,7 +40,13 @@ allowed_tags.extend(bleach.sanitizer.ALLOWED_TAGS) cleaner = bleach.sanitizer.Cleaner(tags=allowed_tags,attributes=allowed_attributes) import markdown -md = markdown.Markdown(extensions=['pymdownx.tilde','fenced_code']) +md = markdown.Markdown(extensions=[ + 'pymdownx.tilde', + 'pymdownx.caret', + 'fenced_code', + 'tables', + 'pymdownx.details', +]) def render(text): text = md.reset().convert(text) diff --git a/apioforum/static/md-colors.css b/apioforum/static/md-colors.css new file mode 100644 index 0000000..e29da0c --- /dev/null +++ b/apioforum/static/md-colors.css @@ -0,0 +1,306 @@ + +pulsate { animation: 2s infinite alternate pulsate; } + +@keyframes pulsate { + from { letter-spacing: normal; } + to { letter-spacing: 1px; } +} + +black { color: black; } +silver { color: silver; } +gray { color: gray; } +white { color: white; } +maroon { color: maroon; } +red { color: red; } +purple { color: purple; } +fuchsia { color: fuchsia; } +green { color: green; } +lime { color: lime; } +olive { color: olive; } +yellow { color: yellow; } +navy { color: navy; } +blue { color: blue; } +teal { color: teal; } +aqua { color: aqua; } +orange { color: orange; } +aliceblue { color: aliceblue; } +antiquewhite { color: antiquewhite; } +aquamarine { color: aquamarine; } +azure { color: azure; } +beige { color: beige; } +bisque { color: bisque; } +blanchedalmond { color: blanchedalmond; } +blueviolet { color: blueviolet; } +brown { color: brown; } +burlywood { color: burlywood; } +cadetblue { color: cadetblue; } +chartreuse { color: chartreuse; } +chocolate { color: chocolate; } +coral { color: coral; } +cornflowerblue { color: cornflowerblue; } +cornsilk { color: cornsilk; } +crimson { color: crimson; } +cyan { color: cyan; } +darkblue { color: darkblue; } +darkcyan { color: darkcyan; } +darkgoldenrod { color: darkgoldenrod; } +darkgray { color: darkgray; } +darkgreen { color: darkgreen; } +darkgrey { color: darkgrey; } +darkkhaki { color: darkkhaki; } +darkmagenta { color: darkmagenta; } +darkolivegreen { color: darkolivegreen; } +darkorange { color: darkorange; } +darkorchid { color: darkorchid; } +darkred { color: darkred; } +darksalmon { color: darksalmon; } +darkseagreen { color: darkseagreen; } +darkslateblue { color: darkslateblue; } +darkslategray { color: darkslategray; } +darkslategrey { color: darkslategrey; } +darkturquoise { color: darkturquoise; } +darkviolet { color: darkviolet; } +deeppink { color: deeppink; } +deepskyblue { color: deepskyblue; } +dimgray { color: dimgray; } +dimgrey { color: dimgrey; } +dodgerblue { color: dodgerblue; } +firebrick { color: firebrick; } +floralwhite { color: floralwhite; } +forestgreen { color: forestgreen; } +gainsboro { color: gainsboro; } +ghostwhite { color: ghostwhite; } +gold { color: gold; } +goldenrod { color: goldenrod; } +greenyellow { color: greenyellow; } +grey { color: grey; } +honeydew { color: honeydew; } +hotpink { color: hotpink; } +indianred { color: indianred; } +indigo { color: indigo; } +ivory { color: ivory; } +khaki { color: khaki; } +lavender { color: lavender; } +lavenderblush { color: lavenderblush; } +lawngreen { color: lawngreen; } +lemonchiffon { color: lemonchiffon; } +lightblue { color: lightblue; } +lightcoral { color: lightcoral; } +lightcyan { color: lightcyan; } +lightgoldenrodyellow { color: lightgoldenrodyellow; } +lightgray { color: lightgray; } +lightgreen { color: lightgreen; } +lightgrey { color: lightgrey; } +lightpink { color: lightpink; } +lightsalmon { color: lightsalmon; } +lightseagreen { color: lightseagreen; } +lightskyblue { color: lightskyblue; } +lightslategray { color: lightslategray; } +lightslategrey { color: lightslategrey; } +lightsteelblue { color: lightsteelblue; } +lightyellow { color: lightyellow; } +limegreen { color: limegreen; } +linen { color: linen; } +magenta { color: magenta; } +mediumaquamarine { color: mediumaquamarine; } +mediumblue { color: mediumblue; } +mediumorchid { color: mediumorchid; } +mediumpurple { color: mediumpurple; } +mediumseagreen { color: mediumseagreen; } +mediumslateblue { color: mediumslateblue; } +mediumspringgreen { color: mediumspringgreen; } +mediumturquoise { color: mediumturquoise; } +mediumvioletred { color: mediumvioletred; } +midnightblue { color: midnightblue; } +mintcream { color: mintcream; } +mistyrose { color: mistyrose; } +moccasin { color: moccasin; } +navajowhite { color: navajowhite; } +oldlace { color: oldlace; } +olivedrab { color: olivedrab; } +orangered { color: orangered; } +orchid { color: orchid; } +palegoldenrod { color: palegoldenrod; } +palegreen { color: palegreen; } +paleturquoise { color: paleturquoise; } +palevioletred { color: palevioletred; } +papayawhip { color: papayawhip; } +peachpuff { color: peachpuff; } +peru { color: peru; } +pink { color: pink; } +plum { color: plum; } +powderblue { color: powderblue; } +rosybrown { color: rosybrown; } +royalblue { color: royalblue; } +saddlebrown { color: saddlebrown; } +salmon { color: salmon; } +sandybrown { color: sandybrown; } +seagreen { color: seagreen; } +seashell { color: seashell; } +sienna { color: sienna; } +skyblue { color: skyblue; } +slateblue { color: slateblue; } +slategray { color: slategray; } +slategrey { color: slategrey; } +snow { color: snow; } +springgreen { color: springgreen; } +steelblue { color: steelblue; } +tan { color: tan; } +thistle { color: thistle; } +tomato { color: tomato; } +turquoise { color: turquoise; } +violet { color: violet; } +wheat { color: wheat; } +whitesmoke { color: whitesmoke; } +yellowgreen { color: yellowgreen; } +rebeccapurple { color: rebeccapurple; } + + +markblack { background-color: black; } +marksilver { background-color: silver; } +markgray { background-color: gray; } +markwhite { background-color: white; } +markmaroon { background-color: maroon; } +markred { background-color: red; } +markpurple { background-color: purple; } +markfuchsia { background-color: fuchsia; } +markgreen { background-color: green; } +marklime { background-color: lime; } +markolive { background-color: olive; } +markyellow { background-color: yellow; } +marknavy { background-color: navy; } +markblue { background-color: blue; } +markteal { background-color: teal; } +markaqua { background-color: aqua; } +markorange { background-color: orange; } +markaliceblue { background-color: aliceblue; } +markantiquewhite { background-color: antiquewhite; } +markaquamarine { background-color: aquamarine; } +markazure { background-color: azure; } +markbeige { background-color: beige; } +markbisque { background-color: bisque; } +markblanchedalmond { background-color: blanchedalmond; } +markblueviolet { background-color: blueviolet; } +markbrown { background-color: brown; } +markburlywood { background-color: burlywood; } +markcadetblue { background-color: cadetblue; } +markchartreuse { background-color: chartreuse; } +markchocolate { background-color: chocolate; } +markcoral { background-color: coral; } +markcornflowerblue { background-color: cornflowerblue; } +markcornsilk { background-color: cornsilk; } +markcrimson { background-color: crimson; } +markcyan { background-color: cyan; } +markdarkblue { background-color: darkblue; } +markdarkcyan { background-color: darkcyan; } +markdarkgoldenrod { background-color: darkgoldenrod; } +markdarkgray { background-color: darkgray; } +markdarkgreen { background-color: darkgreen; } +markdarkgrey { background-color: darkgrey; } +markdarkkhaki { background-color: darkkhaki; } +markdarkmagenta { background-color: darkmagenta; } +markdarkolivegreen { background-color: darkolivegreen; } +markdarkorange { background-color: darkorange; } +markdarkorchid { background-color: darkorchid; } +markdarkred { background-color: darkred; } +markdarksalmon { background-color: darksalmon; } +markdarkseagreen { background-color: darkseagreen; } +markdarkslateblue { background-color: darkslateblue; } +markdarkslategray { background-color: darkslategray; } +markdarkslategrey { background-color: darkslategrey; } +markdarkturquoise { background-color: darkturquoise; } +markdarkviolet { background-color: darkviolet; } +markdeeppink { background-color: deeppink; } +markdeepskyblue { background-color: deepskyblue; } +markdimgray { background-color: dimgray; } +markdimgrey { background-color: dimgrey; } +markdodgerblue { background-color: dodgerblue; } +markfirebrick { background-color: firebrick; } +markfloralwhite { background-color: floralwhite; } +markforestgreen { background-color: forestgreen; } +markgainsboro { background-color: gainsboro; } +markghostwhite { background-color: ghostwhite; } +markgold { background-color: gold; } +markgoldenrod { background-color: goldenrod; } +markgreenyellow { background-color: greenyellow; } +markgrey { background-color: grey; } +markhoneydew { background-color: honeydew; } +markhotpink { background-color: hotpink; } +markindianred { background-color: indianred; } +markindigo { background-color: indigo; } +markivory { background-color: ivory; } +markkhaki { background-color: khaki; } +marklavender { background-color: lavender; } +marklavenderblush { background-color: lavenderblush; } +marklawngreen { background-color: lawngreen; } +marklemonchiffon { background-color: lemonchiffon; } +marklightblue { background-color: lightblue; } +marklightcoral { background-color: lightcoral; } +marklightcyan { background-color: lightcyan; } +marklightgoldenrodyellow { background-color: lightgoldenrodyellow; } +marklightgray { background-color: lightgray; } +marklightgreen { background-color: lightgreen; } +marklightgrey { background-color: lightgrey; } +marklightpink { background-color: lightpink; } +marklightsalmon { background-color: lightsalmon; } +marklightseagreen { background-color: lightseagreen; } +marklightskyblue { background-color: lightskyblue; } +marklightslategray { background-color: lightslategray; } +marklightslategrey { background-color: lightslategrey; } +marklightsteelblue { background-color: lightsteelblue; } +marklightyellow { background-color: lightyellow; } +marklimegreen { background-color: limegreen; } +marklinen { background-color: linen; } +markmagenta { background-color: magenta; } +markmediumaquamarine { background-color: mediumaquamarine; } +markmediumblue { background-color: mediumblue; } +markmediumorchid { background-color: mediumorchid; } +markmediumpurple { background-color: mediumpurple; } +markmediumseagreen { background-color: mediumseagreen; } +markmediumslateblue { background-color: mediumslateblue; } +markmediumspringgreen { background-color: mediumspringgreen; } +markmediumturquoise { background-color: mediumturquoise; } +markmediumvioletred { background-color: mediumvioletred; } +markmidnightblue { background-color: midnightblue; } +markmintcream { background-color: mintcream; } +markmistyrose { background-color: mistyrose; } +markmoccasin { background-color: moccasin; } +marknavajowhite { background-color: navajowhite; } +markoldlace { background-color: oldlace; } +markolivedrab { background-color: olivedrab; } +markorangered { background-color: orangered; } +markorchid { background-color: orchid; } +markpalegoldenrod { background-color: palegoldenrod; } +markpalegreen { background-color: palegreen; } +markpaleturquoise { background-color: paleturquoise; } +markpalevioletred { background-color: palevioletred; } +markpapayawhip { background-color: papayawhip; } +markpeachpuff { background-color: peachpuff; } +markperu { background-color: peru; } +markpink { background-color: pink; } +markplum { background-color: plum; } +markpowderblue { background-color: powderblue; } +markrosybrown { background-color: rosybrown; } +markroyalblue { background-color: royalblue; } +marksaddlebrown { background-color: saddlebrown; } +marksalmon { background-color: salmon; } +marksandybrown { background-color: sandybrown; } +markseagreen { background-color: seagreen; } +markseashell { background-color: seashell; } +marksienna { background-color: sienna; } +markskyblue { background-color: skyblue; } +markslateblue { background-color: slateblue; } +markslategray { background-color: slategray; } +markslategrey { background-color: slategrey; } +marksnow { background-color: snow; } +markspringgreen { background-color: springgreen; } +marksteelblue { background-color: steelblue; } +marktan { background-color: tan; } +markthistle { background-color: thistle; } +marktomato { background-color: tomato; } +markturquoise { background-color: turquoise; } +markviolet { background-color: violet; } +markwheat { background-color: wheat; } +markwhitesmoke { background-color: whitesmoke; } +markyellowgreen { background-color: yellowgreen; } +markrebeccapurple { background-color: rebeccapurple; } diff --git a/apioforum/static/style.css b/apioforum/static/style.css index 2ed2e7a..62d643c 100644 --- a/apioforum/static/style.css +++ b/apioforum/static/style.css @@ -16,8 +16,8 @@ body { font-family: sans-serif; word-wrap: break-word; } } .post:last-of-type { border-bottom: 1px solid black; } -.post-heading { font-size: smaller; } -.post-heading,.username { +.post-heading,.post-footer { font-size: smaller; } +.post-heading,.post-footer,.username { color: hsl(0,0%,25%); } .username { @@ -32,6 +32,8 @@ body { font-family: sans-serif; word-wrap: break-word; } .post-heading-a { margin-left: 0.2em } .post-heading-b { float: right; margin-right: 0.5em } +.post-footer { margin-left: 0.2em; font-style: italic; } + .post-anchor-link { color: hsl(0,0%,25%); } .deleted-post { @@ -51,6 +53,10 @@ body { font-family: sans-serif; word-wrap: break-word; } color: hsl(0,0%,80%); } +.deleted-post > .post-footer { + color: hsl(0,0%,80%); +} + .thread-top-bar, .user-top-bar { margin-bottom: 4px; } @@ -95,7 +101,6 @@ dt { font-weight: bold } img { max-width: 100% } - nav#navbar { float: right; padding: 5px; @@ -162,6 +167,11 @@ nav#navbar .links { display: flex; } .thread-preview-post { font-style: italic; } .thread-preview-ts { font-weight: bold; } +.thread-vote-summary { + margin-top: 4px; + margin-bottom: -8px; +} + /* wide screens */ @media all and (min-width: 600px) { .listing-title { font-size: larger; } @@ -196,6 +206,16 @@ nav#navbar .links { display: flex; } margin-top: 5px; } +#polloptions { + display: block; + resize: vertical; + border:1px solid black; + margin-top: 5px; + height: 5em; + width: 100%; + font-family: sans-serif; +} + main { max-width: 60ch; margin: auto; @@ -213,7 +233,7 @@ fieldset { margin-bottom: 15px; } .warning { color: red; font-weight: bold } -.search-form { +.inline-form { display: inline-block; } @@ -223,6 +243,15 @@ fieldset { margin-bottom: 15px; } border: 1px solid black; } +.md table { + border: 1px solid grey; + border-collapse: collapse; +} +.md table td,.md table th { + border: 1px solid grey; + padding: 4px; +} + .role-input, .name-input { width: 12ch; } .thing-id { color: darkgray; font-size: smaller; font-weight: normal; } diff --git a/apioforum/templates/base.html b/apioforum/templates/base.html index f462df2..ca1dd87 100644 --- a/apioforum/templates/base.html +++ b/apioforum/templates/base.html @@ -6,12 +6,13 @@ <title>{%block title %}{% endblock %}</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="/static/style.css"> + <link rel="stylesheet" href="/static/md-colors.css"> <link rel="icon" href="//gh0.pw/favicon.ico"> </head> <body> <nav id="navbar"> <p style="font-family: monospace;"><b>apio</b><i>forum</i>™</p> - <form class="search-form" action="/search"> + <form class="inline-form" action="/search"> <input type="search" placeholder="query" name="q"> <input type="submit" value="search"> </form> diff --git a/apioforum/templates/common.html b/apioforum/templates/common.html index 7144667..f6b6f29 100644 --- a/apioforum/templates/common.html +++ b/apioforum/templates/common.html @@ -6,7 +6,7 @@ {{url_for('thread.view_thread', thread_id=post.thread)}}#post_{{post.id}} {%- endmacro %} -{% macro disp_post(post, buttons=False, forum=None) %} +{% macro disp_post(post, buttons=False, forum=None, footer=None) %} <div class="post {% if post.deleted %}deleted-post{% endif %}" id="post_{{post.id}}"> <div class="post-heading"> <span class="post-heading-a"> @@ -45,17 +45,25 @@ <a class="actionbutton" href="{{url_for('thread.delete_post',post_id=post.id)}}">delete</a> {% endif %} + <a class="actionbutton" + href="{{url_for('thread.view_post',post_id=post.id)}}">src</a> {% endif %} + <a class="post-anchor-link" href="{{post_url(post)}}">#{{post.id}}</a> </span> </div> - <div class="post-content"> + <div class="post-content md"> {% if not post.deleted %} {{ post.content|md|safe }} {% else %} this post never existed. {% endif %} </div> + {% if footer %} + <div class="post-footer"> + {{ footer }} + </div> + {% endif %} </div> {% endmacro %} @@ -97,3 +105,40 @@ <li>{{ thread.title }}</li> {% endcall -%} {% endmacro %} + +{% macro vote_meter(poll) %} + {% set total_votes = poll.total_votes %} + {% set n = namespace() %} + {% set n.runningtotal = 0 %} + <svg width="100%" height="15px" xmlns="http://www.w3.org/2000/svg"> + {% if total_votes == 0 %} + <text text-anchor="middle" dominant-baseline="middle" x="11%" y="55%" fill="black" style="font-size:15px">no votes</text> + {% else %} + {% for opt in poll.options %} + {% set opt_count = opt.num or 0 %} + {% set colour = (loop.index|string + opt.text)|gen_colour %} + {% if opt_count != 0 %} + {% set percentage = 100*(opt_count/total_votes) %} + {# todo: do this in css somehow #} + {% if opt.text|length > 10 %} + {% set opt_text = opt.text[:7] + "..." %} + {% else %} + {% set opt_text = opt.text %} + {% endif %} + <rect y="0" height="100%" x="{{n.runningtotal}}%" width="{{percentage}}%" stroke="black" fill="{{colour}}" /> + <text text-anchor="middle" dominant-baseline="middle" y="55%" fill="black" style="font-size:15px" x="{{n.runningtotal+(percentage/2)}}%"> + {{opt_text}}: {{opt_count}} + </text> + {% set n.runningtotal = n.runningtotal + percentage %} + {% endif %} + {% endfor %} + {% endif %} + <desc> + poll: {{poll.title}} + {% for opt in poll.options %} + option "{{opt.text}}": {{opt.num or 0}} votes + {% endfor %} + total votes: {{total_votes}} + </desc> + </svg> +{% endmacro %} diff --git a/apioforum/templates/config_thread.html b/apioforum/templates/config_thread.html index b26a73d..7403614 100644 --- a/apioforum/templates/config_thread.html +++ b/apioforum/templates/config_thread.html @@ -2,6 +2,7 @@ {% from 'common.html' import tag %} {% block header %}<h1>{% block title %}configure thread '{{thread.title}}'{% endblock %}</h1>{% endblock %} {% block content %} +<h2>thread options</h2> <form method="post"> <fieldset> <legend>title</legend> @@ -27,4 +28,28 @@ <input type="submit" value="confirm"> <a href="{{url_for('thread.view_thread',thread_id=thread.id)}}">cancel</a> </form> + +{% if thread.poll is none and has_permission(thread.forum, g.user, "p_create_polls" %} +<h2>create poll</h2> +<form method="post" action="{{url_for('thread.create_poll',thread_id=thread.id)}}"> + <fieldset> + <legend>create poll</legend> + <label for="polltitle">question title</label> + <input type="title" id="polltitle" name="polltitle"> + <br> + <label for="polloptions">options (one per line)</label> + <textarea name="polloptions" id="polloptions"></textarea> + </fieldset> + <p>important: once a poll is created, you will not be able to modify it except to delete it entirely</p> + <input type="submit" value="create"> +</form> +{% else %} +<h2>delete poll</h2> +<p>there is already a poll attached to this thread. you can delete it, which will allow you to create a new one, but this will erase all existing votes and data for the current poll.</p> +<form action="{{url_for('thread.delete_poll',thread_id=thread.id)}}" method="post"> + <input type="submit" value="confirm: delete poll"> +</form> + +{% endif %} + {% endblock %} diff --git a/apioforum/templates/view_forum.html b/apioforum/templates/view_forum.html index 863f91c..ff1af9b 100644 --- a/apioforum/templates/view_forum.html +++ b/apioforum/templates/view_forum.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% from 'common.html' import ts, tag, disp_user, post_url, forum_breadcrumb, ab %} +{% from 'common.html' import ts, tag, disp_user, post_url, forum_breadcrumb, ab, vote_meter %} {% block header %} <h1>{% block title %}{{forum.name}}{% endblock %} <span class="thing-id">#{{forum.id}}</span></h1> {% if forum.id != 1 %} @@ -8,15 +8,28 @@ {%endblock%} {%block content%} -{{forum.description|md|safe}} -{% if bureaucrats|length > 0 %} - <p> - bureaucrats in this forum: - {% for b in bureaucrats %} - {{disp_user(b)}} - {% endfor %} +{{forum.description|md|safe}} + +<hr/> +<div class="forum-info"> + {% if bureaucrats|length > 0 %} + <p> + bureaucrats in this forum: + {% for b in bureaucrats %} + {{disp_user(b)}} + {% endfor %} + </p> + {% endif %} + + <p>available tags: + {% for the_tag in avail_tags %} + {{tag(the_tag)}} + {% else %} + <em>(none available)</em> + {% endfor %} </p> -{% endif %} +</div> + <p> {% if is_bureaucrat(forum.id, g.user) %} {{ab("forum settings",url_for('forum.edit_forum',forum_id=forum.id))}} @@ -30,6 +43,7 @@ {{ab("approve users",url_for('forum.view_user_role',forum_id=forum.id))}} {% endif %} </p> + {% if subforums %} <h2>subforæ</h2> <div class="forum-list"> @@ -55,10 +69,11 @@ {% endif %} <h2>threads</h2> +<p> {% if g.user %} -<p><a class="actionbutton" href="{{url_for('forum.create_thread',forum_id=forum.id)}}">create new thread</a></p> +<a class="actionbutton" href="{{url_for('forum.create_thread',forum_id=forum.id)}}">create new thread</a> {% else %} -<p>please log in to create a new thread</p> +please log in to create a new thread {% endif %} {% if has_permission(forum.id, g.user, "p_view_threads") %} @@ -103,11 +118,15 @@ </a> </div> {% endif %} + {% if thread_polls[thread.id] %} + <div class="thread-vote-summary"> + {{ vote_meter(thread_polls[thread.id]) }} + </div> + {% endif %} </div> {%endfor%} </div> {% else %} <p>you do not have permission to view threads in this forum</p> {% endif %} - {%endblock%} diff --git a/apioforum/templates/view_post.html b/apioforum/templates/view_post.html new file mode 100644 index 0000000..993c005 --- /dev/null +++ b/apioforum/templates/view_post.html @@ -0,0 +1,12 @@ +{% from 'common.html' import disp_post %} +{% extends 'base.html' %} +{% block header %} +<h1>{%block title%}viewing post{%endblock%}</h1> +{% endblock %} + +{% block content %} +{{disp_post(post,False)}} +<p>post source:</p> +<textarea readonly class="new-post-box" name="newcontent">{{post.content}}</textarea> +<a href="{{url_for('thread.view_thread',thread_id=post.thread)}}">i am satisfied</a> +{% endblock %} diff --git a/apioforum/templates/view_thread.html b/apioforum/templates/view_thread.html index da8df74..132fd44 100644 --- a/apioforum/templates/view_thread.html +++ b/apioforum/templates/view_thread.html @@ -1,4 +1,4 @@ -{% from 'common.html' import disp_post,tag,thread_breadcrumb %} +{% from 'common.html' import disp_post,tag,thread_breadcrumb,vote_meter %} {% extends 'base.html' %} {% block header %} <h1>{%block title %}{{thread.title}}{% endblock %} <span class="thing-id">#{{thread.id}}</span></h1> @@ -6,6 +6,15 @@ {% endblock %} {%block content%} +{% if poll %} +<p>{{poll.title}}</p> +<ol> + {%for opt in poll.options%} + <li value="{{opt.option_idx}}"><i>{{opt.text}}</i>: {{opt.num or 0}} votes</li> + {%endfor%} +</ol> +{{ vote_meter(poll) }} +{% endif %} <div class="thread-top-bar"> <span class="thread-top-bar-a"> {% if g.user == thread.creator or has_permission(thread.forum, g.user, "p_manage_threads") %} @@ -25,14 +34,70 @@ <div class="posts"> {% for post in posts %} - {{ disp_post(post, buttons=True, forum=thread.forum) }} + {% if votes[post.id] %} + + {% set vote = votes[post.id] %} + {% set option_idx = vote.option_idx %} + + {# this is bad but it's going to get refactored anyway #} + {% set footer %} + {% if vote.is_retraction %} + {% if not post.deleted %} + {{post.author}} retracted their vote + {% else %} + this post retracted a vote + {% endif %} + {% else %} + {% set option = poll.options[option_idx-1] %} + {% if vote.current %} + {{post.author}} votes for {{option_idx}}: {{option.text}} + {% else %} + {% if not post.deleted %} + {{post.author}} voted for {{option_idx}}: {{option.text}}, but later changed their vote + {% else %} + this post presented a vote that was later changed + {% endif %} + {% endif %} + {% endif %} + + {% endset %} + + {{ disp_post(post, forum=thread.forum, buttons=True, footer=footer) }} + + {% else %} + {{ disp_post(post, forum=thread.forum, buttons=True) }} + {% endif %} {% endfor %} </div> -{% if g.user %} +{% if g.user and has_permission(thread.forum, g.user, "p_reply_threads") %} <form class="new-post" action="{{url_for('thread.create_post',thread_id=thread.id)}}" method="POST"> <textarea class="new-post-box" placeholder="your post here..." name="content"></textarea> + {% if poll and has_permission(thread.forum, g.user, "p_vote") %} + <fieldset> + <legend>poll: {{poll.title}}</legend> + <p>if you want, you can submit a vote along with this post. if you have previously voted + on this poll, your previous vote will be changed</p> + + <input type="radio" id="dontvote" name="poll" value="dontvote" checked> + <label for="dontvote">do not submit any vote at the moment</label> + + {% if has_voted %} + <br> + <input type="radio" id="retractvote" name="poll" value="retractvote"> + <label for="retractvote">clear current vote</label> + {% endif %} + + {% for opt in poll.options %} + <br> + <input type="radio" id="option_{{opt.option_idx}}" name="poll" value="{{opt.option_idx}}"> + <label for="option_{{opt.option_idx}}">#{{opt.option_idx}} - {{opt.text}}</label> + {% endfor %} + </fieldset> + {% endif %} <input type="submit" value="yes"> </form> +{% elif g.user %} +<p>you do not have permission to reply to this thread</p> {% else %} <p>please log in to reply to this thread</p> {% endif %} diff --git a/apioforum/thread.py b/apioforum/thread.py index 1291adf..0b0804e 100644 --- a/apioforum/thread.py +++ b/apioforum/thread.py @@ -1,11 +1,14 @@ # view posts in thread +import itertools + from flask import ( Blueprint, render_template, abort, request, g, redirect, - url_for, flash + url_for, flash, jsonify ) from .db import get_db from .roles import has_permission +from .forum import get_avail_tags bp = Blueprint("thread", __name__, url_prefix="/thread") @@ -20,17 +23,158 @@ def view_thread(thread_id): abort(404) if not has_permission(thread['forum'], g.user, "p_view_threads"): abort(403) - posts = db.execute( - "SELECT * FROM posts WHERE thread = ? ORDER BY created ASC;", - (thread_id,) - ).fetchall() + posts = db.execute(""" + SELECT * FROM posts + WHERE posts.thread = ? + ORDER BY created ASC; + """,(thread_id,)).fetchall() tags = db.execute( """SELECT tags.* FROM tags INNER JOIN thread_tags ON thread_tags.tag = tags.id WHERE thread_tags.thread = ? ORDER BY tags.id""",(thread_id,)).fetchall() - return render_template("view_thread.html",posts=posts,thread=thread,tags=tags) + poll = None + votes = None + if thread['poll'] is not None: + poll_row= db.execute(""" + SELECT polls.*,total_vote_counts.total_votes FROM polls + LEFT OUTER JOIN total_vote_counts ON polls.id = total_vote_counts.poll + WHERE polls.id = ?; + """,(thread['poll'],)).fetchone() + options = db.execute(""" + SELECT poll_options.*, vote_counts.num + FROM poll_options + LEFT OUTER JOIN vote_counts ON poll_options.poll = vote_counts.poll + AND poll_options.option_idx = vote_counts.option_idx + WHERE poll_options.poll = ? + ORDER BY option_idx asc; + """,(poll_row['id'],)).fetchall() + poll = {} + poll.update(poll_row) + poll['options'] = options + votes = {} + # todo: optimise this somehow + for post in posts: + if post['vote'] is not None: + votes[post['id']] = db.execute("SELECT * FROM votes WHERE id = ?",(post['vote'],)).fetchone() + + if g.user is None or poll is None: + has_voted = None + else: + v = db.execute("SELECT * FROM votes WHERE poll = ? AND user = ? AND current AND NOT is_retraction;",(poll['id'],g.user)).fetchone() + has_voted = v is not None + + return render_template( + "view_thread.html", + posts=posts, + thread=thread, + tags=tags, + poll=poll, + votes=votes, + has_voted=has_voted, + ) + +def register_vote(thread,pollval): + if pollval is None or pollval == 'dontvote': + return + + is_retraction = pollval == 'retractvote' + + if is_retraction: + option_idx = None + else: + option_idx = int(pollval) + + db = get_db() + cur = db.cursor() + cur.execute(""" + UPDATE votes + SET current = 0 + WHERE poll = ? AND user = ?; + """,(thread['poll'],g.user)) + + cur.execute(""" + INSERT INTO votes (user,poll,option_idx,time,current,is_retraction) + VALUES (?,?,?,current_timestamp,1,?); + """,(g.user,thread['poll'],option_idx,is_retraction)) + vote_id = cur.lastrowid + return vote_id + +@bp.route("/<int:thread_id>/create_poll",methods=["POST"]) +def create_poll(thread_id): + fail = redirect(url_for('thread.config_thread',thread_id=thread_id)) + success = redirect(url_for('thread.view_thread',thread_id=thread_id)) + err = None + db = get_db() + thread = db.execute('select * from threads where id = ?',(thread_id,)).fetchone() + + polltitle = request.form.get('polltitle','').strip() + polloptions = [q.strip() for q in request.form.get('polloptions','').split("\n") if len(q.strip()) > 0] + + if thread is None: + err = "that thread does not exist" + elif g.user is None: + err = "you need to be logged in to do that" + elif g.user != thread['creator'] and \ + not has_permission(thread['forum'],g.user,"p_manage_threads"): + err = "you can only create polls on threads that you own" + elif thread['poll'] is not None: + err = "a poll already exists for that thread" + elif not len(polltitle) > 0: + err = "poll title can't be empty" + elif len(polloptions) < 2: + err = "you must provide at least 2 options" + elif not has_permission(thread['forum'], g.user, "p_create_polls"): + err = "you do not have permission to do that" + + if err is not None: + flash(err) + return fail + else: + cur = db.cursor() + cur.execute("INSERT INTO polls (title) VALUES (?)",(polltitle,)) + pollid = cur.lastrowid + cur.execute("UPDATE threads SET poll = ? WHERE threads.id = ?",(pollid,thread_id)) + cur.executemany( + "INSERT INTO poll_options (poll,option_idx,text) VALUES (?,?,?)", + zip(itertools.repeat(pollid),itertools.count(1),polloptions) + ) + db.commit() + flash("poll created successfully") + return success +@bp.route("/<int:thread_id>/delete_poll",methods=["POST"]) +def delete_poll(thread_id): + fail = redirect(url_for('thread.config_thread',thread_id=thread_id)) + success = redirect(url_for('thread.view_thread',thread_id=thread_id)) + err = None + db = get_db() + thread = db.execute('select * from threads where id = ?',(thread_id,)).fetchone() + + if thread is None: + err = "that thread does not exist" + elif g.user is None: + err = "you need to be logged in to do that" + elif g.user != thread['creator'] and not \ + has_permission(thread['forum'], g.user, "p_manage_threads"): + err = "you can only delete polls on threads that you own" + elif thread['poll'] is None: + err = "there is no poll to delete on this thread" + + if err is not None: + flash(err) + return fail + else: + pollid = thread['poll'] + db.execute("UPDATE posts SET vote = NULL WHERE thread = ?",(thread_id,)) # this assumes only max one poll per thread + db.execute("DELETE FROM votes WHERE poll = ?",(pollid,)) + db.execute("DELETE FROM poll_options WHERE poll = ?",(pollid,)) + db.execute("UPDATE THREADS set poll = NULL WHERE id = ?",(thread_id,)) + db.execute("DELETE FROM polls WHERE id = ?",(pollid,)) + db.commit() + flash("poll deleted successfully") + return success + @bp.route("/<int:thread_id>/create_post", methods=("POST",)) def create_post(thread_id): if g.user is None: @@ -46,12 +190,24 @@ def create_post(thread_id): flash("you do not have permission to do this") elif not has_permission(thread['forum'], g.user, "p_view_threads"): flash("you do not have permission to do this") + elif not has_permission(thread['forum'], g.user, "p_vote") \ + and 'poll' in request.form: + flash("you do not have permission to do this") else: + vote_id = None + if thread['poll'] is not None: + pollval = request.form.get('poll') + try: + vote_id = register_vote(thread,pollval) + except ValueError: + flash("invalid poll form value") + return redirect(url_for('thread.view_thread',thread_id=thread_id)) + cur = db.cursor() - cur.execute( - "INSERT INTO posts (thread,author,content,created) VALUES (?,?,?,current_timestamp);", - (thread_id,g.user,content) - ) + cur.execute(""" + INSERT INTO posts (thread,author,content,created,vote) + VALUES (?,?,?,current_timestamp,?); + """,(thread_id,g.user,content,vote_id)) post_id = cur.lastrowid cur.execute( "UPDATE threads SET updated = current_timestamp WHERE id = ?;", @@ -113,8 +269,7 @@ def edit_post(post_id): post = db.execute("SELECT * FROM posts WHERE id = ?",(post_id,)).fetchone() if post is None: flash("that post doesn't exist") - # todo: index route - return redirect("/") + return redirect(url_for('index')) if post['author'] != g.user: flash("you can only edit posts that you created") @@ -136,13 +291,26 @@ def edit_post(post_id): else: flash(err) return render_template("edit_post.html",post=post) + +@bp.route("/view_post/<int:post_id>") +def view_post(post_id): + db = get_db() + post = db.execute("SELECT * FROM posts WHERE id = ?",(post_id,)).fetchone() + if post is None: + flash("that post doesn't exist") + return redirect(url_for('index')) + + # when we have permissions, insert permissions check here + return render_template("view_post.html",post=post) + + @bp.route("/<int:thread_id>/config",methods=["GET","POST"]) def config_thread(thread_id): db = get_db() thread = db.execute("select * from threads where id = ?",(thread_id,)).fetchone() thread_tags = [r['tag'] for r in db.execute("select tag from thread_tags where thread = ?",(thread_id,)).fetchall()] - avail_tags = db.execute("select * from tags order by id").fetchall() + avail_tags = get_avail_tags(thread['forum']) err = None if g.user is None: err = "you need to be logged in to do that" @@ -164,8 +332,8 @@ def config_thread(thread_id): flash("title updated successfully") db.commit() changed = False - wanted_tags = [] - for tagid in range(1,len(avail_tags)+1): + for avail_tag in avail_tags: + tagid = avail_tag['id'] current = tagid in thread_tags wanted = f'tag_{tagid}' in request.form if wanted and not current: diff --git a/apioforum/util.py b/apioforum/util.py new file mode 100644 index 0000000..64bdf20 --- /dev/null +++ b/apioforum/util.py @@ -0,0 +1,16 @@ +# various utility things + +import hsluv +import hashlib + +# same algorithm as xep-0392 +def gen_colour(s): + b=s.encode("utf-8") + h=hashlib.sha1(b) + two_bytes=h.digest()[:2] + val = int.from_bytes(two_bytes, 'little') + angle = 360*(val/65536) + col = hsluv.hsluv_to_hex([angle, 80, 70]) + return col + + @@ -10,5 +10,6 @@ setup( 'markdown', 'bleach', 'pymdown-extensions', + 'hsluv', ], ) |