sourcing/sourcing/view.py
2023-08-26 17:02:25 -04:00

692 lines
20 KiB
Python

import json
import re
import typing
from functools import wraps
import flask
from flask_login import (
LoginManager,
current_user,
login_required,
login_user,
logout_user,
)
from itsdangerous import URLSafeTimedSerializer
from werkzeug.wrappers import Response
from .database import session
from .edit import apply_edits
from .edl import fulfil_edl, fulfil_edl_with_links, fulfil_edl_with_sources, parse_edl
from .forms import (
AccountSettingsForm,
ForgotPasswordForm,
ItemForm,
LoginForm,
PasswordForm,
SignupForm,
SourceDocForm,
)
from .mail import send_mail
from .model import Item, Reference, SourceDoc, User, XanaLink, XanaPage
from .parse import parse_link, parse_xanapage_facet
from .span import Span
from .text import add_highlight, iter_lines
from .url import get_url
from .utils import nbsp_at_start
# from werkzeug.debug.tbtools import get_current_traceback
# from jinja2 import evalcontextfilter, Markup
# from sqlalchemy_continuum import version_class
login_manager = LoginManager()
login_manager.login_view = ".login"
re_paragraph = re.compile(r"(?:\r\n|\r|\n){2,}")
re_spanpointer = re.compile(r"([A-Za-z0-9]+),start=(\d+),length=(\d+)")
bp = flask.Blueprint("view", __name__)
def init_app(app: flask.Flask) -> None:
"""Initialise app."""
login_manager.init_app(app)
app.register_blueprint(bp)
# @app.template_filter()
# @evalcontextfilter
# def newline_html(eval_ctx, value):
# return u'\n\n'.join(Markup(u'<p>') + p.replace('\n', Markup('<br>')) + Markup(u'</p>')
# for p in re_paragraph.split(value))
@login_manager.user_loader
def load_user(user_id):
return User.query.get(user_id)
# where do we redirect after signup is complete
view_after_signup = ".home"
@bp.context_processor
def inject_user():
return dict(current_user=current_user)
def show_errors(f):
@wraps(f)
def wrapper(*args, **kwargs):
try:
return f(*args, **kwargs)
except Exception:
traceback = get_current_traceback(
skip=1, show_hidden_frames=False, ignore_system_exceptions=True
)
return traceback.render_full().encode("utf-8", "replace")
return wrapper
@bp.route("/")
def home() -> str:
"""Home page."""
docs = Item.query.order_by(Item.created)
docs_info = []
for item in docs:
cur = {
"user": item.user.username,
"id": item.id,
"type": item.type,
"year": item.created.year,
}
if item.type == "xanalink":
cur["link_type"] = item.link_type
docs_info.append(cur)
users = [item_user.username for item_user in User.query]
years = sorted({item["year"] for item in docs_info})
link_types = {item["link_type"] for item in docs_info if item.get("link_type")}
return flask.render_template(
"home.html",
docs=docs,
link_types=link_types,
years=years,
docs_info=docs_info,
nbsp_at_start=nbsp_at_start,
users=users,
)
@bp.route("/password_reset", methods=["GET", "POST"])
def password_reset():
site_name = "perma.pub" # FIXME: move to config
form = ForgotPasswordForm()
if not form.validate_on_submit():
return flask.render_template("auth/password_reset.html", form=form)
ts = URLSafeTimedSerializer(flask.current_app.config["SECRET_KEY"])
user = User.lookup_user_or_email(form.user_or_email.data)
if user:
token = ts.dumps(user.id, salt="password-reset")
reset_link = flask.url_for(".reset_with_token", token=token, _external=True)
reset_mail = flask.render_template(
"mail/password_reset.txt",
reset_link=reset_link,
site_name=site_name,
user=user,
)
subject = "Password reset on " + site_name
send_mail(user, subject, reset_mail)
return flask.redirect(flask.url_for(".password_reset_sent"))
@bp.route("/password_reset/sent", methods=["GET", "POST"])
def password_reset_sent():
return flask.render_template("auth/password_reset_sent.html")
@bp.route("/reset/<token>", methods=["GET", "POST"])
def reset_with_token(token):
ts = URLSafeTimedSerializer(flask.current_app.config["SECRET_KEY"])
try:
user_id = ts.loads(token, salt="password-reset", max_age=86400)
except Exception:
flask.abort(404)
form = PasswordForm()
if not form.validate_on_submit():
return flask.render_template("auth/password_reset_confirm.html", form=form)
user = User.query.get(user_id)
user.set_password(form.password.data)
session.add(user)
session.commit()
return flask.redirect(flask.url_for(".password_reset_complete"))
@bp.route("/reset/done")
def password_reset_complete() -> str:
return flask.render_template("auth/password_reset_complete.html")
@bp.route("/source_doc_upload", methods=["POST"])
@show_errors
def source_doc_upload():
f = flask.request.files["sourcedoc_file"]
text = f.read()
doc = SourceDoc(text=text, user=current_user, filename=f.filename)
session.add(doc)
session.commit()
flask.flash("new source document uploaded")
return flask.redirect(doc.url)
@bp.route("/about")
def about() -> str:
"""About page."""
return flask.render_template("about.html")
@bp.route("/contact")
def contact() -> str:
"""Contact page."""
return flask.render_template("contact.html")
def redirect_to_home() -> Response:
"""Redirect to home page."""
return flask.redirect(flask.url_for(".home"))
@bp.route("/login", methods=["GET", "POST"])
def login() -> Response | str:
"""Login page."""
form = LoginForm(next=flask.request.args.get("next"))
if form.validate_on_submit():
login_user(form.user, remember=form.remember.data)
flask.flash("Logged in successfully.")
return flask.redirect(flask.request.form.get("next") or flask.url_for(".home"))
return flask.render_template("login.html", form=form)
@bp.route("/logout")
def logout() -> Response:
"""Logout and redirect to home."""
logout_user()
flask.flash("You have been logged out.")
return redirect_to_home()
@bp.route("/signup", methods=["GET", "POST"])
def signup():
if not flask.current_app.config.get("ALLOW_SIGNUP"):
flask.abort(404)
form = SignupForm()
if not form.validate_on_submit():
return flask.render_template("signup.html", form=form)
data = form.data.copy()
del data["csrf_token"]
user = User(**data)
session.add(user)
session.commit()
flask.flash("New account created.")
login_user(user)
return flask.redirect(flask.url_for(view_after_signup))
def redirect_to_doc(doc: Item) -> Response:
"""Redirect to the given item."""
return flask.redirect(flask.url_for(".view_document", hashid=doc.hashid))
def get_source_doc(username: str, hashid: str) -> SourceDoc:
"""Get a source doc that belongs to the given uesr."""
doc = Item.get_by_hashid(hashid)
if not doc or doc.user.username != username:
flask.abort(404)
return typing.cast(SourceDoc, doc)
def get_xanapage(username: str, hashid: str) -> XanaPage:
"""Get a xanapage that belongs to the given uesr."""
doc = Item.get_by_hashid(hashid)
if not doc or doc.type != "xanapage" or doc.user.username != username:
flask.abort(404)
return typing.cast(XanaPage, doc)
def get_item(username: str, hashid: str) -> Item:
"""Get an item with the given hashid, check ownership."""
doc = Item.get_by_hashid(hashid)
if not doc or doc.user.username != username:
flask.abort(404)
return doc
@bp.route("/<username>/<hashid>/edl")
def view_edl(username: str, hashid: str) -> str:
"""Show EDL for xanapage."""
item = get_xanapage(username, hashid)
return flask.render_template(
"view.html", doc=item, iter_lines=iter_lines, nbsp_at_start=nbsp_at_start
)
@bp.route("/<username>/<hashid>/realize")
def realize_edl(username, hashid):
item = get_xanapage(username, hashid)
spans = list(fulfil_edl(item.text))
doc_text = "".join(span["text"] for span in spans)
return flask.render_template(
"realize.html",
doc=doc_text,
iter_lines=iter_lines,
item=item,
nbsp_at_start=nbsp_at_start,
)
@bp.route("/<username>/<hashid>/raw")
def view_item_raw(username: str, hashid: str):
return view_item(username, hashid, raw=True)
def fulfil_xanaflight(item: XanaLink) -> str:
link = item.parse()
assert link["type"] == "flight"
facets = link["facets"]
docs = []
edl_list = []
all_links = []
for facet in facets:
xanapage = Item.from_external(parse_xanapage_facet(facet))
assert xanapage and xanapage.type == "xanapage" and xanapage.text
edl = parse_edl(xanapage.text)
edl_list.append((xanapage, edl))
all_links += [parse_link(link["text"]) for link in edl["links"]]
for doc_num, (xanapage, edl) in enumerate(edl_list):
doc = fulfil_edl_with_links(
edl, doc_num=doc_num, links=all_links, hide_all_transclusions=True
)
doc["hashid"] = xanapage.hashid
del doc["link_count"]
docs.append(doc)
return flask.render_template(
"view/xanaflight.html", item=item, link_count=len(all_links), docs=docs
)
@bp.route("/<username>/<hashid>/fulfil")
def fulfil(username, hashid):
item = get_item(username, hashid)
if item.type == "xanapage":
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"):
return fulfil_xanaflight(item)
@bp.route("/<username>/<hashid>/set_title", methods=["POST"])
def set_title(username, hashid):
item = get_item(username, hashid)
has_title = item.has_title()
item.set_title(flask.request.form["title"], current_user)
flask.flash("title change saved" if has_title else "title added")
return flask.redirect(item.url)
@bp.route("/<username>/<hashid>/delete", methods=["POST"])
def delete_item(username, hashid):
item = get_item(username, hashid)
session.delete(item)
session.commit()
flask.flash("item deleted")
return redirect_to_home()
def save_new_xanalink(doc1, doc2):
start1 = flask.request.form["left_start"]
length1 = flask.request.form["left_length"]
start2 = flask.request.form["right_start"]
length2 = flask.request.form["right_length"]
assert length1
assert length2
span1 = f"{doc1.external_url},start={start1},length={length1}"
span2 = f"{doc2.external_url},start={start2},length={length2}"
lines = [
"type=",
"facet=",
"span: " + span1,
"facet=",
"span: " + span2,
]
text = "".join(line + "\n" for line in lines)
obj = XanaLink(user=current_user, text=text)
session.add(obj)
session.commit()
ref1 = Reference(subject_id=obj.id, object_id=doc1.id)
ref2 = Reference(subject_id=obj.id, object_id=doc2.id)
session.add(ref1)
session.add(ref2)
session.commit()
@bp.route("/build_links", methods=["GET", "POST"])
def build_links():
doc1, doc2 = None, None
hashid1, hashid2 = None, None
if "doc1" in flask.request.args:
hashid1 = flask.request.args["doc1"]
doc1 = Item.get_by_hashid(hashid1)
if "doc2" in flask.request.args:
hashid2 = flask.request.args["doc2"]
doc2 = Item.get_by_hashid(hashid2)
if flask.request.method == "POST":
save_new_xanalink(doc1, doc2)
return flask.redirect(
flask.url_for(flask.request.endpoint, doc1=hashid1, doc2=hashid2)
)
if doc1 and doc2:
links = list({i for i in doc1.subjects} & {i for i in doc2.subjects})
else:
links = []
return flask.render_template(
"build_links.html",
iter_lines=iter_lines,
nbsp_at_start=nbsp_at_start,
SourceDoc=SourceDoc,
hashid1=hashid1,
hashid2=hashid2,
doc1=doc1,
doc2=doc2,
links=links,
)
@bp.route("/<username>/<hashid>")
def view_item(username: str, hashid: str, raw: bool = False) -> str | Response:
"""View item."""
if "," in hashid:
m = re_spanpointer.match(hashid)
assert m
hashid, start, length = m.group(1), int(m.group(2)), int(m.group(3))
item = get_item(username, hashid)
if raw:
assert item.text is not None
return flask.Response(
item.text[start : length + start], mimetype="text/plain"
)
else:
start, length = None, None
item = get_item(username, hashid)
if raw:
return flask.Response(item.text, mimetype="text/plain")
v = flask.request.args.get("v")
if v:
if not v.isdigit():
flask.abort(404)
try:
version = item.versions[int(v) - 1]
except IndexError:
flask.abort(404)
text = version.text
else:
version = None
text = item.text
if item.type == "xanapage":
assert item.text
spans = list(fulfil_edl(item.text))
doc_text = "".join(span["text"] for span in spans)
else:
doc_text = None
return flask.render_template(
"view.html",
doc=item,
doc_text=doc_text,
version=version,
text=text,
span_start=start,
span_length=length,
add_highlight=add_highlight,
nbsp_at_start=nbsp_at_start,
iter_lines=iter_lines,
)
@bp.route("/<username>/<hashid>/history")
def history(username: str, hashid: str) -> str:
item = get_item(username, hashid)
return flask.render_template("history.html", doc=item)
@bp.route("/<username>/<hashid>/as_xanapage", methods=["POST"])
def create_xanapage_from_sourcedoc(username, hashid):
src_doc = get_source_doc(username, hashid)
edl = "span: " + src_doc.entire_span + "\n"
page = XanaPage(user=current_user, text=edl)
session.add(page)
session.commit()
page.update_references()
flask.flash("New xanapage created.")
return flask.redirect(page.url)
@bp.route("/<username>/<hashid>/xanaedit", methods=["GET", "POST"])
def xanaedit_item(username, hashid):
if flask.request.method == "POST":
save_xanaedit(username, hashid)
return flask.redirect(
flask.url_for("xanaedit_item", username=username, hashid=hashid)
)
doc = get_xanapage(username, hashid)
spans = list(fulfil_edl(doc.text))
doc_text = "".join(span["text"] for span in spans)
return flask.render_template("xanaedit.html", doc=doc, doc_text=doc_text)
def save_xanaedit(username: str, hashid: str) -> XanaPage:
"""Save a XanaEdit."""
page = get_xanapage(username, hashid)
assert page.text
current_edl = parse_edl(page.text)
spans = [Span(*span) for span in current_edl["spans"]]
edits = json.loads(flask.request.form["edits"])
new_text = ""
new_text_pos = 0
for edit in edits:
if edit["op"] not in ("insert", "replace"):
continue
new_text += edit["new"]
edit["span"] = Span("placeholder", new_text_pos, len(edit["new"]))
new_text_pos += len(edit["new"])
spans = apply_edits(spans, edits)
new_src_doc = SourceDoc(user=current_user, text=new_text)
session.add(new_src_doc)
session.commit()
for span in spans:
if span.url == "placeholder":
span.url = new_src_doc.external_url
new_edl = "".join(f"span: {span.for_edl()}\n" for span in spans)
page.text = new_edl
session.commit()
page.update_references()
flask.flash("Edits saved.")
return page
@bp.route("/<username>/<hashid>/finish", methods=["POST"])
def finish_xanaedit(username: str, hashid: str) -> Response:
page = save_xanaedit(username, hashid)
return flask.redirect(page.url)
@bp.route("/<username>/<hashid>/edit", methods=["GET", "POST"])
def edit_item(username, hashid):
obj = get_item(username, hashid)
form = SourceDocForm(obj=obj)
if form.validate_on_submit():
form.populate_obj(obj)
session.commit()
if obj.type == "xanapage":
obj.update_references()
flask.flash("Changes to {} saved.".format(obj.type))
return flask.redirect(obj.url)
return flask.render_template("edit.html", form=form, doc=obj)
@bp.route("/source_doc_text/<int:source_doc_id>")
def source_doc_text(source_doc_id):
doc = SourceDoc.query.get(source_doc_id)
return flask.render_template("source_doc_text.html", doc=doc, iter_lines=iter_lines)
@bp.route("/settings/account", methods=["GET", "POST"])
@login_required
def account_settings() -> str | Response:
"""Account settings."""
form = AccountSettingsForm(obj=current_user)
if form.validate_on_submit():
form.populate_obj(current_user)
session.commit()
flask.flash("Account details updated.")
assert flask.request.endpoint
return flask.redirect(flask.url_for(flask.request.endpoint))
return flask.render_template("user/account.html", form=form)
@bp.route("/new/sourcedoc", methods=["GET", "POST"])
@login_required
def new_sourcedoc() -> str | Response:
"""Add a new sourcedoc."""
form = SourceDocForm()
if form.validate_on_submit():
doc = SourceDoc(user=current_user)
form.populate_obj(doc)
session.add(doc)
session.commit()
flask.flash("New document saved.")
return flask.redirect(doc.url)
return flask.render_template("new.html", form=form, item_type="source document")
@bp.route("/new/xanalink/raw", methods=["GET", "POST"])
@login_required
def new_xanalink_raw():
form = ItemForm()
if form.validate_on_submit():
obj = XanaLink(user=current_user)
form.populate_obj(obj)
session.add(obj)
session.commit()
flask.flash("New xanalink saved.")
return flask.redirect(obj.url)
return flask.render_template("new.html", form=form, item_type="xanalink")
@bp.route("/new/xanalink", methods=["GET", "POST"])
@login_required
def new_xanalink() -> str | Response:
"""Add a new xanalink."""
if flask.request.method != "POST":
return flask.render_template("new_xanalink.html")
data = flask.request.get_json()
lines = ["type=" + data["link_type"]]
for facet in data["facets"]:
lines += ["facet="] + facet
text = "".join(line + "\n" for line in lines)
obj = XanaLink(user=current_user, text=text)
session.add(obj)
session.commit()
flask.flash("New xanalink saved.")
return flask.jsonify(url=obj.url)
@bp.route("/new/xanapage", methods=["GET", "POST"])
@login_required
def new_xanapage() -> str | Response:
"""Start a new xanapage."""
form = ItemForm()
if form.validate_on_submit():
obj = XanaPage(user=current_user)
form.populate_obj(obj)
session.add(obj)
session.commit()
flask.flash("New xanapage saved.")
return flask.redirect(obj.url)
return flask.render_template("new.html", form=form, item_type="xanapage")
@bp.route("/edit/<filename>", methods=["GET", "POST"])
@login_required
def edit_source_document(filename: str) -> str | Response:
"""Edit a source document."""
doc = get_source_doc(current_user.username, filename)
form = SourceDocForm(obj=doc)
if form.validate_on_submit():
form.populate_obj(doc)
session.add(doc)
session.commit()
flask.flash("Changes to document saved.")
return flask.redirect(doc.url)
return flask.render_template("edit.html", form=form, doc=doc)
@bp.route("/api/1/get/<username>/<filename>")
def api_get_document(username: str, filename: str) -> Response:
doc = get_source_doc(username, filename)
assert doc.text is not None
ret = {
"username": username,
"filename": filename,
"character_count": len(doc.text),
"document_price": str(doc.document_price),
"price_per_character": str(doc.price_per_character),
}
return flask.jsonify(ret)
@bp.route("/get_span.json")
def get_span() -> Response:
"""Return JSON representing a span."""
url = flask.request.args["url"]
start = int(flask.request.args["start"])
length = int(flask.request.args["length"])
spanid = flask.request.args["spanid"]
text = get_url(url)
return flask.jsonify(text=text[start : start + length], spanid=spanid)