Compare commits
6 Commits
develop
...
tvb/editor
Author | SHA1 | Date |
---|---|---|
Tim Van Baak | 2fd92ca0b8 | |
Tim Van Baak | 2bd75328a1 | |
Tim Van Baak | 675e42cfa3 | |
Tim Van Baak | 33459925ce | |
Tim Van Baak | 23d9b1b221 | |
Tim Van Baak | c14959e6e6 |
|
@ -2,7 +2,10 @@
|
||||||
Article query interface
|
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.db import *
|
||||||
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
|
from amanuensis.errors import ArgumentError, BackendArgumentTypeError
|
||||||
|
@ -42,3 +45,22 @@ def create(
|
||||||
db.session.add(new_article)
|
db.session.add(new_article)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return new_article
|
return new_article
|
||||||
|
|
||||||
|
|
||||||
|
def try_from_public_id(db: DbContext, public_id: UUID) -> Optional[Article]:
|
||||||
|
"""Get an article by its public id."""
|
||||||
|
return db(
|
||||||
|
select(Article).where(Article.public_id == public_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
|
@ -5,6 +5,7 @@ import os
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import amanuensis.cli.admin
|
import amanuensis.cli.admin
|
||||||
|
import amanuensis.cli.article
|
||||||
import amanuensis.cli.character
|
import amanuensis.cli.character
|
||||||
import amanuensis.cli.index
|
import amanuensis.cli.index
|
||||||
import amanuensis.cli.lexicon
|
import amanuensis.cli.lexicon
|
||||||
|
@ -111,6 +112,7 @@ def main():
|
||||||
# Add commands from cli submodules
|
# Add commands from cli submodules
|
||||||
subparsers = parser.add_subparsers(metavar="COMMAND")
|
subparsers = parser.add_subparsers(metavar="COMMAND")
|
||||||
add_subcommand(subparsers, amanuensis.cli.admin)
|
add_subcommand(subparsers, amanuensis.cli.admin)
|
||||||
|
add_subcommand(subparsers, amanuensis.cli.article)
|
||||||
add_subcommand(subparsers, amanuensis.cli.character)
|
add_subcommand(subparsers, amanuensis.cli.character)
|
||||||
add_subcommand(subparsers, amanuensis.cli.index)
|
add_subcommand(subparsers, amanuensis.cli.index)
|
||||||
add_subcommand(subparsers, amanuensis.cli.lexicon)
|
add_subcommand(subparsers, amanuensis.cli.lexicon)
|
||||||
|
|
|
@ -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
|
|
@ -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,
|
|
||||||
]]
|
|
|
@ -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
|
|
@ -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
|
|
@ -99,54 +99,6 @@ class HtmlRenderer(RenderableVisitor):
|
||||||
return f'<a href="{link}"{link_class}>{"".join(span.recurse(self))}</a>'
|
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(
|
def title_constraint_analysis(
|
||||||
lexicon: LexiconModel,
|
lexicon: LexiconModel,
|
||||||
player: UserModel,
|
player: UserModel,
|
||||||
|
|
|
@ -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]
|
|
|
@ -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
|
|
|
@ -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",
|
||||||
|
|
|
@ -4,9 +4,8 @@ html, body {
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
}
|
}
|
||||||
div#wrapper {
|
div#wrapper {
|
||||||
max-width: 1128px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display:flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,126 +1,154 @@
|
||||||
// Reduce unnecessary requests by checking for no further changes being made
|
(function(){
|
||||||
// before updating in response to a change.
|
/** Article submission state. */
|
||||||
var nonce = 0;
|
const ArticleState = {
|
||||||
|
DRAFT: 0,
|
||||||
|
SUBMITTED: 1,
|
||||||
|
APPROVED: 2
|
||||||
|
};
|
||||||
|
|
||||||
function ifNoFurtherChanges(callback, timeout=2000) {
|
/** Article state to be tracked in addition to the editable content. */
|
||||||
var nonce_local = Math.random();
|
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;
|
nonce = nonce_local;
|
||||||
|
// Wait to see if this call is overwritten in turn.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (nonce == nonce_local) {
|
if (nonce == nonce_local)
|
||||||
|
{
|
||||||
callback();
|
callback();
|
||||||
nonce = 0;
|
nonce = 0;
|
||||||
}
|
}
|
||||||
}, timeout);
|
}, timeout);
|
||||||
}
|
|
||||||
|
|
||||||
// Read data out of params and initialize editor
|
|
||||||
window.onload = function() {
|
|
||||||
// Kill noscript message first
|
|
||||||
document.getElementById("preview").innerHTML = "<p> </p>";
|
|
||||||
|
|
||||||
if (document.body.contains(document.getElementById("editor-content"))) {
|
|
||||||
onContentChange(0);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
function buildArticleObject() {
|
/** Update the editor controls and preview to match the current state. */
|
||||||
var title = document.getElementById("editor-title").value;
|
function refreshEditor()
|
||||||
var contents = document.getElementById("editor-content").value;
|
{
|
||||||
return {
|
// Enable or disable controls
|
||||||
aid: params.article.aid,
|
const isEditable = article.state == ArticleState.DRAFT;
|
||||||
title: title,
|
const blocked = preview.messages.filter(msg => msg.severity == 2).length > 0;
|
||||||
status: params.article.status,
|
document.getElementById("editor-title").disabled = !isEditable;
|
||||||
contents: contents
|
document.getElementById("editor-content").disabled = !isEditable;
|
||||||
};
|
document.getElementById("button-submit").innerText = isEditable ? "Submit article" : "Edit article";
|
||||||
}
|
document.getElementById("button-submit").disabled = blocked;
|
||||||
|
|
||||||
function update(article) {
|
// Update the preview
|
||||||
var req = new XMLHttpRequest();
|
const previewHtml = "<h1>" + preview.title + "</h1>\n" + preview.rendered;
|
||||||
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;
|
|
||||||
document.getElementById("preview").innerHTML = previewHtml;
|
document.getElementById("preview").innerHTML = previewHtml;
|
||||||
|
|
||||||
var citations = "<ol>";
|
// Fill in the citation block
|
||||||
for (var i = 0; i < response.citations.length; i++) {
|
let citations = "<ol>";
|
||||||
citations += "<li>" + response.citations[i] + "</li>";
|
preview.citations.forEach(cit => citations += "<li>" + JSON.stringify(cit) + "</li>");
|
||||||
}
|
|
||||||
citations += "</ol>";
|
citations += "</ol>";
|
||||||
document.getElementById("preview-citations").innerHTML = citations;
|
document.getElementById("preview-citations").innerHTML = citations;
|
||||||
|
|
||||||
var info = "";
|
// Fill in the status message block
|
||||||
for (var i = 0; i < response.info.length; i++) {
|
let statuses = "<ol>";
|
||||||
info += "<span class=\"message-info\">" + response.info[i] + "</span><br>";
|
preview.messages.forEach(err => statuses += "<li>" + JSON.stringify(err) + "</li>");
|
||||||
|
statuses += "<ol>";
|
||||||
|
document.getElementById("preview-control").innerHTML = statuses;
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(() => {
|
ifNoFurtherChanges(() => {
|
||||||
var article = buildArticleObject();
|
article.state = (article.state == ArticleState.DRAFT
|
||||||
update(article);
|
? ArticleState.SUBMITTED
|
||||||
}, timeout);
|
: ArticleState.DRAFT);
|
||||||
}
|
update();
|
||||||
|
},
|
||||||
|
/* timeout: */ 0);
|
||||||
|
}
|
||||||
|
|
||||||
function submitArticle() {
|
/** Initialize the editor on page load. */
|
||||||
ifNoFurtherChanges(() => {
|
function initializeEditor()
|
||||||
params.article.status.ready = !params.article.status.ready;
|
{
|
||||||
var article = buildArticleObject();
|
// Kill the noscript message
|
||||||
update(article);
|
document.getElementById("preview").innerHTML = "<p>Loading...</p>";
|
||||||
}, 0);
|
document.getElementById("preview-citations").innerHTML = "<p>Loading...</p>";
|
||||||
}
|
document.getElementById("preview-control").innerHTML = "<p>Loading...</p>";
|
||||||
|
|
||||||
window.addEventListener("beforeunload", function(e) {
|
// Hook up the controls
|
||||||
if (nonce != 0) {
|
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?";
|
e.returnValue = "Are you sure?";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.addEventListener("keydown", e =>
|
||||||
|
{
|
||||||
|
if (e.ctrlKey && e.key == 's')
|
||||||
|
{
|
||||||
|
e.preventDefault();
|
||||||
|
onContentChange(e, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("keydown", function(event) {
|
// Get the article status information.
|
||||||
if (event.ctrlKey || event.metaKey)
|
const updateUrl = document.body.dataset.amanuensisUpdateUrl;
|
||||||
{
|
fetch(updateUrl).then(response => response.json()).then(updateState);
|
||||||
if (String.fromCharCode(event.which).toLowerCase() == 's')
|
|
||||||
{
|
|
||||||
event.preventDefault();
|
|
||||||
onContentChange(0);
|
|
||||||
}
|
}
|
||||||
}
|
window.onload = initializeEditor;
|
||||||
});
|
})();
|
||||||
|
|
|
@ -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
|
|
|
@ -15,6 +15,9 @@
|
||||||
{% if current_page == "contents" %}class="current-page"
|
{% if current_page == "contents" %}class="current-page"
|
||||||
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
|
{% else %}href="{{ url_for('lexicon.contents', lexicon_name=g.lexicon.name) }}"
|
||||||
{% endif %}>Contents</a>{% endblock %}
|
{% endif %}>Contents</a>{% endblock %}
|
||||||
|
{% block sb_editor %}<a
|
||||||
|
href="{{ url_for('lexicon.editor.select', lexicon_name=g.lexicon.name) }}"
|
||||||
|
>Editor</a>{% endblock %}
|
||||||
{% block sb_posts %}<a
|
{% block sb_posts %}<a
|
||||||
{% if current_page == "posts" %}class="current-page"
|
{% if current_page == "posts" %}class="current-page"
|
||||||
{% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}"
|
{% else %}href="{{ url_for('lexicon.posts.list', lexicon_name=g.lexicon.name) }}"
|
||||||
|
@ -35,6 +38,7 @@
|
||||||
{% set template_sidebar_rows = [
|
{% set template_sidebar_rows = [
|
||||||
self.sb_characters(),
|
self.sb_characters(),
|
||||||
self.sb_contents(),
|
self.sb_contents(),
|
||||||
|
self.sb_editor(),
|
||||||
self.sb_posts(),
|
self.sb_posts(),
|
||||||
self.sb_rules(),
|
self.sb_rules(),
|
||||||
self.sb_settings(),
|
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 amanuensis.server.helpers import lexicon_param, player_required_if_not_public
|
||||||
|
|
||||||
from .characters import bp as characters_bp
|
from .characters import bp as characters_bp
|
||||||
|
from .editor import bp as editor_bp
|
||||||
from .forms import LexiconJoinForm
|
from .forms import LexiconJoinForm
|
||||||
from .posts import bp as posts_bp
|
from .posts import bp as posts_bp
|
||||||
from .settings import bp as settings_bp
|
from .settings import bp as settings_bp
|
||||||
|
@ -16,6 +17,7 @@ bp = Blueprint(
|
||||||
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
|
"lexicon", __name__, url_prefix="/lexicon/<lexicon_name>", template_folder="."
|
||||||
)
|
)
|
||||||
bp.register_blueprint(characters_bp)
|
bp.register_blueprint(characters_bp)
|
||||||
|
bp.register_blueprint(editor_bp)
|
||||||
bp.register_blueprint(posts_bp)
|
bp.register_blueprint(posts_bp)
|
||||||
bp.register_blueprint(settings_bp)
|
bp.register_blueprint(settings_bp)
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
|
@ -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> </p>
|
||||||
|
</section>
|
||||||
|
<section id="preview-control">
|
||||||
|
<p> </p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -8,17 +8,9 @@
|
||||||
<span style="color:#ff0000">{{ message }}</span><br>
|
<span style="color:#ff0000">{{ message }}</span><br>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% for index in indexed %}
|
{% for article in current_lexicon.articles %}
|
||||||
<b>{{ index }}</b>
|
<p>{{ article.title }} - {{ article.public_id }}</p>
|
||||||
{% if indexed[index] %}
|
<p>{{ article.body }}</p>
|
||||||
<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 %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -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,
|
|
||||||
}
|
|
|
@ -6,25 +6,6 @@ from wtforms.validators import DataRequired
|
||||||
from .settings import ConfigFormBase
|
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):
|
class LexiconReviewForm(FlaskForm):
|
||||||
"""/lexicon/<name>/session/review/"""
|
"""/lexicon/<name>/session/review/"""
|
||||||
APPROVED = 'Y'
|
APPROVED = 'Y'
|
||||||
|
|
|
@ -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> </p>
|
|
||||||
</div>
|
|
||||||
<section id="preview-control">
|
|
||||||
<p> </p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Loading…
Reference in New Issue