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 typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import select from sqlalchemy import select, update
from amanuensis.db import * from amanuensis.db import *
from amanuensis.errors import ArgumentError, BackendArgumentTypeError from amanuensis.errors import ArgumentError, BackendArgumentTypeError
@ -52,3 +52,13 @@ def try_from_public_id(db: DbContext, public_id: UUID) -> Optional[Article]:
return db( return db(
select(Article).where(Article.public_id == public_id) select(Article).where(Article.public_id == public_id)
).scalar_one_or_none() ).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. Module encapsulating all markdown parsing functionality.
""" """
from .core import RenderableVisitor from .core import RenderableVisitor, Renderable
from .helpers import normalize_title, filesafe_title, titlesort from .helpers import normalize_title, filesafe_title, titlesort
from .parsing import parse_raw_markdown from .parsing import parse_raw_markdown
__all__ = [ __all__ = [
"Renderable",
"RenderableVisitor", "RenderableVisitor",
"normalize_title", "normalize_title",
"filesafe_title", "filesafe_title",

View File

@ -17,7 +17,7 @@
title: undefined, title: undefined,
rendered: undefined, rendered: undefined,
citations: [], citations: [],
errors: [] messages: []
} }
/** The nonce of the last-made update request. */ /** The nonce of the last-made update request. */
@ -33,13 +33,11 @@
// 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;
} }
@ -51,7 +49,7 @@
{ {
// Enable or disable controls // Enable or disable controls
const isEditable = article.state == ArticleState.DRAFT; 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-title").disabled = !isEditable;
document.getElementById("editor-content").disabled = !isEditable; document.getElementById("editor-content").disabled = !isEditable;
document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article"; document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article";
@ -69,7 +67,7 @@
// Fill in the status message block // Fill in the status message block
let statuses = "<ol>"; 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>"; statuses += "<ol>";
document.getElementById("preview-control").innerHTML = statuses; document.getElementById("preview-control").innerHTML = statuses;
} }
@ -82,7 +80,7 @@
preview.title = data.title; preview.title = data.title;
preview.rendered = data.rendered; preview.rendered = data.rendered;
preview.citations = data.citations; preview.citations = data.citations;
preview.errors = data.errors; preview.messages = data.messages;
refreshEditor(); refreshEditor();
} }

View File

@ -4,8 +4,8 @@ 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.lexicon.constraint import constraint_check
from amanuensis.parser 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
@ -59,51 +59,6 @@ class PreviewHtmlRenderer(RenderableVisitor):
return 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("/") @bp.get("/")
@lexicon_param @lexicon_param
@player_required @player_required
@ -129,39 +84,65 @@ 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):
# Get the article
article = artiq.try_from_public_id(g.db, article_id) article = artiq.try_from_public_id(g.db, article_id)
if not article: if not article:
return abort(404) return abort(404)
# Generate the preview HTML
parsed = parse_raw_markdown(article.body) parsed = parse_raw_markdown(article.body)
preview_result: PreviewHtmlRenderer = parsed.render(PreviewHtmlRenderer()) 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 { return {
'title': article.title, 'title': article.title,
'rendered': preview_result.contents, 'rendered': preview_result.contents,
'state': article.state.value, 'state': article.state.value,
'ersatz': article.ersatz, 'ersatz': article.ersatz,
'citations': preview_result.citations, 'citations': preview_result.citations,
'errors': errors, 'messages': msg_list,
} }
@bp.post("/<uuid:article_id>/update") @bp.post("/<uuid:article_id>/update")
def update(lexicon_name, article_id): def update(lexicon_name, article_id):
# Get the article
article = artiq.try_from_public_id(g.db, article_id) article = artiq.try_from_public_id(g.db, article_id)
if not article: if not article:
return abort(404) return abort(404)
article.title = request.json['title']
article.body = request.json['body'] # Extract the submitted content
article.state = ArticleState(request.json['state']) new_title = request.json['title']
g.db.session.commit() new_body = request.json['body']
parsed = parse_raw_markdown(article.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()) 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 { return {
'title': article.title, 'title': updated_article.title,
'rendered': preview_result.contents, 'rendered': preview_result.contents,
'state': article.state.value, 'state': updated_article.state.value,
'ersatz': article.ersatz, 'ersatz': updated_article.ersatz,
'citations': preview_result.citations, '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,
# }