Compare commits
2 Commits
837ce735aa
...
6aadbaaf0c
Author | SHA1 | Date |
---|---|---|
Tim Van Baak | 6aadbaaf0c | |
Tim Van Baak | 675e42cfa3 |
|
@ -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,20 @@ 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()
|
||||
|
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
@ -1,76 +1,75 @@
|
|||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
}
|
||||
div#wrapper {
|
||||
max-width: 1128px;
|
||||
height: 100%;
|
||||
display:flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
}
|
||||
div.column {
|
||||
width: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
div#editor-left {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
div#editor-left section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 10px 5px 10px 10px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 10px 5px 10px 10px;
|
||||
width: 100%;
|
||||
padding: 5px;
|
||||
}
|
||||
div#editor-left div#editor-header {
|
||||
margin: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
div#editor-left div#editor-charselect {
|
||||
margin: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
div#editor-left div#editor-buttons {
|
||||
margin: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
div#editor-left input#editor-title {
|
||||
font-size: 2em;
|
||||
margin: 5px;
|
||||
font-size: 2em;
|
||||
margin: 5px;
|
||||
}
|
||||
textarea#editor-content {
|
||||
margin: 5px;
|
||||
resize: none;
|
||||
flex-grow: 1;
|
||||
width: initial;
|
||||
margin: 5px;
|
||||
resize: none;
|
||||
flex-grow: 1;
|
||||
width: initial;
|
||||
}
|
||||
div#editor-right {
|
||||
overflow-y: scroll;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
div#editor-right section {
|
||||
margin: 10px 5px 10px 10px;
|
||||
margin: 10px 5px 10px 10px;
|
||||
}
|
||||
span.message-warning {
|
||||
color: #808000;
|
||||
color: #808000;
|
||||
}
|
||||
span.message-error {
|
||||
color: #ff0000;
|
||||
color: #ff0000;
|
||||
}
|
||||
@media only screen and (max-width: 816px) {
|
||||
div#wrapper {
|
||||
max-width: 564px;
|
||||
margin: 0 auto;
|
||||
padding: inherit;
|
||||
flex-direction: column;
|
||||
}
|
||||
div.column {
|
||||
width: 100%;
|
||||
}
|
||||
div#editor-left {
|
||||
height: 100%;
|
||||
}
|
||||
div#editor-right {
|
||||
overflow-y: inherit;
|
||||
}
|
||||
div#wrapper {
|
||||
max-width: 564px;
|
||||
margin: 0 auto;
|
||||
padding: inherit;
|
||||
flex-direction: column;
|
||||
}
|
||||
div.column {
|
||||
width: 100%;
|
||||
}
|
||||
div#editor-left {
|
||||
height: 100%;
|
||||
}
|
||||
div#editor-right {
|
||||
overflow-y: inherit;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,126 +1,152 @@
|
|||
// Reduce unnecessary requests by checking for no further changes being made
|
||||
// before updating in response to a change.
|
||||
var nonce = 0;
|
||||
(function(){
|
||||
/** Article submission state. */
|
||||
const ArticleState = {
|
||||
DRAFT: 0,
|
||||
SUBMITTED: 1,
|
||||
APPROVED: 2
|
||||
};
|
||||
|
||||
function ifNoFurtherChanges(callback, timeout=2000) {
|
||||
var nonce_local = Math.random();
|
||||
nonce = nonce_local;
|
||||
setTimeout(() => {
|
||||
if (nonce == nonce_local) {
|
||||
callback();
|
||||
nonce = 0;
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
/** Article state to be tracked in addition to the editable content. */
|
||||
var article = {
|
||||
state: undefined,
|
||||
ersatz: false
|
||||
};
|
||||
|
||||
// Read data out of params and initialize editor
|
||||
window.onload = function() {
|
||||
// Kill noscript message first
|
||||
document.getElementById("preview").innerHTML = "<p> </p>";
|
||||
/** Article content as last received from the server. */
|
||||
var preview = {
|
||||
title: undefined,
|
||||
rendered: undefined,
|
||||
citations: [],
|
||||
messages: []
|
||||
}
|
||||
|
||||
if (document.body.contains(document.getElementById("editor-content"))) {
|
||||
onContentChange(0);
|
||||
}
|
||||
};
|
||||
/** The nonce of the last-made update request. */
|
||||
let nonce = 0;
|
||||
|
||||
function buildArticleObject() {
|
||||
var title = document.getElementById("editor-title").value;
|
||||
var contents = document.getElementById("editor-content").value;
|
||||
return {
|
||||
aid: params.article.aid,
|
||||
title: title,
|
||||
status: params.article.status,
|
||||
contents: contents
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Update request debounce wrapper that executes the callback if no further
|
||||
* calls are made during the timeout period. If a new call is made, any
|
||||
* previous calls are skipped.
|
||||
*/
|
||||
function ifNoFurtherChanges(callback, timeout)
|
||||
{
|
||||
// Stake a claim on the nonce, potentially overwriting a previous
|
||||
// nonce value.
|
||||
const nonce_local = 1 + Math.random();
|
||||
nonce = nonce_local;
|
||||
// Wait to see if this call is overwritten in turn.
|
||||
setTimeout(() => {
|
||||
if (nonce == nonce_local)
|
||||
{
|
||||
callback();
|
||||
nonce = 0;
|
||||
}
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
function update(article) {
|
||||
var req = new XMLHttpRequest();
|
||||
req.open("POST", params.updateURL, true);
|
||||
req.setRequestHeader("Content-type", "application/json");
|
||||
req.responseType = "json";
|
||||
req.onreadystatechange = function () {
|
||||
if (req.readyState == 4 && req.status == 200) {
|
||||
// Update internal state with the returned article object
|
||||
params.status = req.response.status;
|
||||
params.errors = req.response.error.length;
|
||||
document.getElementById("editor-title").value = req.response.title;
|
||||
// Set editor editability based on article status
|
||||
updateEditorStatus();
|
||||
// Update the preview with the parse information
|
||||
updatePreview(req.response);
|
||||
}
|
||||
};
|
||||
var payload = { article: article };
|
||||
req.send(JSON.stringify(payload));
|
||||
}
|
||||
/** Update the editor controls and preview to match the current state. */
|
||||
function refreshEditor()
|
||||
{
|
||||
// Enable or disable controls
|
||||
const isEditable = article.state == ArticleState.DRAFT;
|
||||
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";
|
||||
document.getElementById("button-submit").disabled = blocked;
|
||||
|
||||
function updateEditorStatus() {
|
||||
var ready = !!params.status.ready || !!params.status.approved;
|
||||
document.getElementById("editor-title").disabled = ready;
|
||||
document.getElementById("editor-content").disabled = ready;
|
||||
var hasErrors = params.errors > 0;
|
||||
var submitButton = document.getElementById("button-submit");
|
||||
submitButton.innerText = ready ? "Edit article" : "Submit article";
|
||||
submitButton.disabled = hasErrors;
|
||||
}
|
||||
// Update the preview
|
||||
const previewHtml = "<h1>" + preview.title + "</h1>\n" + preview.rendered;
|
||||
document.getElementById("preview").innerHTML = previewHtml;
|
||||
|
||||
function updatePreview(response) {
|
||||
var previewHtml = "<h1>" + response.title + "</h1>\n" + response.rendered;
|
||||
document.getElementById("preview").innerHTML = previewHtml;
|
||||
// Fill in the citation block
|
||||
let citations = "<ol>";
|
||||
preview.citations.forEach(cit => citations += "<li>" + JSON.stringify(cit) + "</li>");
|
||||
citations += "</ol>";
|
||||
document.getElementById("preview-citations").innerHTML = citations;
|
||||
|
||||
var citations = "<ol>";
|
||||
for (var i = 0; i < response.citations.length; i++) {
|
||||
citations += "<li>" + response.citations[i] + "</li>";
|
||||
}
|
||||
citations += "</ol>";
|
||||
document.getElementById("preview-citations").innerHTML = citations;
|
||||
// Fill in the status message block
|
||||
let statuses = "<ol>";
|
||||
preview.messages.forEach(err => statuses += "<li>" + JSON.stringify(err) + "</li>");
|
||||
statuses += "<ol>";
|
||||
document.getElementById("preview-control").innerHTML = statuses;
|
||||
}
|
||||
|
||||
var info = "";
|
||||
for (var i = 0; i < response.info.length; i++) {
|
||||
info += "<span class=\"message-info\">" + response.info[i] + "</span><br>";
|
||||
}
|
||||
var warning = "";
|
||||
for (var i = 0; i < response.warning.length; i++) {
|
||||
warning += "<span class=\"message-warning\">" +
|
||||
response.warning[i] + "</span><br>";
|
||||
}
|
||||
var error = "";
|
||||
for (var i = 0; i < response.error.length; i++) {
|
||||
error += "<span class=\"message-error\">" + response.error[i] + "</span><br>";
|
||||
}
|
||||
var control = info + warning + error;
|
||||
document.getElementById("preview-control").innerHTML = control;
|
||||
}
|
||||
/** Update the current state with the given data and refresh the editor. */
|
||||
function updateState(data)
|
||||
{
|
||||
article.state = data.state;
|
||||
article.ersatz = data.ersatz;
|
||||
preview.title = data.title;
|
||||
preview.rendered = data.rendered;
|
||||
preview.citations = data.citations;
|
||||
preview.messages = data.messages;
|
||||
refreshEditor();
|
||||
}
|
||||
|
||||
function onContentChange(timeout=2000) {
|
||||
ifNoFurtherChanges(() => {
|
||||
var article = buildArticleObject();
|
||||
update(article);
|
||||
}, timeout);
|
||||
}
|
||||
/** Send the article's current content to the server. */
|
||||
function update()
|
||||
{
|
||||
const updateUrl = document.body.dataset.amanuensisUpdateUrl;
|
||||
const data = {
|
||||
title: document.getElementById("editor-title").value,
|
||||
body: document.getElementById("editor-content").value,
|
||||
state: article.state
|
||||
};
|
||||
fetch(updateUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
}).then(response => response.json()).then(updateState);
|
||||
}
|
||||
|
||||
function submitArticle() {
|
||||
ifNoFurtherChanges(() => {
|
||||
params.article.status.ready = !params.article.status.ready;
|
||||
var article = buildArticleObject();
|
||||
update(article);
|
||||
}, 0);
|
||||
}
|
||||
function onContentChange(e, timeout=2000)
|
||||
{
|
||||
ifNoFurtherChanges(update, timeout);
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", function(e) {
|
||||
if (nonce != 0) {
|
||||
e.returnValue = "Are you sure?";
|
||||
}
|
||||
});
|
||||
function submitArticle()
|
||||
{
|
||||
ifNoFurtherChanges(() => {
|
||||
article.state = ArticleState.SUBMITTED;
|
||||
update();
|
||||
},
|
||||
/* timeout: */ 0);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", function(event) {
|
||||
if (event.ctrlKey || event.metaKey)
|
||||
{
|
||||
if (String.fromCharCode(event.which).toLowerCase() == 's')
|
||||
{
|
||||
event.preventDefault();
|
||||
onContentChange(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
/** Initialize the editor on page load. */
|
||||
function initializeEditor()
|
||||
{
|
||||
// Kill the noscript message
|
||||
document.getElementById("preview").innerHTML = "<p>Loading...</p>";
|
||||
document.getElementById("preview-citations").innerHTML = "<p>Loading...</p>";
|
||||
document.getElementById("preview-control").innerHTML = "<p>Loading...</p>";
|
||||
|
||||
// Hook up the controls
|
||||
document.getElementById("button-submit").onclick = submitArticle;
|
||||
document.getElementById("editor-title").oninput = onContentChange;
|
||||
document.getElementById("editor-content").oninput = onContentChange;
|
||||
window.addEventListener("beforeunload", e =>
|
||||
{
|
||||
if (nonce > 0)
|
||||
{
|
||||
e.returnValue = "Are you sure?";
|
||||
}
|
||||
});
|
||||
window.addEventListener("keydown", e =>
|
||||
{
|
||||
if (e.ctrlKey && e.key == 's')
|
||||
{
|
||||
e.preventDefault();
|
||||
onContentChange(e, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// Get the article status information.
|
||||
const updateUrl = document.body.dataset.amanuensisUpdateUrl;
|
||||
fetch(updateUrl).then(response => response.json()).then(updateState);
|
||||
}
|
||||
window.onload = initializeEditor;
|
||||
})();
|
||||
|
|
|
@ -15,6 +15,9 @@
|
|||
{% if current_page == "contents" %}class="current-page"
|
||||
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
|
||||
{% endif %}>Contents</a>{% endblock %}
|
||||
{% block sb_editor %}<a
|
||||
href="{{ url_for('lexicon.editor.select', lexicon_name=g.lexicon.name) }}"
|
||||
>Editor</a>{% endblock %}
|
||||
{% block sb_posts %}<a
|
||||
{% if current_page == "posts" %}class="current-page"
|
||||
{% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}"
|
||||
|
@ -35,6 +38,7 @@
|
|||
{% set template_sidebar_rows = [
|
||||
self.sb_characters(),
|
||||
self.sb_contents(),
|
||||
self.sb_editor(),
|
||||
self.sb_posts(),
|
||||
self.sb_rules(),
|
||||
self.sb_settings(),
|
||||
|
|
|
@ -7,6 +7,7 @@ from amanuensis.errors import ArgumentError
|
|||
from amanuensis.server.helpers import lexicon_param, player_required_if_not_public
|
||||
|
||||
from .characters import bp as characters_bp
|
||||
from .editor import bp as editor_bp
|
||||
from .forms import LexiconJoinForm
|
||||
from .posts import bp as posts_bp
|
||||
from .settings import bp as settings_bp
|
||||
|
@ -16,6 +17,7 @@ bp = Blueprint(
|
|||
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
|
||||
)
|
||||
bp.register_blueprint(characters_bp)
|
||||
bp.register_blueprint(editor_bp)
|
||||
bp.register_blueprint(posts_bp)
|
||||
bp.register_blueprint(settings_bp)
|
||||
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
import re
|
||||
|
||||
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.server.helpers import lexicon_param, player_required
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@lexicon_param
|
||||
@player_required
|
||||
def select(lexicon_name):
|
||||
return {}
|
||||
|
||||
|
||||
@bp.get("/<uuid:article_id>")
|
||||
@lexicon_param
|
||||
@player_required
|
||||
def open(lexicon_name, article_id):
|
||||
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,
|
||||
article=article,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<uuid:article_id>/update")
|
||||
@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())
|
||||
|
||||
# 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,
|
||||
'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)
|
||||
|
||||
# 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())
|
||||
|
||||
# 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': updated_article.title,
|
||||
'rendered': preview_result.contents,
|
||||
'state': updated_article.state.value,
|
||||
'ersatz': updated_article.ersatz,
|
||||
'citations': preview_result.citations,
|
||||
'messages': msg_list,
|
||||
}
|
|
@ -1,187 +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 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,
|
||||
}
|
|
@ -1,121 +1,64 @@
|
|||
{% if character and not article %}
|
||||
{% set characters = [character] %}
|
||||
{% endif %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Editor</title>
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='amanuensis.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='page.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='editor.css') }}">
|
||||
<script>
|
||||
params = {
|
||||
updateURL: "{{ url_for('session.editor_update', name=g.lexicon.cfg.name) }}",
|
||||
{% if character %}
|
||||
character: {{ jsonfmt(character) }},
|
||||
{% else %}
|
||||
character: null,
|
||||
{% endif %}
|
||||
{% if article %}
|
||||
article: {
|
||||
aid: {{ jsonfmt(article.aid) }},
|
||||
status: {{ jsonfmt(article.status) }},
|
||||
errors: 1,
|
||||
}
|
||||
{% else %}
|
||||
article: null
|
||||
{% endif %}
|
||||
};
|
||||
</script>
|
||||
<script type="text/javascript" src="{{ url_for('static', filename='editor.js') }}"></script>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Editor</title>
|
||||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='amanuensis.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='page.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='editor.css') }}">
|
||||
<script type="text/javascript" src="{{ url_for('static', filename='editor.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="wrapper">
|
||||
<div id="editor-left" class="column">
|
||||
<section>
|
||||
{# Thin header bar #}
|
||||
<div id="editor-header">
|
||||
{# Header always includes backlink to lexicon #}
|
||||
<a href="{{ url_for('session.session', name=g.lexicon.cfg.name) }}">
|
||||
{{ g.lexicon.title }}
|
||||
</a>
|
||||
{# If article is not finalized, show button to submit and retract #}
|
||||
{% if article and not article.status.approved %}
|
||||
<button id="button-submit" onclick="submitArticle()" disabled>Submit article</button>
|
||||
{% endif %}
|
||||
{# Header always includes character/player info #}
|
||||
<span>
|
||||
<b>
|
||||
{% if character %}
|
||||
{{ character.name }} /
|
||||
{% endif %}
|
||||
{{ current_user.cfg.username }}
|
||||
</b>
|
||||
</span>
|
||||
</div>
|
||||
{# In load mode, `characters` is specified and `article` is #}
|
||||
{# not, and the main body of the editor column contains a #}
|
||||
{# list of articles that can be loaded. #}
|
||||
{% for char in characters %}
|
||||
<div id="editor-charselect">
|
||||
<b>{{ char.name }}</b>
|
||||
<ul>
|
||||
{% for article in articles %}
|
||||
{% if article.character == char.cid %}
|
||||
<li>
|
||||
<a href="{{ url_for('session.editor', name=g.lexicon.cfg.name, aid=article.aid) }}">
|
||||
{{ article.title if article.title.strip() else "Untitled" }}</a>
|
||||
<span>
|
||||
{% if not article.status.ready %}
|
||||
[Draft]
|
||||
{% elif not article.status.approved %}
|
||||
[Pending]
|
||||
{% else %}
|
||||
[Approved]
|
||||
{% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li>
|
||||
<a href="{{ url_for('session.editor_new', name=g.lexicon.cfg.name, cid=char.cid) }}">
|
||||
New
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{# In edit mode, `article` is specified and `characters` is #}
|
||||
{# not, and the editor pane contains the article editor. #}
|
||||
{% if article %}
|
||||
{# <div id="editor-buttons">
|
||||
Character literals:
|
||||
<button>*</button>
|
||||
<button>/</button>
|
||||
<button>[</button>
|
||||
<button>]</button>
|
||||
<button>~</button>
|
||||
</div> #}
|
||||
<input id="editor-title" placeholder="Title" oninput="onContentChange()" disabled value="{{ article.title }}">
|
||||
<textarea id="editor-content" class="fullwidth" oninput="onContentChange()" disabled>
|
||||
{# #}{{ article.contents }}{#
|
||||
#}</textarea>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
<div id="editor-right" class="column">
|
||||
<section id="preview">
|
||||
<p>This editor requires Javascript to function.</p>
|
||||
</div>
|
||||
<section id="preview-citations">
|
||||
<p> </p>
|
||||
</div>
|
||||
<section id="preview-control">
|
||||
<p> </p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<body
|
||||
data-amanuensis-update-url="{{ url_for('lexicon.editor.load', lexicon_name=lexicon_name, article_id=article.public_id) }}"
|
||||
>
|
||||
<div id="wrapper">
|
||||
<div id="editor-left" class="column">
|
||||
<section>
|
||||
{# Thin header bar #}
|
||||
<div id="editor-header">
|
||||
{# Header always includes backlink to lexicon #}
|
||||
<a href="{{ url_for('lexicon.contents', lexicon_name=lexicon_name) }}">
|
||||
{{ g.lexicon.full_title }}
|
||||
</a>
|
||||
{# If article is not finalized, show button to submit and retract #}
|
||||
{# {% if article and not article.status.approved %} #}
|
||||
{% if article %}
|
||||
<button
|
||||
id="button-submit"
|
||||
disabled
|
||||
>Submit article</button>
|
||||
{% endif %}
|
||||
{# Header always includes character/player info #}
|
||||
<span>{{ article.character.name }}</span>
|
||||
</div>
|
||||
{% if article %}
|
||||
{# <div id="editor-buttons">
|
||||
Character literals:
|
||||
<button>*</button>
|
||||
<button>/</button>
|
||||
<button>[</button>
|
||||
<button>]</button>
|
||||
<button>~</button>
|
||||
</div> #}
|
||||
<input id="editor-title" placeholder="Title" disabled value="{{ article.title }}">
|
||||
<textarea id="editor-content" class="fullwidth" disabled>
|
||||
{{- article.body -}}
|
||||
</textarea>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
<div id="editor-right" class="column">
|
||||
<section id="preview">
|
||||
<p>This editor requires Javascript to function.</p>
|
||||
</section>
|
||||
<section id="preview-citations">
|
||||
<p> </p>
|
||||
</section>
|
||||
<section id="preview-control">
|
||||
<p> </p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -8,17 +8,9 @@
|
|||
<span style="color:#ff0000">{{ message }}</span><br>
|
||||
{% endfor %}
|
||||
|
||||
{% for index in indexed %}
|
||||
<b>{{ index }}</b>
|
||||
{% if indexed[index] %}
|
||||
<ul>
|
||||
{% for article in indexed[index] %}
|
||||
<li><a href="{{ article.title|articlelink }}" class="{{ 'phantom' if not article.character else '' }}">
|
||||
{{ article.title }}
|
||||
</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% for article in current_lexicon.articles %}
|
||||
<p>{{ article.title }} - {{ article.public_id }}</p>
|
||||
<p>{{ article.body }}</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
Loading…
Reference in New Issue