Compare commits

..

2 Commits

7 changed files with 129 additions and 188 deletions

View File

@ -5,7 +5,7 @@ Article query interface
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy import select, update
from amanuensis.db import *
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
@ -52,3 +52,13 @@ def try_from_public_id(db: DbContext, public_id: UUID) -> Optional[Article]:
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,73 @@
import re
from typing import Sequence
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
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
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 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}"))
# W: Check signature count
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"))
# E: tmp to test errors
if check_result.tmp:
messages.append(ConstraintMessage.error("Temporary error"))
return messages

View File

@ -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",

View File

@ -17,7 +17,7 @@
title: undefined,
rendered: undefined,
citations: [],
errors: []
messages: []
}
/** The nonce of the last-made update request. */
@ -33,13 +33,11 @@
// 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;
}
@ -51,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";
@ -69,7 +67,7 @@
// Fill in the status message block
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>";
document.getElementById("preview-control").innerHTML = statuses;
}
@ -82,7 +80,7 @@
preview.title = data.title;
preview.rendered = data.rendered;
preview.citations = data.citations;
preview.errors = data.errors;
preview.messages = data.messages;
refreshEditor();
}

View File

@ -4,8 +4,8 @@ from flask import Blueprint, render_template, g, abort, request
from amanuensis.backend import *
from amanuensis.db import *
from amanuensis.lexicon.constraint import constraint_check
from amanuensis.parser import *
from amanuensis.parser.core import Renderable
from amanuensis.server.helpers import lexicon_param, player_required
@ -59,51 +59,6 @@ class PreviewHtmlRenderer(RenderableVisitor):
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
@ -129,39 +84,65 @@ def open(lexicon_name, article_id):
@lexicon_param
@player_required
def load(lexicon_name, article_id):
# 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())
errors = constraint_check(parsed)
# Check article content against constraints
messages = constraint_check(parsed)
# Return the article information to the editor
msg_list = list([msg.json() for msg in messages])
return {
'title': article.title,
'rendered': preview_result.contents,
'state': article.state.value,
'ersatz': article.ersatz,
'citations': preview_result.citations,
'errors': errors,
'messages': msg_list,
}
@bp.post("/<uuid:article_id>/update")
def update(lexicon_name, article_id):
# Get the article
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)
# 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())
errors = constraint_check(parsed)
# Check article content against constraints
messages = 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,
'title': updated_article.title,
'rendered': preview_result.contents,
'state': article.state.value,
'ersatz': article.ersatz,
'state': updated_article.state.value,
'ersatz': updated_article.ersatz,
'citations': preview_result.citations,
'errors': errors,
'messages': msg_list,
}

View File

@ -1,122 +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 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,
# }