diff --git a/amanuensis/backend/article.py b/amanuensis/backend/article.py index 84d2572..a21177e 100644 --- a/amanuensis/backend/article.py +++ b/amanuensis/backend/article.py @@ -2,6 +2,9 @@ Article query interface """ +from typing import Optional +from uuid import UUID + from sqlalchemy import select from amanuensis.db import * @@ -42,3 +45,10 @@ 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() diff --git a/amanuensis/resources/editor.js b/amanuensis/resources/editor.js index 3836ef0..00087aa 100644 --- a/amanuensis/resources/editor.js +++ b/amanuensis/resources/editor.js @@ -33,11 +33,13 @@ // Stake a claim on the nonce, potentially overwriting a previous // nonce value. const nonce_local = 1 + Math.random(); + console.log("ifNoFurtherChanges: timeout=" + timeout.toString() + ", nonce=" + nonce_local.toString()); nonce = nonce_local; // Wait to see if this call is overwritten in turn. setTimeout(() => { if (nonce == nonce_local) { + console.log("Executing for: " + nonce.toString()); callback(); nonce = 0; } @@ -102,9 +104,9 @@ }).then(response => response.json()).then(updateState); } - function onContentChange() + function onContentChange(e, timeout=2000) { - ifNoFurtherChanges(update, /* timeout: */ 2000); + ifNoFurtherChanges(update, timeout); } function submitArticle() @@ -140,7 +142,7 @@ if (e.ctrlKey && e.key == 's') { e.preventDefault(); - onContentChange(0); + onContentChange(e, 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 + + +class ConstraintCheck(RenderableVisitor): + """Analyzes an article for content-based constraint violations.""" + def __init__(self) -> None: + self.word_count: int = 0 + self.signatures: int = 0 + self.tmp: bool = False + + def TextSpan(self, span): + self.word_count += len(re.split(r'\s+', span.innertext.strip())) + return self + + def BoldSpan(self, span): + self.tmp = True + return self + + def SignatureParagraph(self, span): + self.signatures += 1 + return self + + +def constraint_check(parsed: Renderable): + check_result: ConstraintCheck = parsed.render(ConstraintCheck()) + errors = [] + errors.append({ + "severity": 0, + "message": f"Word count: {check_result.word_count}" + }) + if check_result.signatures < 1: + errors.append({ + "severity": 1, + "message": "Missing signature paragraph" + }) + if check_result.signatures > 1: + errors.append({ + "severity": 1, + "message": "More than one signature paragraph" + }) + if check_result.tmp: + errors.append({ + "severity": 2, + "message": "Temporary error" + }) + return errors + + +@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 +129,39 @@ 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"}, - ] + article = artiq.try_from_public_id(g.db, article_id) + if not article: + return abort(404) + parsed = parse_raw_markdown(article.body) + preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer()) + errors = constraint_check(parsed) return { 'title': article.title, - 'rendered': article.body, + 'rendered': preview_result.contents, 'state': article.state.value, 'ersatz': article.ersatz, - 'citations': citations, + 'citations': preview_result.citations, 'errors': errors, } @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"}, - ] + article = artiq.try_from_public_id(g.db, article_id) + if not article: + return abort(404) + article.title = request.json['title'] + article.body = request.json['body'] + article.state = ArticleState(request.json['state']) + g.db.session.commit() + parsed = parse_raw_markdown(article.body) + preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer()) + errors = constraint_check(parsed) return { 'title': article.title, - 'rendered': article.body, + 'rendered': preview_result.contents, 'state': article.state.value, 'ersatz': article.ersatz, - 'citations': citations, + 'citations': preview_result.citations, 'errors': errors, } diff --git a/amanuensis/server/lexicon/editor/editor.py b/amanuensis/server/lexicon/editor/editor.py index 79a3cb3..027d096 100644 --- a/amanuensis/server/lexicon/editor/editor.py +++ b/amanuensis/server/lexicon/editor/editor.py @@ -1,187 +1,122 @@ -""" -Handler helper functions pertaining to the article editor -""" -import json -import uuid +# """ +# 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 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 +# 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 = "" +# 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 TextSpan(self, span): +# return span.innertext - def LineBreak(self, span): - return '
' +# def LineBreak(self, span): +# return '
' - def ParsedArticle(self, span): - self.contents = '\n'.join(span.recurse(self)) - return self +# def ParsedArticle(self, span): +# self.contents = '\n'.join(span.recurse(self)) +# return self - def BodyParagraph(self, span): - return f'

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

' +# def BodyParagraph(self, span): +# return f'

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

' - def SignatureParagraph(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 BoldSpan(self, span): +# return f'{"".join(span.recurse(self))}' - def ItalicSpan(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 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))) +# 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") - # 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) +# # Perform the update +# title = article_json.get('title') +# contents = article_json.get('contents') +# status = article_json.get('status') +# parsed = parse_raw_markdown(contents) -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)) +# # 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 - # Character not specified - flash('Character not found') - return redirect(url_for('session.session', name=lexicon.cfg.name)) +# # 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) - -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, - } +# # 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, +# }