Improvements

This commit is contained in:
Edward Betts 2023-08-29 11:00:24 -04:00
parent 51b09edbf5
commit fcc3afa4dc
9 changed files with 306 additions and 138 deletions

View file

@ -15,7 +15,7 @@ def init_db(db_url: str) -> None:
def get_engine(db_url: str) -> sqlalchemy.engine.base.Engine:
"""Create an engine object."""
return create_engine(db_url, pool_recycle=3600)
return create_engine(db_url, echo=False, pool_recycle=3600)
def init_app(app: flask.app.Flask) -> None:

View file

@ -81,7 +81,7 @@ def fulfil_edl_with_sources(
def fulfil_edl_with_links(
edl: EDLDict,
doc_num: str = "",
doc_num: int | str = "",
links: list[dict[str, str]] | None = None,
hide_all_transclusions: bool = False,
):

View file

@ -1,82 +1,135 @@
from flask_wtf import FlaskForm
from wtforms.fields import StringField, PasswordField, BooleanField, HiddenField, TextAreaField, FileField, IntegerField
from wtforms.validators import InputRequired, Email, Length, ValidationError, Regexp, NoneOf, Optional
from .model import User, LoginError, re_username, reserved_name, user_exists
from wtforms.fields import (
BooleanField,
FileField,
HiddenField,
IntegerField,
PasswordField,
StringField,
TextAreaField,
)
from wtforms.validators import (
Email,
InputRequired,
Length,
NoneOf,
Optional,
Regexp,
ValidationError,
)
from .model import LoginError, User, re_username, reserved_name, user_exists
PASSWORD_LEN = 64
EMAIL_LEN = 64
class SignupForm(FlaskForm):
username = StringField('username',
[InputRequired(),
Regexp(re_username),
NoneOf(reserved_name, message='Not available.'),
Length(min=3, max=64)],
[lambda name: name and name.replace(' ', '_')])
email = StringField('e-mail address',
[InputRequired(), Email(),
Length(min=5, max=EMAIL_LEN)],
description="we never share your e-mail address")
password = StringField('password',
[InputRequired(), Length(min=4, max=PASSWORD_LEN)])
"""Signup form."""
username = StringField(
"username",
[
InputRequired(),
Regexp(re_username),
NoneOf(reserved_name, message="Not available."),
Length(min=3, max=64),
],
[lambda name: name and name.replace(" ", "_")],
)
email = StringField(
"e-mail address",
[InputRequired(), Email(), Length(min=5, max=EMAIL_LEN)],
description="we never share your e-mail address",
)
password = StringField(
"password", [InputRequired(), Length(min=4, max=PASSWORD_LEN)]
)
def validate_username(form, field):
if user_exists(User.username, field.data):
raise ValidationError('Not available')
raise ValidationError("Not available")
def validate_email(form, field):
if user_exists(User.email, field.data):
raise ValidationError('In use by another account')
raise ValidationError("In use by another account")
class LoginForm(FlaskForm):
user_or_email = StringField('username or e-mail address',
[InputRequired(), Length(min=3, max=EMAIL_LEN)],
[lambda name: name and name.replace(' ', '_')])
password = PasswordField('password',
[InputRequired(), Length(max=PASSWORD_LEN)])
remember = BooleanField('stay logged in')
next = HiddenField('next')
"""Login form."""
def validate(self):
user_or_email = StringField(
"username or e-mail address",
[InputRequired(), Length(min=3, max=EMAIL_LEN)],
[lambda name: name and name.replace(" ", "_")],
)
password = PasswordField("password", [InputRequired(), Length(max=PASSWORD_LEN)])
remember = BooleanField("stay logged in")
next = HiddenField("next")
def validate(self) -> bool:
"""Validate."""
rv = FlaskForm.validate(self)
if not rv:
return False
try:
self.user = User.attempt_login(self.user_or_email.data,
self.password.data)
self.user = User.attempt_login(self.user_or_email.data, self.password.data)
return True
except LoginError as e:
self.user_or_email.errors.append(e.msg)
return False
class ForgotPasswordForm(FlaskForm):
user_or_email = StringField('username or e-mail address',
[InputRequired(), Length(max=EMAIL_LEN)])
"""Forgot password form."""
user_or_email = StringField(
"username or e-mail address", [InputRequired(), Length(max=EMAIL_LEN)]
)
class PasswordForm(FlaskForm):
password = PasswordField('new password',
[InputRequired(), Length(min=4, max=PASSWORD_LEN)])
"""Password form."""
password = PasswordField(
"new password", [InputRequired(), Length(min=4, max=PASSWORD_LEN)]
)
class AccountSettingsForm(FlaskForm):
full_name = StringField('full name', [Length(max=64)])
email = StringField('e-mail address',
[InputRequired(), Email(),
Length(min=5, max=EMAIL_LEN)])
"""Account settings form."""
full_name = StringField("full name", [Length(max=64)])
email = StringField(
"e-mail address", [InputRequired(), Email(), Length(min=5, max=EMAIL_LEN)]
)
class ChangePasswordForm(FlaskForm):
old_password = PasswordField('current password',
[InputRequired(), Length(max=PASSWORD_LEN)])
new_password = PasswordField('new password',
[InputRequired(), Length(max=PASSWORD_LEN)])
"""Change password form."""
old_password = PasswordField(
"current password", [InputRequired(), Length(max=PASSWORD_LEN)]
)
new_password = PasswordField(
"new password", [InputRequired(), Length(max=PASSWORD_LEN)]
)
class SourceDocForm(FlaskForm):
text = TextAreaField('text', [InputRequired()])
db_price_per_character = IntegerField('price per character', [Optional()])
db_document_price = IntegerField('document price', [Optional()])
"""Source doc form."""
text = TextAreaField("text", [InputRequired()])
db_price_per_character = IntegerField("price per character", [Optional()])
db_document_price = IntegerField("document price", [Optional()])
class ItemForm(FlaskForm):
text = TextAreaField('text', [InputRequired()])
"""Item form."""
text = TextAreaField("text", [InputRequired()])
class UploadSourceDocForm(FlaskForm):
sourcedoc_file = FileField('SourceDoc', [Regexp(r'^[^/\\]+\.txt$')])
sourcedoc_file = FileField("SourceDoc", [Regexp(r"^[^/\\]+\.txt$")])

View file

@ -27,7 +27,7 @@ from sqlalchemy_continuum.plugins import ActivityPlugin, FlaskPlugin
from werkzeug.security import check_password_hash, generate_password_hash
from .database import session
from .parse import parse_link, parse_span
from .parse import ParsedLink, SpanTuple, parse_link, parse_span
from .text import first_non_empty_line
activity_plugin = ActivityPlugin()
@ -79,27 +79,37 @@ re_username = re.compile(r"^\w+$", re.U)
re_full_name = re.compile(r'^([-.\'" ]|[^\W\d_])+$', re.U)
re_comment = re.compile(r"#.*")
AllTitles = dict["Item", str]
def item_url():
return url_for("view.view_item", username=self.user.username, hashid=self.hashid)
def user_exists(field, value):
return session.query(exists().where(field == value)).scalar()
def user_exists(field: Column[str], value: str) -> bool:
"""Check that user exists."""
return typing.cast(bool, session.query(exists().where(field == value)).scalar())
class TimeStampedModel(Base):
"""Time stamped model."""
__abstract__ = True
created = Column(DateTime, default=func.now())
modified = Column(DateTime, default=func.now(), onupdate=func.now())
class LoginError(Exception):
def __init__(self, msg):
"""Login error."""
def __init__(self, msg: str):
"""Init."""
self.msg = msg
class User(TimeStampedModel, UserMixin):
"""User model."""
__tablename__ = "user"
id = Column(Integer, primary_key=True)
username = Column(Unicode(32), unique=True, nullable=False)
@ -118,28 +128,35 @@ class User(TimeStampedModel, UserMixin):
user_name = synonym("username")
def __init__(self, **kwargs):
"""Init."""
pw_hash = generate_password_hash(kwargs.pop("password"))
return super(User, self).__init__(pw_hash=pw_hash, **kwargs)
def __repr__(self):
def __repr__(self) -> str:
"""Repr."""
return "<User: {!r}>".format(self.username)
def set_password(self, password):
def set_password(self, password: str) -> None:
"""Set password."""
self.pw_hash = generate_password_hash(password)
def check_password(self, password):
def check_password(self, password: str) -> bool:
"""Check password."""
return check_password_hash(self.pw_hash, password)
def get_id(self):
def get_id(self) -> int:
"""Get ID."""
return self.id
@validates("email")
def validate_email(self, key, value):
def validate_email(self, key, value: str) -> str:
"""Validate email."""
assert "@" in value
return value
@validates("username")
def validate_usernane(self, key, value):
"""Validate username."""
assert re_username.match(value)
return value
@ -150,21 +167,24 @@ class User(TimeStampedModel, UserMixin):
return value
@hybrid_property
def is_live(self):
return self.email_verified & ~self.disabled & ~self.deleted
def is_live(self) -> bool:
"""User account is live."""
return bool(self.email_verified & ~self.disabled & ~self.deleted)
@classmethod
def lookup_user_or_email(cls, user_or_email):
def lookup_user_or_email(cls, user_or_email: str) -> User:
"""Lookup user or email."""
field = cls.email if "@" in user_or_email else cls.username
return cls.query.filter(field == user_or_email).one_or_none()
return typing.cast(User, cls.query.filter(field == user_or_email).one_or_none())
@property
def mail_to_name(self):
def mail_to_name(self) -> str:
"""Name to use on e-mails sent to the user."""
return self.full_name or self.username
@classmethod
def attempt_login(cls, user_or_email, password):
def attempt_login(cls, user_or_email: str, password: str) -> User:
"""Attempt login."""
user = cls.lookup_user_or_email(user_or_email)
if not user:
raise LoginError("user not found")
@ -208,7 +228,7 @@ class Item(TimeStampedModel):
primaryjoin=id == Reference.subject_id,
secondaryjoin=id == Reference.object_id,
)
user = relationship("User", backref="items")
user = relationship("User", backref="items", lazy="select")
__mapper_args__ = {
"polymorphic_on": type,
@ -218,7 +238,7 @@ class Item(TimeStampedModel):
@property
def hashid(self) -> str:
"""Hashid for item."""
return doc_hashids.encode(self.id)
return typing.cast(str, doc_hashids.encode(self.id))
@classmethod
def get_by_hashid(cls, hashid: str) -> Item | None:
@ -229,7 +249,7 @@ class Item(TimeStampedModel):
return None
return typing.cast("Item", cls.query.get(item_id))
def view_url(self, endpoint, **kwargs) -> str:
def view_url(self, endpoint: str, **kwargs) -> str:
return url_for(
"view." + endpoint,
username=self.user.username,
@ -239,21 +259,27 @@ class Item(TimeStampedModel):
@property
def url(self) -> str:
"""URL for to view this item."""
return self.view_url("view_item")
def url_fragment(self):
def url_fragment(self) -> str:
"""URL fragment."""
return self.user.username + "/" + self.hashid
def version_url(self, version):
def version_url(self, version: int) -> str:
"""URL for version."""
return self.view_url("view_item", v=version)
@property
def history_url(self):
def history_url(self) -> str:
"""History URL."""
return self.view_url("history")
@property
def external_url(self):
def external_url(self) -> str:
"""External URL."""
base_url = current_app.config.get("BASE_URL")
assert base_url and isinstance(base_url, str)
if not base_url.endswith("/"):
base_url += "/"
if base_url:
@ -262,19 +288,23 @@ class Item(TimeStampedModel):
return self.view_url("view_item", _external=True)
@property
def edit_url(self):
def edit_url(self) -> str:
"""Edit URL."""
return self.view_url("edit_item")
@property
def set_title_url(self):
def set_title_url(self) -> str:
"""Set title URL."""
return self.view_url("set_title")
def title_from_link(self, titles=None):
if not titles:
def title_from_link(self, titles: AllTitles | None = None) -> str | None:
"""Get title from link."""
if titles is None:
titles = XanaLink.get_all_titles()
return titles.get(self)
def title(self, titles=None):
def title(self, titles: AllTitles | None = None) -> str:
"""Get title."""
return self.type + ": " + (self.title_from_link(titles) or self.hashid)
def has_title(self) -> bool:
@ -282,7 +312,8 @@ class Item(TimeStampedModel):
titles = XanaLink.get_all_titles()
return self in titles
def set_title(self, title, user):
def set_title(self, title: str, user: User) -> None:
"""Set item title."""
title_source_doc = SourceDoc(text=title, user=user)
session.add(title_source_doc)
session.commit()
@ -299,8 +330,20 @@ span: {},start=0,length={}""".format(
session.commit()
@classmethod
def from_external(cls, url: str, home: str | None = None) -> None | "Item":
def from_external(cls, url: str, home: str | None = None) -> None | Item:
"""Get item from URL."""
username: str | None
hashid: str | None
parts = url.split("/")
username, hashid = parts[-2:]
item_id = doc_hashids.decode(hashid)[0]
q = cls.query.filter(User.username == username, cls.id == item_id)
item = q.one_or_none()
if item:
return typing.cast(Item, item)
base = current_app.config.get("BASE_URL")
username, hashid = None, None
if home is None:
@ -329,18 +372,23 @@ class XanaPage(Item):
id = Column(Integer, ForeignKey(Item.id), primary_key=True)
def snippet(self):
def snippet(self) -> str:
"""Snippet of text."""
return self.text
@property
def xanaedit_url(self):
def xanaedit_url(self) -> str:
"""XanaExit URL."""
return self.view_url("xanaedit_item")
@property
def save_xanaedit_url(self):
def save_xanaedit_url(self) -> str:
"""XanaExit save URL."""
return self.view_url("save_xanaedit")
def iter_spans(self):
def iter_spans(self) -> typing.Iterator[SpanTuple]:
"""Span iterator."""
assert self.text is not None
for line in self.text.splitlines():
line = re_comment.sub("", line).strip()
if not line:
@ -364,25 +412,32 @@ class XanaPage(Item):
class XanaLink(Item):
"""XanaLink."""
__tablename__ = "xanalink"
__mapper_args__ = {"polymorphic_identity": "xanalink"}
id = Column(Integer, ForeignKey(Item.id), primary_key=True)
def parse(self):
def parse(self) -> ParsedLink:
"""Parse link."""
assert self.text is not None
return parse_link(self.text)
@property
def link_type(self):
def link_type(self) -> str:
"""Get link type."""
return self.parse()["type"]
def title(self, titles=None):
def title(self, titles: AllTitles | None = None) -> str:
"""Title of link."""
if titles is None:
titles = XanaLink.get_all_titles()
if self in titles:
return self.type + ": " + titles[self]
parsed = self.parse()
assert isinstance(parsed["type"], str)
if parsed["type"] == "title":
ident = parsed["facets"][0][0].partition(": ")[2]
@ -418,7 +473,8 @@ class XanaLink(Item):
return (item, source_of_title.text[start : length + start])
@classmethod
def get_all_titles(cls, home: str | None = None) -> dict["Item", str]:
def get_all_titles(cls, home: str | None = None) -> AllTitles:
"""Get all known titles."""
titles = {}
for link in cls.query:
ret = link.item_and_title(home)
@ -428,7 +484,8 @@ class XanaLink(Item):
titles[item] = title
return titles
def snippet(self):
def snippet(self) -> str | None:
"""Snippet of text."""
return self.text
@ -442,13 +499,23 @@ class SourceDoc(Item):
@property
def document_price(self):
assert self.text is not None
return self.db_document_price or self.db_price_per_character * len(self.text)
@property
def price_per_character(self):
assert self.text is not None
return self.db_price_per_character or self.db_document_price / len(self.text)
def snippet(self, length=255, killwords=False, end="...", leeway=5):
def snippet(
self,
length: int = 255,
killwords: bool = False,
end: str = "...",
leeway: int = 5,
) -> str:
"""Get snippet of text."""
assert self.text is not None
s = self.text
assert length >= len(end), "expected length >= %s, got %s" % (len(end), length)
assert leeway >= 0, "expected leeway >= 0, got %s" % leeway
@ -459,26 +526,33 @@ class SourceDoc(Item):
result = s[: length - len(end)].rsplit(" ", 1)[0]
return result + end
def raw_title(self):
def raw_title(self) -> str:
"""Raw title."""
return self.title(with_type=False)
def title(self, titles=None, with_type=True):
def title(self, titles: AllTitles | None = None, with_type: bool = True) -> str:
"""Source document title."""
start = self.type + ": " if with_type else ""
titles = XanaLink.get_all_titles()
if titles is None:
titles = XanaLink.get_all_titles()
from_link = self.title_from_link(titles=titles)
if from_link:
return start + from_link
assert self.text is not None
first_line = first_non_empty_line(self.text)
if first_line:
return start + first_line
return start + self.hashid
@property
def create_xanapage_url(self):
def create_xanapage_url(self) -> str:
"""Create xanapage URL."""
return self.view_url("create_xanapage_from_sourcedoc")
@property
def entire_span(self):
def entire_span(self) -> str:
"""Entire span."""
assert self.text is not None
return self.external_url + f",start=0,length={len(self.text)}"

View file

@ -3,7 +3,7 @@ import re
import typing
from html import escape
from .url import ExternalText
from .types import ExternalText
re_span_pointer = re.compile(r"span: (.*),start=(\d+),length=(\d+)")
re_xanalink = re.compile(r"xanalink: +([^ ]+) *$")
@ -28,6 +28,8 @@ SpanTuple = tuple[str, int, int]
SourceText = dict[str, ExternalText]
ParsedLink = dict[str, str | None | list[list[str]]]
def parse_span(line: str) -> None | SpanTuple:
"""Parse a span."""
@ -118,7 +120,7 @@ def parse_xanapage_facet(facet: list[str]) -> str:
return leg[len(prefix) :]
def parse_link(link_text: str) -> dict[str, str | None | list[list[str]]]:
def parse_link(link_text: str) -> ParsedLink:
link_type = None
expect = "link_type"
facets = []

View file

@ -68,8 +68,12 @@
{% for doc in docs %}
<div data-id="{{ doc.id }}" class="card border-primary my-2">
<h5 class="card-header"><a href="{{ doc.url }}">{{ doc.title() }}</a>
&mdash; {{ doc.user.username }} &mdash; {{ doc.created | datetime }}</h5>
<h5 class="card-header">
<a href="{{ doc.url }}">{{ doc.title(titles=titles) }}</a>
{#
&mdash; {{ doc.user.username }} &mdash; {{ doc.created | datetime }}
#}
</h5>
<div class="card-body">
<p class="card-text">
{%- for line in doc.snippet().splitlines() -%}
@ -79,6 +83,7 @@
</div>
</div>
{% endfor %}
<div id="lower-buttons">
{{ new_buttons() }}
</div>

View file

@ -1,12 +1,18 @@
import re
"""Text utility functions."""
import random
import re
import typing
re_newline = re.compile('\r?\n')
re_newline = re.compile("\r?\n")
def find_newlines(text):
def find_newlines(text: str) -> typing.Iterator[int]:
"""Find position of newlines in a piece of text."""
return (m.end(0) for m in re_newline.finditer(text))
def iter_lines(text):
def iter_lines(text: str) -> typing.Iterator[tuple[int, str]]:
start = 0
for m in re_newline.finditer(text):
end = m.end(0)
@ -15,33 +21,42 @@ def iter_lines(text):
if start < len(text) - 1:
yield (start, text[start:])
def censor_text(text):
def random_chr():
return chr(random.randint(9728, 9983))
return ''.join(random_chr() if c.isalnum() else c for c in text)
def add_highlight(text, span_start, span_length):
def censor_text(text: str) -> str:
"""Censor text with a random character."""
def random_chr() -> str:
return chr(random.randint(9728, 9983))
return "".join(random_chr() if c.isalnum() else c for c in text)
def add_highlight(text: str, span_start: int, span_length: int):
span_end = span_start + span_length
for start, line in iter_lines(text):
end = start + len(line)
if not ((end > span_start) and (start < span_end)):
yield start, [{'text': line}]
yield start, [{"text": line}]
continue
within_line = span_start - start
cur = []
if within_line > 0:
before = line[:within_line]
cur.append({'text': before})
cur.append({'highlight': line[max(within_line, 0):within_line + span_length]})
after = line[within_line + span_length:]
cur.append({"text": before})
cur.append({"highlight": line[max(within_line, 0) : within_line + span_length]})
after = line[within_line + span_length :]
if after:
cur.append({'text': after})
cur.append({"text": after})
yield start, cur
def first_non_empty_line(text):
def first_non_empty_line(text: str) -> str | None:
"""First non-empty line."""
for start, cur in iter_lines(text):
tidy = cur.strip()
if tidy:
return tidy
return None

View file

@ -5,6 +5,7 @@ import typing
import requests
from .model import Item
from .types import ExternalText
project_dir = os.path.dirname(os.path.dirname(__file__))
cache_location = os.path.join(project_dir, "cache")
@ -17,15 +18,6 @@ def url_filename(url: str) -> str:
return re_colon_slash.sub("_", url)
class ExternalText(typing.TypedDict):
"""Text from external URL."""
url: str
text: str
heading: str
length: int
def get_text(url: str) -> ExternalText:
"""Get text from URL and return as dict."""
# assume UTF-8

View file

@ -108,10 +108,13 @@ def home() -> str:
link_types = {item["link_type"] for item in docs_info if item.get("link_type")}
titles = XanaLink.get_all_titles()
return flask.render_template(
"home.html",
docs=docs,
link_types=link_types,
titles=titles,
years=years,
docs_info=docs_info,
nbsp_at_start=nbsp_at_start,
@ -120,7 +123,8 @@ def home() -> str:
@bp.route("/password_reset", methods=["GET", "POST"])
def password_reset():
def password_reset() -> str | Response:
"""Password reset."""
site_name = "perma.pub" # FIXME: move to config
form = ForgotPasswordForm()
if not form.validate_on_submit():
@ -142,12 +146,13 @@ def password_reset():
@bp.route("/password_reset/sent", methods=["GET", "POST"])
def password_reset_sent():
def password_reset_sent() -> str:
"""Password reset form."""
return flask.render_template("auth/password_reset_sent.html")
@bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_with_token(token):
def reset_with_token(token: str) -> str | Response:
ts = URLSafeTimedSerializer(flask.current_app.config["SECRET_KEY"])
try:
user_id = ts.loads(token, salt="password-reset", max_age=86400)
@ -173,7 +178,8 @@ def password_reset_complete() -> str:
@bp.route("/source_doc_upload", methods=["POST"])
@show_errors
def source_doc_upload():
def source_doc_upload() -> Response:
"""Source doc upload."""
f = flask.request.files["sourcedoc_file"]
text = f.read()
doc = SourceDoc(text=text, user=current_user, filename=f.filename)
@ -220,7 +226,7 @@ def logout() -> Response:
@bp.route("/signup", methods=["GET", "POST"])
def signup():
def signup() -> str | Response:
if not flask.current_app.config.get("ALLOW_SIGNUP"):
flask.abort(404)
form = SignupForm()
@ -277,9 +283,10 @@ def view_edl(username: str, hashid: str) -> str:
@bp.route("/<username>/<hashid>/realize")
def realize_edl(username, hashid):
def realize_edl(username: str, hashid: str) -> str:
item = get_xanapage(username, hashid)
assert item.text is not None
spans = list(fulfil_edl(item.text))
doc_text = "".join(span["text"] for span in spans)
@ -293,7 +300,7 @@ def realize_edl(username, hashid):
@bp.route("/<username>/<hashid>/raw")
def view_item_raw(username: str, hashid: str):
def view_item_raw(username: str, hashid: str) -> str | Response:
return view_item(username, hashid, raw=True)
@ -324,19 +331,29 @@ def fulfil_xanaflight(item: XanaLink) -> str:
@bp.route("/<username>/<hashid>/fulfil")
def fulfil(username, hashid):
def fulfil(username: str, hashid: str) -> str:
"""Fufil XanaPage or XanaLink."""
item = get_item(username, hashid)
if item.type == "xanapage":
assert item.text is not None
return flask.render_template(
"view/xanapage.html", item=item, doc=fulfil_edl_with_sources(item.text)
)
if item.type == "xanalink" and item.text.startswith("type=flight"):
if (
item.type == "xanalink"
and item.text is not None
and item.text.startswith("type=flight")
):
assert isinstance(item, XanaLink)
return fulfil_xanaflight(item)
assert False
@bp.route("/<username>/<hashid>/set_title", methods=["POST"])
def set_title(username, hashid):
def set_title(username: str, hashid: str) -> Response:
item = get_item(username, hashid)
has_title = item.has_title()
item.set_title(flask.request.form["title"], current_user)
@ -345,7 +362,7 @@ def set_title(username, hashid):
@bp.route("/<username>/<hashid>/delete", methods=["POST"])
def delete_item(username, hashid):
def delete_item(username: str, hashid: str) -> Response:
item = get_item(username, hashid)
session.delete(item)
session.commit()
@ -353,7 +370,8 @@ def delete_item(username, hashid):
return redirect_to_home()
def save_new_xanalink(doc1, doc2):
def save_new_xanalink(doc1: XanaPage, doc2: XanaPage) -> None:
"""Save XanaLink."""
start1 = flask.request.form["left_start"]
length1 = flask.request.form["left_length"]
start2 = flask.request.form["right_start"]
@ -385,7 +403,8 @@ def save_new_xanalink(doc1, doc2):
@bp.route("/build_links", methods=["GET", "POST"])
def build_links():
def build_links() -> str | Response:
"""Build links."""
doc1, doc2 = None, None
hashid1, hashid2 = None, None
if "doc1" in flask.request.args:
@ -396,12 +415,17 @@ def build_links():
doc2 = Item.get_by_hashid(hashid2)
if flask.request.method == "POST":
assert (
doc1 and doc2 and isinstance(doc1, XanaPage) and isinstance(doc2, XanaPage)
)
save_new_xanalink(doc1, doc2)
assert flask.request.endpoint
return flask.redirect(
flask.url_for(flask.request.endpoint, doc1=hashid1, doc2=hashid2)
)
if doc1 and doc2:
assert isinstance(doc1, XanaPage) and isinstance(doc2, XanaPage)
links = list({i for i in doc1.subjects} & {i for i in doc2.subjects})
else:
links = []
@ -479,7 +503,7 @@ def history(username: str, hashid: str) -> str:
@bp.route("/<username>/<hashid>/as_xanapage", methods=["POST"])
def create_xanapage_from_sourcedoc(username, hashid):
def create_xanapage_from_sourcedoc(username: str, hashid: str) -> Response:
src_doc = get_source_doc(username, hashid)
edl = "span: " + src_doc.entire_span + "\n"
@ -493,7 +517,7 @@ def create_xanapage_from_sourcedoc(username, hashid):
@bp.route("/<username>/<hashid>/xanaedit", methods=["GET", "POST"])
def xanaedit_item(username, hashid):
def xanaedit_item(username: str, hashid: str) -> str | Response:
if flask.request.method == "POST":
save_xanaedit(username, hashid)
@ -502,6 +526,7 @@ def xanaedit_item(username, hashid):
)
doc = get_xanapage(username, hashid)
assert doc.text is not None
spans = list(fulfil_edl(doc.text))
doc_text = "".join(span["text"] for span in spans)
@ -554,7 +579,7 @@ def finish_xanaedit(username: str, hashid: str) -> Response:
@bp.route("/<username>/<hashid>/edit", methods=["GET", "POST"])
def edit_item(username, hashid):
def edit_item(username: str, hashid: str) -> str | Response:
obj = get_item(username, hashid)
form = SourceDocForm(obj=obj)
if form.validate_on_submit():
@ -568,7 +593,7 @@ def edit_item(username, hashid):
@bp.route("/source_doc_text/<int:source_doc_id>")
def source_doc_text(source_doc_id):
def source_doc_text(source_doc_id: int) -> str:
doc = SourceDoc.query.get(source_doc_id)
return flask.render_template("source_doc_text.html", doc=doc, iter_lines=iter_lines)
@ -605,7 +630,7 @@ def new_sourcedoc() -> str | Response:
@bp.route("/new/xanalink/raw", methods=["GET", "POST"])
@login_required
def new_xanalink_raw():
def new_xanalink_raw() -> str | Response:
form = ItemForm()
if form.validate_on_submit():
obj = XanaLink(user=current_user)
@ -625,8 +650,9 @@ def new_xanalink() -> str | Response:
return flask.render_template("new_xanalink.html")
data = flask.request.get_json()
lines = ["type=" + data["link_type"]]
for facet in data["facets"]:
assert isinstance(data, dict)
lines = ["type=" + typing.cast(str, data["link_type"])]
for facet in typing.cast(list[list[str]], data["facets"]):
lines += ["facet="] + facet
text = "".join(line + "\n" for line in lines)
obj = XanaLink(user=current_user, text=text)
@ -668,6 +694,7 @@ def edit_source_document(filename: str) -> str | Response:
@bp.route("/api/1/get/<username>/<filename>")
def api_get_document(username: str, filename: str) -> Response:
"""API to get a document, returns JSON."""
doc = get_source_doc(username, filename)
assert doc.text is not None
ret = {