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 = "
{"".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("/{"".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, - }