From 2bd75328a131bd466192e6ec51674fa9dcd12de0 Mon Sep 17 00:00:00 2001 From: Tim Van Baak Date: Wed, 13 Oct 2021 20:56:59 -0700 Subject: [PATCH] Implement draft previews and constraint analysis --- amanuensis/backend/article.py | 24 ++- amanuensis/lexicon/__init__.py | 0 amanuensis/lexicon/constraint.py | 151 +++++++++++++++ amanuensis/parser/__init__.py | 3 +- amanuensis/resources/editor.js | 12 +- amanuensis/server/lexicon.jinja | 2 +- amanuensis/server/lexicon/editor/__init__.py | 165 +++++++++++----- amanuensis/server/lexicon/editor/editor.py | 187 ------------------- 8 files changed, 307 insertions(+), 237 deletions(-) create mode 100644 amanuensis/lexicon/__init__.py create mode 100644 amanuensis/lexicon/constraint.py delete mode 100644 amanuensis/server/lexicon/editor/editor.py diff --git a/amanuensis/backend/article.py b/amanuensis/backend/article.py index 84d2572..f20169e 100644 --- a/amanuensis/backend/article.py +++ b/amanuensis/backend/article.py @@ -2,7 +2,10 @@ 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.errors import ArgumentError, BackendArgumentTypeError @@ -42,3 +45,22 @@ def create( db.session.add(new_article) db.session.commit() 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() diff --git a/amanuensis/lexicon/__init__.py b/amanuensis/lexicon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/amanuensis/lexicon/constraint.py b/amanuensis/lexicon/constraint.py new file mode 100644 index 0000000..17cb48e --- /dev/null +++ b/amanuensis/lexicon/constraint.py @@ -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 diff --git a/amanuensis/parser/__init__.py b/amanuensis/parser/__init__.py index 7aa5bd7..1faa490 100644 --- a/amanuensis/parser/__init__.py +++ b/amanuensis/parser/__init__.py @@ -2,11 +2,12 @@ Module encapsulating all markdown parsing functionality. """ -from .core import RenderableVisitor +from .core import RenderableVisitor, Renderable from .helpers import normalize_title, filesafe_title, titlesort from .parsing import parse_raw_markdown __all__ = [ + "Renderable", "RenderableVisitor", "normalize_title", "filesafe_title", diff --git a/amanuensis/resources/editor.js b/amanuensis/resources/editor.js index fcdbcf2..e09a7ab 100644 --- a/amanuensis/resources/editor.js +++ b/amanuensis/resources/editor.js @@ -17,7 +17,7 @@ title: undefined, rendered: undefined, citations: [], - errors: [] + messages: [] } /** The nonce of the last-made update request. */ @@ -49,7 +49,7 @@ { // Enable or disable controls 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-content").disabled = !isEditable; document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article"; @@ -67,7 +67,7 @@ // Fill in the status message block let statuses = "
    "; - preview.errors.forEach(err => statuses += "
  1. " + JSON.stringify(err) + "
  2. "); + preview.messages.forEach(err => statuses += "
  3. " + JSON.stringify(err) + "
  4. "); statuses += "
      "; document.getElementById("preview-control").innerHTML = statuses; } @@ -80,7 +80,7 @@ preview.title = data.title; preview.rendered = data.rendered; preview.citations = data.citations; - preview.errors = data.errors; + preview.messages = data.messages; refreshEditor(); } @@ -110,7 +110,9 @@ function submitArticle() { ifNoFurtherChanges(() => { - article.state = ArticleState.SUBMITTED; + article.state = (article.state == ArticleState.DRAFT + ? ArticleState.SUBMITTED + : ArticleState.DRAFT); update(); }, /* timeout: */ 0); diff --git a/amanuensis/server/lexicon.jinja b/amanuensis/server/lexicon.jinja index 333f8f4..54aabff 100644 --- a/amanuensis/server/lexicon.jinja +++ b/amanuensis/server/lexicon.jinja @@ -16,7 +16,7 @@ {% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}" {% endif %}>Contents{% endblock %} {% block sb_editor %}Editor{% endblock %} {% block sb_posts %} 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 "
      " + + # Translate the simple container spans to text + def BoldSpan(self, span): + return f'{"".join(span.recurse(self))}' + + def ItalicSpan(self, span): + return f'{"".join(span.recurse(self))}' + + # 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'{"".join(span.recurse(self))}[{len(self.citations)}]' + + # Translate the paragraph-level containers to their text contents + def BodyParagraph(self, span): + return f'

      {"".join(span.recurse(self))}

      ' + + def SignatureParagraph(self, span): + return ( + '

      ' + f'{"".join(span.recurse(self))}' + "

      " + ) + + # 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("/") @lexicon_param @player_required def open(lexicon_name, article_id): - db: DbContext = g.db - article: Article = db( - select(Article).where(Article.public_id == article_id) - ).scalar_one() + article = artiq.try_from_public_id(g.db, article_id) + if not article: + return abort(404) return render_template( "session.editor.jinja", lexicon_name=lexicon_name, @@ -29,46 +83,73 @@ def open(lexicon_name, article_id): @lexicon_param @player_required def load(lexicon_name, article_id): - db: DbContext = g.db - article: Article = db( - select(Article).where(Article.public_id == article_id) - ).scalar_one() - citations = [ - {'title': 'Citation Title', 'type': 'phantom'} - ] - errors = [ - {'severity': 0, 'message': "OK"}, - {'severity': 1, 'message': "Warning"}, - {'severity': 2, 'message': "Error"}, - ] + # Get the article + article = artiq.try_from_public_id(g.db, article_id) + if not article: + return abort(404) + + # Generate the preview HTML + parsed = parse_raw_markdown(article.body) + preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer()) + + # Check article content against constraints + 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 { - 'title': article.title, - 'rendered': article.body, - 'state': article.state.value, - 'ersatz': article.ersatz, - 'citations': citations, - 'errors': errors, + "title": article.title, + "rendered": preview_result.contents, + "state": article.state.value, + "ersatz": article.ersatz, + "citations": preview_result.citations, + "messages": msg_list, } @bp.post("//update") def update(lexicon_name, article_id): - db: DbContext = g.db - article: Article = db( - select(Article).where(Article.public_id == article_id) - ).scalar_one() - citations = [ - {'title': 'Citation Title', 'type': 'phantom'} - ] - errors = [ - {'severity': 0, 'message': "OK"}, - {'severity': 1, 'message': "Warning"}, - ] + # Get the article + article = artiq.try_from_public_id(g.db, article_id) + if not article: + return abort(404) + + # Extract the submitted content + new_title = request.json["title"] + new_body = request.json["body"] + new_state = ArticleState(request.json["state"]) + + # 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 { - 'title': article.title, - 'rendered': article.body, - 'state': article.state.value, - 'ersatz': article.ersatz, - 'citations': citations, - 'errors': errors, + "title": updated_article.title, + "rendered": preview_result.contents, + "state": updated_article.state.value, + "ersatz": updated_article.ersatz, + "citations": preview_result.citations, + "messages": msg_list, } diff --git a/amanuensis/server/lexicon/editor/editor.py b/amanuensis/server/lexicon/editor/editor.py deleted file mode 100644 index 79a3cb3..0000000 --- a/amanuensis/server/lexicon/editor/editor.py +++ /dev/null @@ -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 '
      ' - - def ParsedArticle(self, span): - self.contents = '\n'.join(span.recurse(self)) - return self - - def BodyParagraph(self, span): - return f'

      {"".join(span.recurse(self))}

      ' - - def SignatureParagraph(self, span): - return ( - '

      ' - f'{"".join(span.recurse(self))}' - '

      ' - ) - - def BoldSpan(self, span): - return f'{"".join(span.recurse(self))}' - - def ItalicSpan(self, span): - return f'{"".join(span.recurse(self))}' - - 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'{"".join(span.recurse(self))}[{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, - }