Implement draft previews and constraint analysis

This commit is contained in:
Tim Van Baak 2021-10-13 20:56:59 -07:00
parent 675e42cfa3
commit 2bd75328a1
8 changed files with 307 additions and 237 deletions

View File

@ -2,7 +2,10 @@
Article query interface Article query interface
""" """
from sqlalchemy import select from typing import Optional
from uuid import UUID
from sqlalchemy import select, update
from amanuensis.db import * from amanuensis.db import *
from amanuensis.errors import ArgumentError, BackendArgumentTypeError from amanuensis.errors import ArgumentError, BackendArgumentTypeError
@ -42,3 +45,22 @@ def create(
db.session.add(new_article) db.session.add(new_article)
db.session.commit() db.session.commit()
return new_article return new_article
def try_from_public_id(db: DbContext, public_id: UUID) -> Optional[Article]:
"""Get an article by its public id."""
return db(
select(Article).where(Article.public_id == public_id)
).scalar_one_or_none()
def update_state(
db: DbContext, article_id: int, title: str, body: str, state: ArticleState
):
"""Update an article."""
db(
update(Article)
.where(Article.id == article_id)
.values(title=title, body=body, state=state)
)
db.session.commit()

View File

View File

@ -0,0 +1,151 @@
import re
from typing import Sequence
from amanuensis.db import *
from amanuensis.parser import *
class ConstraintCheck(RenderableVisitor):
"""Analyzes an article for content-based constraint violations."""
def __init__(self) -> None:
self.word_count: int = 0
self.signatures: int = 0
def TextSpan(self, span):
self.word_count += len(re.split(r'\s+', span.innertext.strip()))
return self
def SignatureParagraph(self, span):
self.signatures += 1
span.recurse(self)
return self
class ConstraintMessage:
INFO = 0
WARNING = 1
ERROR = 2
def __init__(self, severity: int, message: str) -> None:
self.severity = severity
self.message = message
@staticmethod
def info(message) -> "ConstraintMessage":
return ConstraintMessage(ConstraintMessage.INFO, message)
@staticmethod
def warning(message) -> "ConstraintMessage":
return ConstraintMessage(ConstraintMessage.WARNING, message)
@staticmethod
def error(message) -> "ConstraintMessage":
return ConstraintMessage(ConstraintMessage.ERROR, message)
@property
def is_error(self) -> bool:
return self.severity == ConstraintMessage.ERROR
def json(self):
return {"severity": self.severity, "message": self.message}
def title_constraint_check(title: str) -> Sequence[ConstraintMessage]:
"""Perform checks that apply to the article title."""
messages = []
# I: Current index assignments
# TODO
# E: No title
if not title:
messages.append(ConstraintMessage.error("Missing title"))
# I: This article is new
# TODO
# E: And new articles are forbidden
# TODO
# I: This article is a phantom
# TODO
# I: This article is an addendum
# TODO
# E: And the user is at the addendum limit
# TODO
# E: This article has already been written and addendums are disabled
# TODO
# I: This article's index
# TODO
# E: The article does not fulfill the player's index assignment
# TODO
# E: The index does not have room for a new article
# TODO
# E: The player has cited this phantom article before
# TODO
# W: Another player is writing an article with this title
# TODO
# E: Another player has an approved article with this title
# TODO
# W: The article's title matches a character's name
# TODO
return messages
def content_constraint_check(parsed: Renderable) -> Sequence[ConstraintMessage]:
check_result: ConstraintCheck = parsed.render(ConstraintCheck())
messages = []
# I: Word count
messages.append(ConstraintMessage.info(f"Word count: {check_result.word_count}"))
# E: Self-citation when forbidden
# TODO
# W: A new citation matches a character's name
# TODO
# W: The article cites itself
# TODO
# E: A new citation would create more articles than can be written
# TODO
# E: Extant citation count requirements
# TODO
# E: Phantom citation count requirements
# TODO
# E: New citation count requirements
# TODO
# E: Character citation count requirements
# TODO
# E: Total citation count requirements
# TODO
# E: Exceeded hard word limit
# TODO
# W: Exceeded soft word limit
# TODO
# W: Missing or multiple signatures
if check_result.signatures < 1:
messages.append(ConstraintMessage.warning("Missing signature paragraph"))
if check_result.signatures > 1:
messages.append(ConstraintMessage.warning("More than one signature paragraph"))
return messages

View File

@ -2,11 +2,12 @@
Module encapsulating all markdown parsing functionality. Module encapsulating all markdown parsing functionality.
""" """
from .core import RenderableVisitor from .core import RenderableVisitor, Renderable
from .helpers import normalize_title, filesafe_title, titlesort from .helpers import normalize_title, filesafe_title, titlesort
from .parsing import parse_raw_markdown from .parsing import parse_raw_markdown
__all__ = [ __all__ = [
"Renderable",
"RenderableVisitor", "RenderableVisitor",
"normalize_title", "normalize_title",
"filesafe_title", "filesafe_title",

View File

@ -17,7 +17,7 @@
title: undefined, title: undefined,
rendered: undefined, rendered: undefined,
citations: [], citations: [],
errors: [] messages: []
} }
/** The nonce of the last-made update request. */ /** The nonce of the last-made update request. */
@ -49,7 +49,7 @@
{ {
// Enable or disable controls // Enable or disable controls
const isEditable = article.state == ArticleState.DRAFT; const isEditable = article.state == ArticleState.DRAFT;
const blocked = preview.errors.filter(err => err.severity == 2).length > 0; const blocked = preview.messages.filter(msg => msg.severity == 2).length > 0;
document.getElementById("editor-title").disabled = !isEditable; document.getElementById("editor-title").disabled = !isEditable;
document.getElementById("editor-content").disabled = !isEditable; document.getElementById("editor-content").disabled = !isEditable;
document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article"; document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article";
@ -67,7 +67,7 @@
// Fill in the status message block // Fill in the status message block
let statuses = "<ol>"; let statuses = "<ol>";
preview.errors.forEach(err => statuses += "<li>" + JSON.stringify(err) + "</li>"); preview.messages.forEach(err => statuses += "<li>" + JSON.stringify(err) + "</li>");
statuses += "<ol>"; statuses += "<ol>";
document.getElementById("preview-control").innerHTML = statuses; document.getElementById("preview-control").innerHTML = statuses;
} }
@ -80,7 +80,7 @@
preview.title = data.title; preview.title = data.title;
preview.rendered = data.rendered; preview.rendered = data.rendered;
preview.citations = data.citations; preview.citations = data.citations;
preview.errors = data.errors; preview.messages = data.messages;
refreshEditor(); refreshEditor();
} }
@ -110,7 +110,9 @@
function submitArticle() function submitArticle()
{ {
ifNoFurtherChanges(() => { ifNoFurtherChanges(() => {
article.state = ArticleState.SUBMITTED; article.state = (article.state == ArticleState.DRAFT
? ArticleState.SUBMITTED
: ArticleState.DRAFT);
update(); update();
}, },
/* timeout: */ 0); /* timeout: */ 0);

View File

@ -16,7 +16,7 @@
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}" {% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
{% endif %}>Contents</a>{% endblock %} {% endif %}>Contents</a>{% endblock %}
{% block sb_editor %}<a {% block sb_editor %}<a
href="{{ url_for('lexicon.editor.editor', lexicon_name=g.lexicon.name) }}" href="{{ url_for('lexicon.editor.select', lexicon_name=g.lexicon.name) }}"
>Editor</a>{% endblock %} >Editor</a>{% endblock %}
{% block sb_posts %}<a {% block sb_posts %}<a
{% if current_page == "posts" %}class="current-page" {% if current_page == "posts" %}class="current-page"

View File

@ -1,23 +1,77 @@
from flask import Blueprint, render_template, g from flask import Blueprint, render_template, g, abort, request
from sqlalchemy import select
from amanuensis.backend import * from amanuensis.backend import *
from amanuensis.db import * from amanuensis.db import *
from amanuensis.parser.core import * from amanuensis.lexicon.constraint import (
title_constraint_check,
content_constraint_check,
)
from amanuensis.parser import *
from amanuensis.server.helpers import lexicon_param, player_required from amanuensis.server.helpers import lexicon_param, player_required
bp = Blueprint("editor", __name__, url_prefix="/editor", template_folder=".") bp = Blueprint("editor", __name__, url_prefix="/editor", template_folder=".")
class PreviewHtmlRenderer(RenderableVisitor):
"""Parses stylistic markdown and stores citations as footnotes."""
def __init__(self) -> None:
self.citations: list = []
self.rendered: str = ""
# Translate the leaf spans to text
def TextSpan(self, span):
return span.innertext
def LineBreak(self, span):
return "<br>"
# Translate the simple container spans to text
def BoldSpan(self, span):
return f'<b>{"".join(span.recurse(self))}</b>'
def ItalicSpan(self, span):
return f'<i>{"".join(span.recurse(self))}</i>'
# Record citations in the visitor, then translate the span to text as an
# underline and footnote number
def CitationSpan(self, span):
self.citations.append({"title": span.cite_target, "type": "phantom"})
return f'<u>{"".join(span.recurse(self))}</u>[{len(self.citations)}]'
# Translate the paragraph-level containers to their text contents
def BodyParagraph(self, span):
return f'<p>{"".join(span.recurse(self))}</p>'
def SignatureParagraph(self, span):
return (
'<hr><span class="signature"><p>'
f'{"".join(span.recurse(self))}'
"</p></span>"
)
# Return the visitor from the top-level article span after saving the full
# text parsed from the child spans
def ParsedArticle(self, span):
self.contents = "\n".join(span.recurse(self))
return self
@bp.get("/")
@lexicon_param
@player_required
def select(lexicon_name):
return {}
@bp.get("/<uuid:article_id>") @bp.get("/<uuid:article_id>")
@lexicon_param @lexicon_param
@player_required @player_required
def open(lexicon_name, article_id): def open(lexicon_name, article_id):
db: DbContext = g.db article = artiq.try_from_public_id(g.db, article_id)
article: Article = db( if not article:
select(Article).where(Article.public_id == article_id) return abort(404)
).scalar_one()
return render_template( return render_template(
"session.editor.jinja", "session.editor.jinja",
lexicon_name=lexicon_name, lexicon_name=lexicon_name,
@ -29,46 +83,73 @@ def open(lexicon_name, article_id):
@lexicon_param @lexicon_param
@player_required @player_required
def load(lexicon_name, article_id): def load(lexicon_name, article_id):
db: DbContext = g.db # Get the article
article: Article = db( article = artiq.try_from_public_id(g.db, article_id)
select(Article).where(Article.public_id == article_id) if not article:
).scalar_one() return abort(404)
citations = [
{'title': 'Citation Title', 'type': 'phantom'} # Generate the preview HTML
] parsed = parse_raw_markdown(article.body)
errors = [ preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer())
{'severity': 0, 'message': "OK"},
{'severity': 1, 'message': "Warning"}, # Check article content against constraints
{'severity': 2, 'message': "Error"}, messages = title_constraint_check(article.title)
] messages.extend(content_constraint_check(parsed))
# Return the article information to the editor
msg_list = list([msg.json() for msg in messages])
return { return {
'title': article.title, "title": article.title,
'rendered': article.body, "rendered": preview_result.contents,
'state': article.state.value, "state": article.state.value,
'ersatz': article.ersatz, "ersatz": article.ersatz,
'citations': citations, "citations": preview_result.citations,
'errors': errors, "messages": msg_list,
} }
@bp.post("/<uuid:article_id>/update") @bp.post("/<uuid:article_id>/update")
def update(lexicon_name, article_id): def update(lexicon_name, article_id):
db: DbContext = g.db # Get the article
article: Article = db( article = artiq.try_from_public_id(g.db, article_id)
select(Article).where(Article.public_id == article_id) if not article:
).scalar_one() return abort(404)
citations = [
{'title': 'Citation Title', 'type': 'phantom'} # Extract the submitted content
] new_title = request.json["title"]
errors = [ new_body = request.json["body"]
{'severity': 0, 'message': "OK"}, new_state = ArticleState(request.json["state"])
{'severity': 1, 'message': "Warning"},
] # Generate the preview HTML from the submitted content
parsed = parse_raw_markdown(new_body)
preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer())
# Check article content against constraints
messages = title_constraint_check(new_title)
messages.extend(content_constraint_check(parsed))
# Block submission if the article is a draft with errors
has_errors = any([msg for msg in messages if msg.is_error])
if (
article.state == ArticleState.DRAFT
and new_state != ArticleState.DRAFT
and has_errors
):
new_state = ArticleState.DRAFT
# Update the article with the submitted information
artiq.update_state(
g.db, article.id, title=new_title, body=new_body, state=new_state
)
updated_article = artiq.try_from_public_id(g.db, article_id)
# Return the article information to the editor
msg_list = list([msg.json() for msg in messages])
return { return {
'title': article.title, "title": updated_article.title,
'rendered': article.body, "rendered": preview_result.contents,
'state': article.state.value, "state": updated_article.state.value,
'ersatz': article.ersatz, "ersatz": updated_article.ersatz,
'citations': citations, "citations": preview_result.citations,
'errors': errors, "messages": msg_list,
} }

View File

@ -1,187 +0,0 @@
"""
Handler helper functions pertaining to the article editor
"""
import json
import uuid
from flask import (
flash, redirect, url_for, render_template, Markup)
from flask_login import current_user
from amanuensis.lexicon import (
get_player_characters,
get_player_drafts,
get_draft,
title_constraint_analysis,
content_constraint_analysis)
from amanuensis.models import LexiconModel
from amanuensis.parser import (
normalize_title,
parse_raw_markdown)
from amanuensis.parser.core import RenderableVisitor
class PreviewHtmlRenderer(RenderableVisitor):
def __init__(self, lexicon):
with lexicon.ctx.read('info') as info:
self.article_map = {
title: article.character
for title, article in info.items()
}
self.citations = []
self.contents = ""
def TextSpan(self, span):
return span.innertext
def LineBreak(self, span):
return '<br>'
def ParsedArticle(self, span):
self.contents = '\n'.join(span.recurse(self))
return self
def BodyParagraph(self, span):
return f'<p>{"".join(span.recurse(self))}</p>'
def SignatureParagraph(self, span):
return (
'<hr><span class="signature"><p>'
f'{"".join(span.recurse(self))}'
'</p></span>'
)
def BoldSpan(self, span):
return f'<b>{"".join(span.recurse(self))}</b>'
def ItalicSpan(self, span):
return f'<i>{"".join(span.recurse(self))}</i>'
def CitationSpan(self, span):
if span.cite_target in self.article_map:
if self.article_map.get(span.cite_target):
link_class = '[extant]'
else:
link_class = '[phantom]'
else:
link_class = '[new]'
self.citations.append(f'{span.cite_target} {link_class}')
return f'<u>{"".join(span.recurse(self))}</u>[{len(self.citations)}]'
def load_editor(lexicon: LexiconModel, aid: str):
"""
Load the editor page
"""
if aid:
# Article specfied, load editor in edit mode
article = get_draft(lexicon, aid)
if not article:
flash("Draft not found")
return redirect(url_for('session.session', name=lexicon.cfg.name))
# Check that the player owns this article
character = lexicon.cfg.character.get(article.character)
if character.player != current_user.uid:
flash("Access forbidden")
return redirect(url_for('session.session', name=lexicon.cfg.name))
return render_template(
'session.editor.jinja',
character=character,
article=article,
jsonfmt=lambda obj: Markup(json.dumps(obj)))
# Article not specified, load editor in load mode
characters = list(get_player_characters(lexicon, current_user.uid))
articles = list(get_player_drafts(lexicon, current_user.uid))
return render_template(
'session.editor.jinja',
characters=characters,
articles=articles)
def new_draft(lexicon: LexiconModel, cid: str):
"""
Create a new draft and open it in the editor
"""
if cid:
new_aid = uuid.uuid4().hex
# TODO harden this
character = lexicon.cfg.character.get(cid)
article = {
"version": "0",
"aid": new_aid,
"lexicon": lexicon.lid,
"character": cid,
"title": "",
"turn": 1,
"status": {
"ready": False,
"approved": False
},
"contents": f"\n\n{character.signature}",
}
filename = f"{cid}.{new_aid}"
with lexicon.ctx.draft.new(filename) as j:
j.update(article)
return redirect(url_for(
'session.editor',
name=lexicon.cfg.name,
cid=cid,
aid=new_aid))
# Character not specified
flash('Character not found')
return redirect(url_for('session.session', name=lexicon.cfg.name))
def update_draft(lexicon: LexiconModel, article_json):
"""
Update a draft and perform analysis on it
"""
# Check if the update is permitted
aid = article_json.get('aid')
article = get_draft(lexicon, aid)
if not article:
raise ValueError("missing article")
if lexicon.cfg.character.get(article.character).player != current_user.uid:
return ValueError("bad user")
if article.status.approved:
raise ValueError("bad status")
# Perform the update
title = article_json.get('title')
contents = article_json.get('contents')
status = article_json.get('status')
parsed = parse_raw_markdown(contents)
# HTML parsing
preview = parsed.render(PreviewHtmlRenderer(lexicon))
# Constraint analysis
title_infos, title_warnings, title_errors = title_constraint_analysis(
lexicon, current_user, article.character, title)
content_infos, content_warnings, content_errors = content_constraint_analysis(
lexicon, current_user, article.character, parsed)
if any(title_errors) or any(content_errors):
status['ready'] = False
# Article update
filename = f'{article.character}.{aid}'
with lexicon.ctx.draft.edit(filename) as draft:
draft.title = normalize_title(title)
draft.contents = contents
draft.status.ready = status.get('ready', False)
# Return canonical information to editor
return {
'title': draft.title,
'status': {
'ready': draft.status.ready,
'approved': draft.status.approved,
},
'rendered': preview.contents,
'citations': preview.citations,
'info': title_infos + content_infos,
'warning': title_warnings + content_warnings,
'error': title_errors + content_errors,
}