Compare commits

...

6 Commits

21 changed files with 769 additions and 791 deletions

View File

@ -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,22 @@ 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()

View File

@ -5,6 +5,7 @@ import os
from typing import Callable
import amanuensis.cli.admin
import amanuensis.cli.article
import amanuensis.cli.character
import amanuensis.cli.index
import amanuensis.cli.lexicon
@ -111,6 +112,7 @@ def main():
# Add commands from cli submodules
subparsers = parser.add_subparsers(metavar="COMMAND")
add_subcommand(subparsers, amanuensis.cli.admin)
add_subcommand(subparsers, amanuensis.cli.article)
add_subcommand(subparsers, amanuensis.cli.character)
add_subcommand(subparsers, amanuensis.cli.index)
add_subcommand(subparsers, amanuensis.cli.lexicon)

30
amanuensis/cli/article.py Normal file
View File

@ -0,0 +1,30 @@
import logging
from amanuensis.backend import *
from amanuensis.db import *
from .helpers import add_argument
COMMAND_NAME = "article"
COMMAND_HELP = "Interact with articles."
LOG = logging.getLogger(__name__)
@add_argument("--lexicon", required=True, help="The lexicon's name")
@add_argument("--character", required=True, help="The character's public id")
def command_create(args) -> int:
"""
Create an article in a draft state.
"""
db: DbContext = args.get_db()
lexicon = lexiq.try_from_name(db, args.lexicon)
if not lexicon:
raise ValueError("Lexicon does not exist")
char = charq.try_from_public_id(db, args.character)
if not char:
raise ValueError("Character does not exist")
article: Article = artiq.create(db, lexicon.id, char.id)
LOG.info(f"Created article {article.id} in {lexicon.full_title}")
return 0

View File

@ -1,32 +0,0 @@
from .admin import (
valid_name,
create_lexicon,
load_all_lexicons)
from .gameloop import (
get_player_characters,
get_player_drafts,
get_draft,
title_constraint_analysis,
content_constraint_analysis,
sort_by_index_spec,
attempt_publish)
from .setup import (
player_can_join_lexicon,
add_player_to_lexicon,
create_character_in_lexicon)
__all__ = [member.__name__ for member in [
valid_name,
create_lexicon,
load_all_lexicons,
get_player_characters,
get_player_drafts,
get_draft,
title_constraint_analysis,
content_constraint_analysis,
sort_by_index_spec,
attempt_publish,
player_can_join_lexicon,
add_player_to_lexicon,
create_character_in_lexicon,
]]

View File

@ -0,0 +1,131 @@
"""
Submodule for citation logic.
"""
import enum
from typing import Sequence, Set, List, Dict, Union
from amanuensis.backend import *
from amanuensis.db import Lexicon, Article
from amanuensis.parser import *
class CitationNodeType(enum.Enum):
ExtantWritten = 0
"""Title of an article that was previously written."""
ExtantPhantom = 1
"""Unwritten title cited by an extant written article."""
NewDraft = 2
"""Title of a pending article with a new title."""
PhantomDraft = 3
"""Title of a pending article with a phantom title."""
PhantomPending = 4
"""Unwritten title cited by a draft article."""
class CitationNode:
"""
Represents an article in the context of citations. Phantom articles do not
correspond to articles in the database.
"""
def __init__(self, title: str, node_type: CitationNodeType) -> None:
self.title: str = title
self.cites: Set[CitationNode] = set()
self.cited_by: Set[CitationNode] = set()
self.node_type: CitationNodeType = node_type
class CitationMap:
"""Represents metadata about which articles cite each other."""
def __init__(self) -> None:
self.by_title: Dict[str, CitationNode] = {}
def __contains__(self, title: str) -> bool:
return title in self.by_title
def __getitem__(self, title: str) -> CitationNode:
return self.by_title[title]
def get_or_add(self, title: str, node_type: CitationNodeType) -> CitationNode:
"""
Get the citation node for a title. If one does not exist, create it
with the given type.
"""
if title not in self.by_title:
self.add(title, node_type)
return self.by_title[title]
def add(self, title: str, node_type: CitationNodeType) -> None:
"""
Create a citation node with the given title and type.
"""
self.by_title[title] = CitationNode(title, node_type)
def create_citation(
self, citer: Union[CitationNode, str], cited: Union[CitationNode, str]
) -> None:
"""Add a citation between two titles."""
if isinstance(citer, str):
citer = self.by_title[citer]
if isinstance(cited, str):
cited = self.by_title[cited]
citer.cites.add(cited)
cited.cited_by.add(citer)
class GetCitations(RenderableVisitor):
"""Returns a list of all article titles cited in this article."""
def __init__(self) -> None:
self.citations: List[str] = []
def CitationSpan(self, span):
self.citations.append(span.cite_target)
def ParsedArticle(self, span):
return self.citations
def create_citation_map(lexicon: Lexicon, articles: Sequence[Article]) -> CitationMap:
"""
Generates mappings useful for tracking citations.
"""
article: Article
citemap = CitationMap()
# Add extant articles to the citation map
for article in articles:
if article.turn < lexicon.current_turn:
citemap.add(article.title, CitationNodeType.ExtantWritten)
# Add phantoms created by extant articles
for article in articles:
if article.turn < lexicon.current_turn:
parsed = parse_raw_markdown(article.body)
citeds = parsed.render(GetCitations())
for cited in citeds:
cited_node = citemap.get_or_add(cited, CitationNodeType.ExtantPhantom)
citemap.create_citation(article.title, cited_node)
# Add drafts, noting new and phantom drafts
for article in articles:
if article.turn >= lexicon.current_turn:
draft_node = citemap.get_or_add(article.title, CitationNodeType.NewDraft)
if draft_node.node_type == CitationNodeType.ExtantPhantom:
draft_node.node_type = CitationNodeType.PhantomDraft
# Add phantoms created by drafts
for article in articles:
if article.turn >= lexicon.current_turn:
parsed = parse_raw_markdown(article.body)
citeds = parsed.render(GetCitations())
for cited in citeds:
cited_node = citemap.get_or_add(cited, CitationNodeType.PhantomPending)
citemap.create_citation(article.title, cited_node)
return citemap

View File

@ -0,0 +1,168 @@
import re
from typing import Sequence
from amanuensis.db import *
from amanuensis.parser import *
from .citation import CitationMap, CitationNodeType, create_citation_map
class ConstraintCheck(RenderableVisitor):
"""Analyzes an article for content-based constraint violations."""
def __init__(self) -> None:
self.word_count: int = 0
self.signatures: int = 0
def TextSpan(self, span):
self.word_count += len(re.split(r"\s+", span.innertext.strip()))
return self
def SignatureParagraph(self, span):
self.signatures += 1
span.recurse(self)
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(
article: Article,
parsed: Renderable,
citemap: CitationMap,
) -> Sequence[ConstraintMessage]:
""""""
messages = []
# TODO: Need
# player index assignments for article turn
# extant article titles
# pending article titles
# phantom article titles
# pending phantom article titles
# index capacities
title = article.title
# author_id = article.character_id
###
# Constraints that apply to the title
###
# I: Current index assignments
# TODO
# E: No title
if not article.title:
messages.append(ConstraintMessage.error("Missing title"))
if citemap[title].node_type == CitationNodeType.NewDraft:
# I: This article is new
messages.append(ConstraintMessage.info("Writing a new article"))
# E: New articles are forbidden
# TODO
if citemap[title].node_type == CitationNodeType.PhantomDraft:
# I: This article is a phantom
messages.append(ConstraintMessage.info("Writing a phantom article")
# I: This article is an addendum
# TODO
# E: And the user is at the addendum limit
# TODO
# E: This article has already been written and addendums are disabled
# TODO
# I: This article's index
# TODO
# E: The article does not fulfill the player's index assignment
# TODO
# E: The index does not have room for a new article
# TODO
# E: The player has cited this phantom article before
# TODO
# W: Another player is writing an article with this title
# TODO
# E: Another player has an approved article with this title
# TODO
# W: The article's title matches a character's name
# TODO
check_result: ConstraintCheck = parsed.render(ConstraintCheck())
# I: Word count
messages.append(ConstraintMessage.info(f"Word count: {check_result.word_count}"))
# E: Self-citation when forbidden
# TODO
# W: A new citation matches a character's name
# TODO
# W: The article cites itself
# TODO
# E: A new citation would create more articles than can be written
# TODO
# E: Extant citation count requirements
# TODO
# E: Phantom citation count requirements
# TODO
# E: New citation count requirements
# TODO
# E: Character citation count requirements
# TODO
# E: Total citation count requirements
# TODO
# E: Exceeded hard word limit
# TODO
# W: Exceeded soft word limit
# TODO
# W: Missing or multiple signatures
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"))
return messages

View File

@ -99,54 +99,6 @@ class HtmlRenderer(RenderableVisitor):
return f'<a href="{link}"{link_class}>{"".join(span.recurse(self))}</a>'
def get_player_characters(
lexicon: LexiconModel,
uid: str) -> Iterable[ReadOnlyOrderedDict]:
"""
Returns each character in the lexicon owned by the given player
"""
for character in lexicon.cfg.character.values():
if character.player == uid:
yield character
def get_player_drafts(
lexicon: LexiconModel,
uid: str) -> Iterable[ReadOnlyOrderedDict]:
"""
Returns each draft in the lexicon by a character owned by the
given player.
"""
characters = list(get_player_characters(lexicon, uid))
drafts: List[Any] = []
for filename in lexicon.ctx.draft.ls():
for character in characters:
if filename.startswith(character.cid):
drafts.append(filename)
break
for i in range(len(drafts)):
with lexicon.ctx.draft.read(drafts[i]) as draft:
drafts[i] = draft
return drafts
def get_draft(
lexicon: LexiconModel,
aid: str) -> Optional[ReadOnlyOrderedDict]:
"""
Loads an article from its id
"""
article_fn = None
for filename in lexicon.ctx.draft.ls():
if filename.endswith(f'{aid}.json'):
article_fn = filename
break
if not article_fn:
return None
with lexicon.ctx.draft.read(article_fn) as article:
return article
def title_constraint_analysis(
lexicon: LexiconModel,
player: UserModel,

View File

@ -1,99 +0,0 @@
# """
# Functions for managing lexicons, primarily within the context of the
# Amanuensis config directory.
# """
# import json
# import os
# import re
# import shutil
# import time
# import uuid
# from amanuensis.config import prepend, json_rw, json_ro, logger
# from amanuensis.config.loader import AttrOrderedDict
# from amanuensis.errors import ArgumentError
# from amanuensis.lexicon import LexiconModel
# from amanuensis.parser import parse_raw_markdown, filesafe_title, titlesort
# from amanuensis.resources import get_stream
# def delete_lexicon(lex, purge=False):
# """
# Deletes the given lexicon from the internal configuration
# Does not delete the lexicon from the data folder unless purge=True.
# """
# # Verify arguments
# if lex is None:
# raise ArgumentError("Invalid lexicon: '{}'".format(lex))
# # Delete the lexicon from the index
# with json_rw('lexicon', 'index.json') as index:
# if lex.name in index:
# del index[lex.name]
# # Delete the lexicon data folder if purging
# if purge:
# raise NotImplementedError()
# # Delete the lexicon config
# lex_path = prepend('lexicon', lex.id)
# shutil.rmtree(lex_path)
# def get_user_lexicons(user):
# """
# Loads each lexicon that the given user is a player in
# """
# return [
# lexicon
# for lexicon in get_all_lexicons()
# if user.in_lexicon(lexicon)]
# def remove_player(lex, player):
# """
# Remove a player from a lexicon
# """
# # Verify arguments
# if lex is None:
# raise ArgumentError("Invalid lexicon: '{}'".format(lex))
# if player is None:
# raise ArgumentError("Invalid player: '{}'".format(player))
# if lex.editor == player.id:
# raise ArgumentError(
# "Can't remove the editor '{}' from lexicon '{}'".format(
# player.username, lex.name))
# # Idempotently remove player
# with json_rw(lex.config_path) as cfg:
# if player.id in cfg.join.joined:
# cfg.join.joined.remove(player.id)
# # TODO Reassign the player's characters to the editor
# def delete_character(lex, charname):
# """
# Delete a character from a lexicon
# """
# # Verify arguments
# if lex is None:
# raise ArgumentError("Invalid lexicon: '{}'".format(lex))
# if charname is None:
# raise ArgumentError("Invalid character name: '{}'".format(charname))
# # Find character in this lexicon
# matches = [
# char for cid, char in lex.character.items()
# if char.name == charname]
# if len(matches) != 1:
# raise ArgumentError(matches)
# char = matches[0]
# # Remove character from character list
# with json_rw(lex.config_path) as cfg:
# del cfg.character[char.cid]

View File

@ -1,81 +0,0 @@
"""
Submodule of functions for managing lexicon games during the setup and
joining part of the game lifecycle.
"""
import json
import uuid
from amanuensis.config import AttrOrderedDict
from amanuensis.errors import ArgumentError
from amanuensis.models import LexiconModel, UserModel
from amanuensis.resources import get_stream
def player_can_create_character(
player: UserModel,
lexicon: LexiconModel,
name: str) -> bool:
"""
Checks whether a player can create a character with the given name
"""
# Trivial failures
if not player or not lexicon or not name:
return False
# User needs to be a player
if player.uid not in lexicon.cfg.join.joined:
return False
# Character can't be a dupe
if any([
char.name for char in lexicon.cfg.character.values()
if char.name == name]):
return False
# Player can't add more characters than the limit
if len([
char for char in lexicon.cfg.character.values()
if char.player == player.uid]) > lexicon.cfg.join.chars_per_player:
return False
# Players can't add characters after the game has started
if lexicon.cfg.turn.current:
return False
return True
def create_character_in_lexicon(
player: UserModel,
lexicon: LexiconModel,
name: str) -> str:
"""
Unconditionally creates a character for a player
"""
# Verify arguments
if lexicon is None:
raise ArgumentError(f'Invalid lexicon: {lexicon}')
if player is None:
raise ArgumentError(f'Invalid player: {player}')
if player.uid not in lexicon.cfg.join.joined:
raise ArgumentError(f'Player {player} not in lexicon {lexicon}')
if not name:
raise ArgumentError(f'Invalid character name: "{name}"')
if any([
char.name for char in lexicon.cfg.character.values()
if char.name == name]):
raise ArgumentError(f'Duplicate character name: "{name}"')
# Load the character template
with get_stream('character.json') as template:
character = json.load(template, object_pairs_hook=AttrOrderedDict)
# Fill out the character's information
character.cid = uuid.uuid4().hex
character.name = name
character.player = player.uid
character.signature = "~" + character.name
# Add the character to the lexicon
with lexicon.ctx.edit_config() as cfg:
cfg.character.new(character.cid, character)
# Log addition
lexicon.log(f'Character "{name}" created ({character.cid})')
return character.cid

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

@ -4,7 +4,6 @@ html, body {
margin: 0px;
}
div#wrapper {
max-width: 1128px;
height: 100%;
display: flex;
flex-direction: row;

View File

@ -1,126 +1,154 @@
// 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();
/** Article state to be tracked in addition to the editable content. */
var article = {
state: undefined,
ersatz: false
};
/** Article content as last received from the server. */
var preview = {
title: undefined,
rendered: undefined,
citations: [],
messages: []
}
/** The nonce of the last-made update request. */
let nonce = 0;
/**
* 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) {
if (nonce == nonce_local)
{
callback();
nonce = 0;
}
}, timeout);
}
// Read data out of params and initialize editor
window.onload = function() {
// Kill noscript message first
document.getElementById("preview").innerHTML = "<p>&nbsp;</p>";
/** 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;
if (document.body.contains(document.getElementById("editor-content"))) {
onContentChange(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
};
}
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));
}
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;
}
function updatePreview(response) {
var previewHtml = "<h1>" + response.title + "</h1>\n" + response.rendered;
// Update the preview
const previewHtml = "<h1>" + preview.title + "</h1>\n" + preview.rendered;
document.getElementById("preview").innerHTML = previewHtml;
var citations = "<ol>";
for (var i = 0; i < response.citations.length; i++) {
citations += "<li>" + response.citations[i] + "</li>";
}
// 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 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;
// 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;
}
function onContentChange(timeout=2000) {
/** 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();
}
/** 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 onContentChange(e, timeout=2000)
{
ifNoFurtherChanges(update, timeout);
}
function submitArticle()
{
ifNoFurtherChanges(() => {
var article = buildArticleObject();
update(article);
}, timeout);
article.state = (article.state == ArticleState.DRAFT
? ArticleState.SUBMITTED
: ArticleState.DRAFT);
update();
},
/* timeout: */ 0);
}
function submitArticle() {
ifNoFurtherChanges(() => {
params.article.status.ready = !params.article.status.ready;
var article = buildArticleObject();
update(article);
}, 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>";
window.addEventListener("beforeunload", function(e) {
if (nonce != 0) {
// 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", function(event) {
if (event.ctrlKey || event.metaKey)
window.addEventListener("keydown", e =>
{
if (String.fromCharCode(event.which).toLowerCase() == 's')
if (e.ctrlKey && e.key == 's')
{
event.preventDefault();
onContentChange(0);
}
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;
})();

View File

@ -1,33 +0,0 @@
from flask import current_app
from wtforms.validators import ValidationError
from amanuensis.config import RootConfigDirectoryContext
# Custom validators
def User(should_exist: bool = True):
template: str = 'User "{{}}" {}'.format(
"not found" if should_exist else "already exists")
should_exist_copy: bool = bool(should_exist)
def validate_user(form, field):
root: RootConfigDirectoryContext = current_app.config['root']
with root.user.read_index() as index:
if (field.data in index.keys()) != should_exist_copy:
raise ValidationError(template.format(field.data))
return validate_user
def Lexicon(should_exist: bool = True):
template: str = 'Lexicon "{{}}" {}'.format(
"not found" if should_exist else "already exists")
should_exist_copy: bool = bool(should_exist)
def validate_lexicon(form, field):
root: RootConfigDirectoryContext = current_app.config['root']
with root.lexicon.read_index() as index:
if (field.data in index.keys()) != should_exist_copy:
raise ValidationError(template.format(field.data))
return validate_lexicon

View File

@ -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(),

View File

@ -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)

View File

@ -0,0 +1,155 @@
from flask import Blueprint, render_template, g, abort, request
from amanuensis.backend import *
from amanuensis.db import *
from amanuensis.lexicon.constraint import (
title_constraint_check,
content_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 = title_constraint_check(article.title)
messages.extend(content_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 = title_constraint_check(new_title)
messages.extend(content_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,
}

View File

@ -0,0 +1,64 @@
<!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 type="text/javascript" src="{{ url_for('static', filename='editor.js') }}"></script>
</head>
<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>&nbsp;</p>
</section>
<section id="preview-control">
<p>&nbsp;</p>
</fieldset>
</div>
</div>
</body>
</html>

View File

@ -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 %}

View File

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

View File

@ -6,25 +6,6 @@ from wtforms.validators import DataRequired
from .settings import ConfigFormBase
class LexiconCharacterForm(FlaskForm):
"""/lexicon/<name>/session/character/"""
characterName = StringField(
'Character name',
validators=[DataRequired()])
defaultSignature = TextAreaField('Default signature')
submit = SubmitField('Submit')
def for_new(self):
self.characterName.data = ""
self.defaultSignature.data = "~"
return self
def for_character(self, character):
self.characterName.data = character.name
self.defaultSignature.data = character.signature
return self
class LexiconReviewForm(FlaskForm):
"""/lexicon/<name>/session/review/"""
APPROVED = 'Y'

View File

@ -1,121 +0,0 @@
{% 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>
</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>&nbsp;</p>
</div>
<section id="preview-control">
<p>&nbsp;</p>
</div>
</div>
</div>
</body>
</html>