This commit is contained in:
Tim Van Baak 2021-10-11 09:09:33 -07:00
parent 241ec45430
commit 837ce735aa
5 changed files with 246 additions and 206 deletions

View File

@ -2,6 +2,9 @@
Article query interface Article query interface
""" """
from typing import Optional
from uuid import UUID
from sqlalchemy import select from sqlalchemy import select
from amanuensis.db import * from amanuensis.db import *
@ -42,3 +45,10 @@ 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()

View File

@ -33,11 +33,13 @@
// Stake a claim on the nonce, potentially overwriting a previous // Stake a claim on the nonce, potentially overwriting a previous
// nonce value. // nonce value.
const nonce_local = 1 + Math.random(); const nonce_local = 1 + Math.random();
console.log("ifNoFurtherChanges: timeout=" + timeout.toString() + ", nonce=" + nonce_local.toString());
nonce = nonce_local; nonce = nonce_local;
// Wait to see if this call is overwritten in turn. // Wait to see if this call is overwritten in turn.
setTimeout(() => { setTimeout(() => {
if (nonce == nonce_local) if (nonce == nonce_local)
{ {
console.log("Executing for: " + nonce.toString());
callback(); callback();
nonce = 0; nonce = 0;
} }
@ -102,9 +104,9 @@
}).then(response => response.json()).then(updateState); }).then(response => response.json()).then(updateState);
} }
function onContentChange() function onContentChange(e, timeout=2000)
{ {
ifNoFurtherChanges(update, /* timeout: */ 2000); ifNoFurtherChanges(update, timeout);
} }
function submitArticle() function submitArticle()
@ -140,7 +142,7 @@
if (e.ctrlKey && e.key == 's') if (e.ctrlKey && e.key == 's')
{ {
e.preventDefault(); e.preventDefault();
onContentChange(0); onContentChange(e, 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,123 @@
from flask import Blueprint, render_template, g import re
from sqlalchemy import select
from flask import Blueprint, render_template, g, abort, request
from amanuensis.backend import * from amanuensis.backend import *
from amanuensis.db import * from amanuensis.db import *
from amanuensis.parser.core import * from amanuensis.parser import *
from amanuensis.parser.core import Renderable
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
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("/<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 +129,39 @@ 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 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() parsed = parse_raw_markdown(article.body)
citations = [ preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer())
{'title': 'Citation Title', 'type': 'phantom'} errors = constraint_check(parsed)
]
errors = [
{'severity': 0, 'message': "OK"},
{'severity': 1, 'message': "Warning"},
{'severity': 2, 'message': "Error"},
]
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, 'errors': errors,
} }
@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 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() article.title = request.json['title']
citations = [ article.body = request.json['body']
{'title': 'Citation Title', 'type': 'phantom'} article.state = ArticleState(request.json['state'])
] g.db.session.commit()
errors = [ parsed = parse_raw_markdown(article.body)
{'severity': 0, 'message': "OK"}, preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer())
{'severity': 1, 'message': "Warning"}, errors = constraint_check(parsed)
]
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, 'errors': errors,
} }

View File

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