From 8837b1e04ff1c8c1d36785bb5b445ef44c7ca694 Mon Sep 17 00:00:00 2001 From: Edward Betts <edward@4angle.com> Date: Mon, 30 Jan 2017 10:22:05 +0000 Subject: [PATCH] initial commit --- autoapp.py | 2 + create_db.py | 12 ++ run.py | 7 + sourcing/__init__.py | 12 ++ sourcing/database.py | 18 ++ sourcing/forms.py | 75 +++++++ sourcing/model.py | 234 ++++++++++++++++++++++ sourcing/parse.py | 273 ++++++++++++++++++++++++++ sourcing/static/bootstrap | 1 + sourcing/static/handlebars | 1 + sourcing/static/jquery | 1 + sourcing/static/jquery-ui | 1 + sourcing/static/js/doc.js | 15 ++ sourcing/static/js/sourcedoc.js | 49 +++++ sourcing/templates/edit.html | 17 ++ sourcing/templates/flash_msg.html | 10 + sourcing/templates/foot.html | 11 ++ sourcing/templates/form/controls.html | 33 ++++ sourcing/templates/form/errors.html | 9 + sourcing/templates/form/main.html | 11 ++ sourcing/templates/form/simple.html | 10 + sourcing/templates/head.html | 16 ++ sourcing/templates/home.html | 23 +++ sourcing/templates/login.html | 13 ++ sourcing/templates/mail/signup.txt | 1 + sourcing/templates/navbar.html | 34 ++++ sourcing/templates/new.html | 15 ++ sourcing/templates/signup.html | 13 ++ sourcing/templates/source_doc.html | 33 ++++ sourcing/templates/user/account.html | 12 ++ sourcing/templates/view.html | 34 ++++ sourcing/text.py | 21 ++ sourcing/utils.py | 13 ++ sourcing/view.py | 232 ++++++++++++++++++++++ titles.py | 10 + 35 files changed, 1272 insertions(+) create mode 100644 autoapp.py create mode 100755 create_db.py create mode 100755 run.py create mode 100644 sourcing/__init__.py create mode 100644 sourcing/database.py create mode 100644 sourcing/forms.py create mode 100644 sourcing/model.py create mode 100644 sourcing/parse.py create mode 120000 sourcing/static/bootstrap create mode 120000 sourcing/static/handlebars create mode 120000 sourcing/static/jquery create mode 120000 sourcing/static/jquery-ui create mode 100644 sourcing/static/js/doc.js create mode 100644 sourcing/static/js/sourcedoc.js create mode 100644 sourcing/templates/edit.html create mode 100644 sourcing/templates/flash_msg.html create mode 100644 sourcing/templates/foot.html create mode 100644 sourcing/templates/form/controls.html create mode 100644 sourcing/templates/form/errors.html create mode 100644 sourcing/templates/form/main.html create mode 100644 sourcing/templates/form/simple.html create mode 100644 sourcing/templates/head.html create mode 100644 sourcing/templates/home.html create mode 100644 sourcing/templates/login.html create mode 100644 sourcing/templates/mail/signup.txt create mode 100644 sourcing/templates/navbar.html create mode 100644 sourcing/templates/new.html create mode 100644 sourcing/templates/signup.html create mode 100644 sourcing/templates/source_doc.html create mode 100644 sourcing/templates/user/account.html create mode 100644 sourcing/templates/view.html create mode 100644 sourcing/text.py create mode 100644 sourcing/utils.py create mode 100644 sourcing/view.py create mode 100755 titles.py diff --git a/autoapp.py b/autoapp.py new file mode 100644 index 0000000..1778f8d --- /dev/null +++ b/autoapp.py @@ -0,0 +1,2 @@ +from sourcing import create_app +app = create_app('config.default') diff --git a/create_db.py b/create_db.py new file mode 100755 index 0000000..c9438fd --- /dev/null +++ b/create_db.py @@ -0,0 +1,12 @@ +#!/usr/bin/python3 + +from sourcing.model import Base +from sourcing.database import init_db, session +from sourcing import create_app + +app = create_app('config.default') +db_url = app.config['DB_URL'] +init_db(db_url) +engine = session.get_bind() +# Base.metadata.drop_all(engine) +Base.metadata.create_all(engine) diff --git a/run.py b/run.py new file mode 100755 index 0000000..de5dd14 --- /dev/null +++ b/run.py @@ -0,0 +1,7 @@ +#!/usr/bin/python3 + +from sourcing import create_app + +if __name__ == "__main__": + app = create_app('config.default') + app.run('0.0.0.0', debug=True) diff --git a/sourcing/__init__.py b/sourcing/__init__.py new file mode 100644 index 0000000..aa2e424 --- /dev/null +++ b/sourcing/__init__.py @@ -0,0 +1,12 @@ +from flask import Flask +from . import database +from . import view +from .utils import display_datetime + +def create_app(config): + app = Flask(__name__) + app.config.from_object(config) + database.init_app(app) + view.init_app(app) + app.jinja_env.filters['datetime'] = display_datetime + return app diff --git a/sourcing/database.py b/sourcing/database.py new file mode 100644 index 0000000..fe92862 --- /dev/null +++ b/sourcing/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import scoped_session, sessionmaker + +session = scoped_session(sessionmaker()) + +def init_db(db_url): + session.configure(bind=get_engine(db_url)) + +def get_engine(db_url): + return create_engine(db_url, pool_recycle=3600) + +def init_app(app): + db_url = app.config['DB_URL'] + session.configure(bind=get_engine(db_url)) + + @app.teardown_appcontext + def shutdown_session(exception=None): + session.remove() diff --git a/sourcing/forms.py b/sourcing/forms.py new file mode 100644 index 0000000..475df64 --- /dev/null +++ b/sourcing/forms.py @@ -0,0 +1,75 @@ +from flask_wtf import Form +from wtforms.fields import StringField, PasswordField, BooleanField, HiddenField, TextAreaField, RadioField, FileField, IntegerField +from wtforms.validators import InputRequired, Email, Length, ValidationError, Regexp, NoneOf, Optional +from .model import User, LoginError, re_username, reserved_name, user_exists + +PASSWORD_LEN = 64 +EMAIL_LEN = 64 + +class SignupForm(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 = PasswordField('password', + [InputRequired(), Length(min=4, max=PASSWORD_LEN)]) + + def validate_username(form, field): + if user_exists(User.username, field.data): + raise ValidationError('Not available') + + def validate_email(form, field): + if user_exists(User.email, field.data): + raise ValidationError('In use by another account') + +class LoginForm(Form): + 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): + rv = Form.validate(self) + if not rv: + return False + + try: + 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(Form): + username_or_email = StringField('username or e-mail address', + [InputRequired(), Length(max=EMAIL_LEN)]) + +class AccountSettingsForm(Form): + full_name = StringField('full name', [Length(max=64)]) + +class ChangePasswordForm(Form): + old_password = PasswordField('current password', + [InputRequired(), Length(max=PASSWORD_LEN)]) + new_password = PasswordField('new password', + [InputRequired(), Length(max=PASSWORD_LEN)]) + +class SourceDocForm(Form): + text = TextAreaField('text', [InputRequired()]) + db_price_per_character = IntegerField('price per character', [Optional()]) + db_document_price = IntegerField('document price', [Optional()]) + +class ItemForm(Form): + text = TextAreaField('text', [InputRequired()]) + +class UploadSourceDocForm(Form): + sourcedoc_file = FileField('SourceDoc', [Regexp(r'^[^/\\]+\.txt$')]) diff --git a/sourcing/model.py b/sourcing/model.py new file mode 100644 index 0000000..d64d547 --- /dev/null +++ b/sourcing/model.py @@ -0,0 +1,234 @@ +from flask import url_for +from .database import session +from .parse import parse_link, parse_sourcedoc_facet, parse_span +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy import Column, ForeignKey +from sqlalchemy.types import String, Unicode, Integer, DateTime, Boolean, UnicodeText, Enum +from sqlalchemy import func +from sqlalchemy.orm import relationship, validates, synonym +from sqlalchemy.sql import exists +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from sqlalchemy.ext.hybrid import hybrid_property +import re + +from hashids import Hashids + +doc_hashids = Hashids(min_length=8) + +Base = declarative_base() +Base.query = session.query_property() + +# list of disallowed usernames - maybe this should be in the database +reserved_name = ['root', 'admin', 'administrator', 'support', 'info', + 'test', 'tech', 'online', 'old', 'new', 'jobs', 'login', 'job', 'ipad' + 'iphone', 'javascript', 'script', 'host', 'mail', 'image', 'faq', + 'file', 'ftp', 'error', 'warning', 'the', 'assistance', 'maintenance', + 'controller', 'head', 'chief', 'anon'] + +re_username = re.compile('^\w+$', re.U) +re_full_name = re.compile('^([-.\'" ]|[^\W\d_])+$', re.U) + +def user_exists(field, value): + return session.query(exists().where(field == value)).scalar() + +class TimeStampedModel(Base): + __abstract__ = True + created = Column(DateTime, default=func.now()) + modified = Column(DateTime, default=func.now(), onupdate=func.now()) + +class LoginError(Exception): + def __init__(self, msg): + self.msg = msg + +class User(TimeStampedModel, UserMixin): + __tablename__ = 'user' + id = Column(Integer, primary_key=True) + username = Column(Unicode(32), unique=True, nullable=False) + pw_hash = Column(String(160), nullable=False) + email = Column(Unicode(64), unique=True, nullable=False) + email_verified = Column(Boolean(), nullable=False, default=False) + disabled = Column(Boolean(), nullable=False, default=False) + deleted = Column(Boolean(), nullable=False, default=False) + is_super = Column(Boolean, nullable=False, default=False) + last_login = Column(DateTime) + full_name = Column(Unicode(64)) + balance = Column(Integer, nullable=False, default=0) + + user_id = synonym('id') + name = synonym('full_name') + user_name = synonym('username') + + def __init__(self, **kwargs): + pw_hash = generate_password_hash(kwargs.pop('password')) + return super(User, self).__init__(pw_hash=pw_hash, **kwargs) + + def __repr__(self): + return '<User: {!r}>'.format(self.username) + + def set_password(self, password): + self.pw_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.pw_hash, password) + + def get_id(self): + return self.id + + @validates('email') + def validate_email(self, key, value): + assert '@' in value + return value + + @validates('username') + def validate_usernane(self, key, value): + assert re_username.match(value) + return value + + @validates('full_name') + def validate_full_name(self, key, value): + if value: + assert re_full_name.match(value) + return value + + @hybrid_property + def is_live(self): + return self.email_verified & ~self.disabled & ~self.deleted + + @classmethod + def lookup_user_or_email(cls, 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() + + @property + def mail_to_name(self): + '''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): + user = cls.lookup_user_or_email(user_or_email) + if not user: + raise LoginError('user not found') + if user.disabled: + raise LoginError('user account disabled') + if not user.check_password(password): + raise LoginError('incorrect password') + return user + +class Item(TimeStampedModel): + __tablename__ = 'item' + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('user.id')) + published = Column(DateTime) + type = Column(Enum('sourcedoc', 'xanadoc', 'xanalink', name='item_type'), nullable=False) + filename = Column(Unicode) + text = Column(UnicodeText) + + user = relationship('User', backref='items') + + __mapper_args__ = { + 'polymorphic_identity': 'item', + 'polymorphic_on': type, + } + + @property + def hashid(self): + return doc_hashids.encode(self.id) + + @classmethod + def get_by_hashid(cls, hashid): + return cls.query.get(doc_hashids.decode(hashid)) + + @property + def url(self): + return url_for('.view_item', username=self.user.username, hashid=self.hashid) + + @property + def external_url(self): + return url_for('.view_item', + username=self.user.username, + hashid=self.hashid, + _external=True) + + @property + def edit_url(self): + return url_for('.edit_item', username=self.user.username, hashid=self.hashid) + + def title(self, titles=None): + if not titles: + titles = XanaLink.get_all_titles() + return self.type + ": " + titles.get(self, self.hashid) + + @classmethod + def from_external(cls, url): + home = url_for('.home', _external=True) + if not url.startswith(home): + return + username, _, hashid = url[len(home):].partition('/') + q = cls.query.filter(User.username == username, + cls.id == doc_hashids.decode(hashid)[0]) + return q.one_or_none() + +class XanaDoc(Item): + __tablename__ = 'xanadoc' + id = Column(Integer, ForeignKey('item.id'), primary_key=True) + + __mapper_args__ = {'polymorphic_identity': 'xanadoc'} + +class XanaLink(Item): + __tablename__ = 'xanalink' + id = Column(Integer, ForeignKey('item.id'), primary_key=True) + + __mapper_args__ = {'polymorphic_identity': 'xanalink'} + + def parse(self): + return parse_link(self.text) + + def title(self, titles=None): + if titles is None: + titles = XanaLink.get_all_titles() + if self in titles: + return self.type + ": " + titles[self] + + parsed = self.parse() + + if parsed['type'] == 'title': + ident = parsed['facets'][0][0].partition(': ')[2] + item = Item.from_external(ident) + if item in titles: + return parsed['type'] + " link for " + item.title(titles=titles) + + return parsed['type'] + " link: " + self.hashid + + @classmethod + def get_all_titles(cls): + titles = {} + for link in (obj.parse() for obj in cls.query): + if link['type'] != 'title': + continue + facet1, facet2 = link['facets'] + link_type, _, ident = facet1[0].partition(': ') + item = Item.from_external(ident) + print(ident, item) + + ident2, start, length = parse_span(facet2[0]) + title = SourceDoc.from_external(ident2).text[start:length + start] + titles[item] = title + return titles + +class SourceDoc(Item): + __tablename__ = 'sourcedoc' + id = Column(Integer, ForeignKey('item.id'), primary_key=True) + db_price_per_character = Column(Integer) + db_document_price = Column(Integer) + + @property + def document_price(self): + return self.db_document_price or self.db_price_per_character * len(self.text) + + @property + def price_per_character(self): + return self.db_price_per_character or self.db_document_price / len(self.text) + + __mapper_args__ = {'polymorphic_identity': 'sourcedoc'} diff --git a/sourcing/parse.py b/sourcing/parse.py new file mode 100644 index 0000000..7cda3c3 --- /dev/null +++ b/sourcing/parse.py @@ -0,0 +1,273 @@ +import re +import requests +import os.path +import random +from html import escape +from collections import defaultdict + +re_comment = re.compile(r'#.*') +re_span_pointer = re.compile(r'span: (.*),start=(\d+),length=(\d+)') +re_xanalink = re.compile('xanalink: +([^ ]+) *$') +re_facet = re.compile('^facet\d* *=\s*(.*)\s*$') + +re_colon_slash = re.compile('[/:]+') + +project_dir = os.path.dirname(os.path.dirname(__file__)) +cache_location = os.path.join(project_dir, 'cache') + +max_sourcedoc_size = 600000 + +xnb_per_char = 150000 + +censor_urls = {'http://hyperland.com/xuCambDemo/J.Ineffable.txt', + 'http://royalty.pub/Ineffable/17/32/Trovato'} + +def censor_text(text): + return ''.join(chr(random.randint(9728, 9983)) if c.isalnum() else c + for c in text) + +def get_url(url): + filename = os.path.join(cache_location, url_filename(url)) + + if os.path.exists(filename): + content = open(filename, 'rb').read() + else: + content = requests.get(url).content + open(filename, 'wb').write(content) + + return content.decode(errors='replace') + +def get_text(url): + # assume UTF-8 + + text = get_url(url) + if url in censor_urls: + text = censor_text(text) + + heading = url.rsplit('/', 1)[-1] + return { + 'url': url, + 'text': text, + 'heading': heading, + 'length': len(text), + } + +def parse_span(line): + m = re_span_pointer.match(line) + if not m: + return None + return (m.group(1), int(m.group(2)), int(m.group(3))) + +def parse_edl(edl_text): + edl = { + 'spans': [], + 'links': [], + } + for line in edl_text.splitlines(): + line = re_comment.sub('', line).strip() + if not line: + continue + span_pointer = parse_span(line) + if span_pointer: + edl['spans'].append(span_pointer) + continue + m = re_xanalink.match(line) + if m: + link_url = m.group(1) + edl['links'].append({ + 'url': link_url, + 'text': get_url(link_url), + }) + continue + + return edl + +def get_span(text, url, start, length): + return { + 'url': url, + 'start': start, + 'length': length, + 'text': text[url][start:start + length] + } + +def fulfil_edl(edl): + text = {} + for url, start, length in parse_edl(edl): + if url not in text: + text[url] = get_text(url) + + yield get_span(text, url, start, length) + +def get_urls(spans): + return {i[0] for i in spans} + +def url_filename(url): + return re_colon_slash.sub('_', url) + +def find_min_max(spans, source): + text_min, text_max = {}, {} + for url, start, length in spans: + if url in text_min: + text_min[url] = min(text_min[url], start) + else: + text_min[url] = start + + if url in text_max: + text_max[url] = max(text_max[url], start + length) + else: + text_max[url] = start + length + + for s in source: + url = s['url'] + s['min'] = text_min[url] + s['max'] = text_max[url] + +def span_html(span_type, num): + return '<span class="{span_type} sourcedoc{span_type}" id="{span_type}{num}">'.format(num=num, span_type=span_type) + +def xanadoc_span_html(num, text, url, start, length, highlight=True, censor=False): + cls = [] + if highlight: + cls = ['xanadoctransclusion', 'transclusion'] + html_class = ' class="{}"'.format(' '.join(cls)) if cls else '' + + html = '<span id="span{}"{} data-url="{}" data-start="{}" data-length="{}">{}</span>'.format(num, html_class, escape(url), start, length, text) + + if censor: + return '<span class="censor">' + html + '</span>' + else: + return html + +def parse_sourcedoc_facet(facet): + leg = facet[0] + prefix = 'sourcedoc: ' + assert leg.startswith(prefix) + return leg[len(prefix):] + +def parse_link(link_text): + link_type = None + expect = 'link_type' + facets = [] + for line in link_text.splitlines(): + line = re_comment.sub('', line).strip() + if not line: + continue + if expect == 'link_type': + if line.startswith('type='): + link_type = line[5:] + expect = 'facets' + continue + if expect != 'facets': + # print("unrecognized:", line) + continue + + m = re_facet.match(line) + if m: + legs = [] + facets.append(legs) + if m.group(1): + line = m.group(1) + else: + continue + if legs and legs[-1] == 'span:' and line.startswith('http'): + legs[-1] += ' ' + line + else: + legs.append(line.strip()) + return {'type': link_type, 'facets': facets} + +def fulfil_edl_with_sources(edl_text): + edl = parse_edl(edl_text) + spans = edl['spans'] + + hide_transclusions = set() + + two_facet_links = [] + + link_num = 0 + for link in edl['links']: + link_detail = parse_link(link['text']) + if link_detail['type'] == 'HideTransclusions': + hide_transclusions.add(parse_sourcedoc_facet(link_detail['facets'][0])) + elif len(link_detail['facets']) == 2: + two_facet_links.append((link_num, [parse_span(span[0]) for span in link_detail['facets']])) + link_num += 1 + + source = [get_text(url) for url in get_urls(spans)] + + source_text = {s['url']: s['text'] for s in source} + + source_doc_links = defaultdict(list) + + for link_num, facets in two_facet_links: + for span in facets: + url, start, length = span + source_doc_links[url].append((start, length, link_num, 'link')) + if url in source_text: + continue + + s = get_text(url) + source.append(s) + source_text[s['url']] = s['text'] + + for s in source_doc_links.values(): + s.sort() + + spans = list(enumerate(spans)) + + doc_spans = [] + for num, (url, start, length) in spans: + highlight = url not in hide_transclusions + span_text = source_text[url] # [start:start + length] + censor = url in censor_urls + new_text = '' + pos = start + for link_start, link_len, link_num, span_type in source_doc_links[url]: + link_end = link_start + link_len + if link_start >= start + length: + break + if link_end < start: + continue + open_tag = '<span class="xanadoclink link" id="xanalink{}">'.format(link_num) + link_span = (open_tag + + escape(span_text[link_start:link_end]) + + '</span>') + new_text += escape(span_text[pos:link_start]) + link_span + pos = link_end + new_text += escape(span_text[pos:start + length]) + cur = xanadoc_span_html(num, new_text, url, start, length, highlight=highlight, censor=censor) + doc_spans.append(cur) + + doc = ''.join(doc_spans) + + for s in source: + text = s.pop('text') + if s['length'] > max_sourcedoc_size: + # print('{} > {}'.format(s['length'], max_sourcedoc_size)) + continue + if s['url'] in hide_transclusions: + continue + source_spans = [(start, length, num, 'transclusion') for num, (url, start, length) in spans if url == s['url']] + source_spans += source_doc_links[s['url']] + source_spans.sort() + + new_text = '' + pos = 0 + + for start, length, num, span_type in source_spans: + end = start + length + new_text += (escape(text[pos:start]) + + span_html(span_type, num) + + escape(text[start:end]) + + '</span>') + pos = end + new_text += escape(text[pos:]) + new_text = new_text.replace('\n', '<br/>\n') + + s['text'] = new_text + + return { + 'source': source, + 'doc': doc.replace('\n', '<br/>\n'), + 'span_count': len(spans), + 'link_count': len(two_facet_links), + } diff --git a/sourcing/static/bootstrap b/sourcing/static/bootstrap new file mode 120000 index 0000000..fe0f86b --- /dev/null +++ b/sourcing/static/bootstrap @@ -0,0 +1 @@ +/usr/share/javascript/bootstrap \ No newline at end of file diff --git a/sourcing/static/handlebars b/sourcing/static/handlebars new file mode 120000 index 0000000..2a678c5 --- /dev/null +++ b/sourcing/static/handlebars @@ -0,0 +1 @@ +/usr/share/javascript/handlebars \ No newline at end of file diff --git a/sourcing/static/jquery b/sourcing/static/jquery new file mode 120000 index 0000000..7e2f826 --- /dev/null +++ b/sourcing/static/jquery @@ -0,0 +1 @@ +/usr/share/javascript/jquery \ No newline at end of file diff --git a/sourcing/static/jquery-ui b/sourcing/static/jquery-ui new file mode 120000 index 0000000..d9f803c --- /dev/null +++ b/sourcing/static/jquery-ui @@ -0,0 +1 @@ +/usr/share/javascript/jquery-ui \ No newline at end of file diff --git a/sourcing/static/js/doc.js b/sourcing/static/js/doc.js new file mode 100644 index 0000000..c0a90a2 --- /dev/null +++ b/sourcing/static/js/doc.js @@ -0,0 +1,15 @@ +$(function() { + $("div#right").hide(); + /* $("button#add-new-span").click(function() { + $("div#right").show(); + }); */ + $("a.source-doc-link").click(function(e) { + var link = $(e.target); + var title = link.text(); + console.log("source doc link " + title); + $("div#source-doc-heading").text("Add span from " + title); + $("#newSpanModal").modal('hide'); + $("div#right").show(); + $("div#source-doc-body").load(link.data("url")); + }); +}); diff --git a/sourcing/static/js/sourcedoc.js b/sourcing/static/js/sourcedoc.js new file mode 100644 index 0000000..265cc31 --- /dev/null +++ b/sourcing/static/js/sourcedoc.js @@ -0,0 +1,49 @@ +function add_message() { + $("div#messages").text("whatever"); +} + +function show_selection(event) { + var range = window.getSelection().getRangeAt(0); + var start_element = range.startContainer.parentElement; + var end_element = range.endContainer.parentElement; + + if ($(start_element).closest("div#right").prop("tagName") == 'DIV') { + return; + } + + if (!start_element.hasAttribute('data-start') || + !end_element.hasAttribute('data-start')) { + $("div#right").hide(); + return; + } + + var start = parseInt(start_element.getAttribute('data-start')) + range.startOffset; + var end = parseInt(end_element.getAttribute('data-start')) + range.endOffset; + var length = end - start; + if(length === 0) { + $("div#right").hide(); + return; + } + + $("#span").text(doc_url + ",start=" + start + ",length=" + length); + + /* + $("div#right").show(); + $("span#length").text(length); + $("input[name='offset']").val(start); + $("input[name='length']").val(length); + */ +} + + +$(function() { + $("button#go").click(add_message); + $("div#right").hide(); + + $("body").mouseup(show_selection); + + $("#show-span-selector").click(function(e) { + $("#span-selector").removeClass("hidden"); + $(this).hide(); + }); +}); diff --git a/sourcing/templates/edit.html b/sourcing/templates/edit.html new file mode 100644 index 0000000..9268075 --- /dev/null +++ b/sourcing/templates/edit.html @@ -0,0 +1,17 @@ +{% from "form/controls.html" import render_field %} + +{% set title="edit " + doc.title %} + +{% set action=doc.edit_url %} +{% set label="save" %} + +{% set fields %} +{# +{{ render_field(form.filename) }} +{{ render_field(form.db_price_per_character) }} +{{ render_field(form.db_document_price) }} +#} +{{ render_field(form.text, rows=20) }} +{% endset %} + +{% include "form/simple.html" %} diff --git a/sourcing/templates/flash_msg.html b/sourcing/templates/flash_msg.html new file mode 100644 index 0000000..a390a28 --- /dev/null +++ b/sourcing/templates/flash_msg.html @@ -0,0 +1,10 @@ +{% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} +<div class="alert alert-success alert-dismissible" role="alert"> + <button type="button" class="close" data-dismiss="alert"><span aria-hidden="true">×</span><span class="sr-only">Close</span></button> + {{ message }} +</div> + {% endfor %} + {% endif %} +{% endwith %} diff --git a/sourcing/templates/foot.html b/sourcing/templates/foot.html new file mode 100644 index 0000000..6d6036f --- /dev/null +++ b/sourcing/templates/foot.html @@ -0,0 +1,11 @@ +<hr/> +<footer>© 2017 Project Xanadu</footer> +</div> + +<script src="{{ url_for('static', filename='jquery/jquery.js') }}"></script> +<script src="{{ url_for('static', filename='bootstrap/js/bootstrap.js') }}"></script> + +{{ scripts | safe }} + +</body> +</html> diff --git a/sourcing/templates/form/controls.html b/sourcing/templates/form/controls.html new file mode 100644 index 0000000..1c00ff0 --- /dev/null +++ b/sourcing/templates/form/controls.html @@ -0,0 +1,33 @@ +{% macro render_field(field) %} +<div class="form-group"> + {{ field.label() }} + {{ field(class="form-control", **kwargs) }} + {% if field.description %} + <span class="help">{{ field.description|safe }}</span> + {% endif %} +</div> +{% endmacro %} + +{% macro checkbox(field) %} +<div class="form-group"> + <label class="checkbox-inline"> + {{ field() }} + {{ field.label.text }} + </label> + {% if field.description %} + <span class="help">{{ field.description|safe }}</span> + {% endif %} +</div> +{% endmacro %} + +{% macro radio(field) %} +<div class="form-group"> + <label class="radio-inline"> + {{ field() }} + {{ field.label.text }} + </label> + {% if field.description %} + <span class="help">{{ field.description|safe }}</span> + {% endif %} +</div> +{% endmacro %} diff --git a/sourcing/templates/form/errors.html b/sourcing/templates/form/errors.html new file mode 100644 index 0000000..e7ae1c7 --- /dev/null +++ b/sourcing/templates/form/errors.html @@ -0,0 +1,9 @@ +{% if form.errors %} +<ul class="errors"> + {% for field in form %} + {% for error in form.errors[field.name] %} + <li>{{ field.label }}: {{ error }}</li> + {% endfor %} + {% endfor %} +</ul> +{% endif %} diff --git a/sourcing/templates/form/main.html b/sourcing/templates/form/main.html new file mode 100644 index 0000000..a57b6fa --- /dev/null +++ b/sourcing/templates/form/main.html @@ -0,0 +1,11 @@ +<div class="well"> + {% include "form/errors.html" %} + <form action="{{action}}" method="{{method|default('post')}}" role="form"> + {{ form.hidden_tag() }} + {{ fields | safe }} + <div class="form-group"> + <button type="submit" class="btn btn-default">{{ label }}</button> + </div> + </form> +</div> + diff --git a/sourcing/templates/form/simple.html b/sourcing/templates/form/simple.html new file mode 100644 index 0000000..97a9a68 --- /dev/null +++ b/sourcing/templates/form/simple.html @@ -0,0 +1,10 @@ +{% include "head.html" %} + +<div class="row"> + <div class="col-md-12"> + <h1>{{ title }}</h1> + {% include "form/main.html" %} + </div> +</div> + +{% include "foot.html" %} diff --git a/sourcing/templates/head.html b/sourcing/templates/head.html new file mode 100644 index 0000000..3ff6f28 --- /dev/null +++ b/sourcing/templates/head.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta http-equiv="X-UA-Compatible" content="IE=edge"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>{{ title | default("Xanadu") }}</title> + +<link rel="stylesheet" href="{{ url_for('static', filename='bootstrap/css/bootstrap.css') }}"> +</head> + +<body> +{% include "navbar.html" %} +{{ dialogs | safe }} +<div class="container"> +{% include "flash_msg.html" %} diff --git a/sourcing/templates/home.html b/sourcing/templates/home.html new file mode 100644 index 0000000..84f6952 --- /dev/null +++ b/sourcing/templates/home.html @@ -0,0 +1,23 @@ +{% include "head.html" %} +<h1>perma.pub</h1> + +<ul> +{% for doc in docs %} +<li> +<a href="{{ doc.url }}">{{ doc.title() }}</a> +by {{ doc.user.username }} +— created {{ doc.created | datetime }} +</li> +{% endfor %} +</ul> + +<p> +<a href="{{ url_for('.new_sourcedoc') }}" class="btn btn-default">new source document</a> +<a href="{{ url_for('.new_xanalink') }}" class="btn btn-default">new xanalink</a> +<a href="{{ url_for('.new_xanadoc') }}" class="btn btn-default">new xanadoc</a> +{# +<a href="#" class="btn btn-default">upload a source document</a> +#} +</p> + +{% include "foot.html" %} diff --git a/sourcing/templates/login.html b/sourcing/templates/login.html new file mode 100644 index 0000000..3b97ea7 --- /dev/null +++ b/sourcing/templates/login.html @@ -0,0 +1,13 @@ +{% from "form/controls.html" import render_field, checkbox, submit %} + +{% set title="Login" %} +{% set action=url_for('.login') %} +{% set label="Sign in" %} + +{% set fields %} +{{ render_field(form.user_or_email) }} +{{ render_field(form.password) }} +{{ checkbox(form.remember) }} +{% endset %} + +{% include "form/simple.html" %} diff --git a/sourcing/templates/mail/signup.txt b/sourcing/templates/mail/signup.txt new file mode 100644 index 0000000..ee3f593 --- /dev/null +++ b/sourcing/templates/mail/signup.txt @@ -0,0 +1 @@ +You signed up for xanadu diff --git a/sourcing/templates/navbar.html b/sourcing/templates/navbar.html new file mode 100644 index 0000000..24bf377 --- /dev/null +++ b/sourcing/templates/navbar.html @@ -0,0 +1,34 @@ +<nav class="navbar navbar-default"> + <div class="container-fluid"> + <div class="navbar-header"> + <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar"> + <span class="sr-only">Toggle navigation</span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + <span class="icon-bar"></span> + </button> + <a class="navbar-brand" href="{{ url_for('.home') }}">{{ config.SITE_NAME }}</a> + </div> + + <div id="navbar" class="navbar-collapse collapse"> + <ul class="nav navbar-nav"> + {% for page in ('home', 'about', 'contact') %} + <li{% if current_page == page %} class="active"{% endif %}><a href="{{ url_for('.' + page) }}">{{ page | title }}</a></li> + {% endfor %} + </ul> + <ul class="nav navbar-nav navbar-right"> + {% if current_user.is_authenticated %} + {% if current_user.admin %} + <li><a href="{{ url_for('.admin') }}">admin</a></li> + {% endif %} + <li><a href="{{ url_for('.account_settings') }}">{{ current_user.username }}</a></li> + <li><a href="{{ url_for('.logout') }}">logout</a></li> + {% else %} + <li><a href="{{ url_for('.signup') }}">signup</a></li> + <li><a href="{{ url_for('.login') }}">login</a></li> + {% endif %} + </ul> + </div> + </div> +</nav> + diff --git a/sourcing/templates/new.html b/sourcing/templates/new.html new file mode 100644 index 0000000..8d8e943 --- /dev/null +++ b/sourcing/templates/new.html @@ -0,0 +1,15 @@ +{% from "form/controls.html" import render_field %} + +{% set action=url_for(request.endpoint) %} +{% set label="save" %} + +{% set fields %} +{# +{{ render_field(form.filename) }} +{{ render_field(form.db_price_per_character) }} +{{ render_field(form.db_document_price) }} +#} +{{ render_field(form.text, rows=20) }} +{% endset %} + +{% include "form/simple.html" %} diff --git a/sourcing/templates/signup.html b/sourcing/templates/signup.html new file mode 100644 index 0000000..4d9448f --- /dev/null +++ b/sourcing/templates/signup.html @@ -0,0 +1,13 @@ +{% from "form/controls.html" import render_field %} + +{% set title="Sign up" %} +{% set action=url_for('.signup') %} +{% set label="Sign up" %} + +{% set fields %} +{{ render_field(form.username) }} +{{ render_field(form.password) }} +{{ render_field(form.email) }} +{% endset %} + +{% include "form/simple.html" %} diff --git a/sourcing/templates/source_doc.html b/sourcing/templates/source_doc.html new file mode 100644 index 0000000..ebfe45a --- /dev/null +++ b/sourcing/templates/source_doc.html @@ -0,0 +1,33 @@ +{% set title = doc.filename %} +{% set price = doc.document_price %} +{% include "head.html" %} + +<style> +div#text { font-family: Courier; } +</style> + +<div class="row"> + <div class="col-xs-12"> + <h1>{{ title }} + {% if doc.user == current_user %} + <a href="{{ url_for('.edit_source_document', + filename=doc.filename) }}" class="btn btn-default">edit</a> + {% endif %} + </h1> + <p><a href="{{ url_for('.home') }}">back to index</a></p> + <ul> + <li>character count: {{ '{:,d}'.format(doc.text | length) }}</li> + {% if price %} + <li>document price: {{ '{:,.2f}'.format(price) }} nanobucks</li> + <li>price per character: {{ '{:,.2f}'.format(doc.price_per_character) }} nanobucks</li> + {% endif %} + </ul> + <div class="well" id="text"> + {%- for start, line in iter_lines(doc.get_text()) if line -%} + <p data-start="{{ start }}">{{ line }}</p> + {%- endfor -%} + </div> + </div> +</div> + +{% include "foot.html" %} diff --git a/sourcing/templates/user/account.html b/sourcing/templates/user/account.html new file mode 100644 index 0000000..ad165ac --- /dev/null +++ b/sourcing/templates/user/account.html @@ -0,0 +1,12 @@ +{% from "form/controls.html" import render_field %} + +{% set title="account settings" %} + +{% set action=url_for(".account_settings") %} +{% set label="save" %} + +{% set fields %} +{{ render_field(form.full_name) }} +{% endset %} + +{% include "form/simple.html" %} diff --git a/sourcing/templates/view.html b/sourcing/templates/view.html new file mode 100644 index 0000000..628bc2d --- /dev/null +++ b/sourcing/templates/view.html @@ -0,0 +1,34 @@ +{% set title = doc.title() %} +{% include "head.html" %} + +<style> +div#text { font-family: Courier; } +</style> + +<div class="row"> + <div class="col-xs-12"> + <h1>{{ title }} + {% if doc.user == current_user %} + <a href="{{ doc.edit_url }}" class="btn btn-default">edit</a> + {% endif %} + </h1> + <p><a href="{{ url_for('.home') }}">back to index</a></p> + <div class="well" id="text"> + {%- for start, line in iter_lines(doc.text) if line -%} + <p data-start="{{ start }}">{{ line }}</p> + {%- endfor -%} + </div> + {% if doc.type == 'sourcedoc' %} + <a href="#" id="show-span-selector" class="btn btn-default">show span selector</a> + <p id="span-selector" class="hidden">span: <span id="span"></span></p> + {% set scripts %} + <script> + var doc_url = '{{ doc.external_url }}'; + </script> + <script src="{{ url_for('static', filename='js/sourcedoc.js') }}"></script> + {% endset %} + {% endif %} + </div> +</div> + +{% include "foot.html" %} diff --git a/sourcing/text.py b/sourcing/text.py new file mode 100644 index 0000000..cbfea91 --- /dev/null +++ b/sourcing/text.py @@ -0,0 +1,21 @@ +import re +import random + +re_newline = re.compile('\r?\n') + +def find_newlines(text): + return (m.end(0) for m in re_newline.finditer(text)) + +def iter_lines(text): + start = 0 + for m in re_newline.finditer(text): + end = m.end(0) + yield (start, text[start:end]) + start = m.end(0) + 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) diff --git a/sourcing/utils.py b/sourcing/utils.py new file mode 100644 index 0000000..701e875 --- /dev/null +++ b/sourcing/utils.py @@ -0,0 +1,13 @@ +import humanize +from datetime import date, timedelta + +def display_datetime(dt): + if dt is None: + return 'n/a' + today = date.today() + if today - dt.date() < timedelta(days=1): + return humanize.naturaltime(dt) + else: + return dt.strftime('%a, %d %b %Y') + + diff --git a/sourcing/view.py b/sourcing/view.py new file mode 100644 index 0000000..4029456 --- /dev/null +++ b/sourcing/view.py @@ -0,0 +1,232 @@ +from flask import (Blueprint, render_template, request, redirect, flash, + url_for, abort, jsonify) +from flask_login import (login_user, current_user, logout_user, + login_required, LoginManager) +from .forms import (LoginForm, SignupForm, AccountSettingsForm, + UploadSourceDocForm, SourceDocForm, ItemForm) +from .model import User, SourceDoc, Item, XanaDoc, XanaLink +from .database import session +from .text import iter_lines +from werkzeug.debug.tbtools import get_current_traceback +from jinja2 import evalcontextfilter, Markup +from functools import wraps + +import re + +login_manager = LoginManager() +login_manager.login_view = 'login' +re_paragraph = re.compile(r'(?:\r\n|\r|\n){2,}') +bp = Blueprint('view', __name__) + +def init_app(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(): + titles = XanaLink.get_all_titles() + docs = Item.query.order_by(Item.created) + return render_template('home.html', docs=docs) + +@bp.route('/source_doc_upload', methods=["POST"]) +@show_errors +def source_doc_upload(): + f = request.files['sourcedoc_file'] + text = f.read() + doc = SourceDoc(text=text, user=current_user, filename=f.filename) + session.add(doc) + session.commit() + flash('new source document uploaded') + return redirect(doc.url) + +@bp.route('/about') +def about(): + return render_template('about.html') + +@bp.route('/contact') +def contact(): + return render_template('contact.html') + +def redirect_to_home(): + return redirect(url_for('.home')) + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + form = LoginForm(next=request.args.get('next')) + if form.validate_on_submit(): + login_user(form.user, remember=form.remember.data) + flash('Logged in successfully.') + return redirect(request.form.get('next') or url_for('.home')) + return render_template('login.html', form=form) + +@bp.route('/logout') +def logout(): + logout_user() + flash('You have been logged out.') + return redirect_to_home() + +@bp.route('/signup', methods=['GET', 'POST']) +def signup(): + form = SignupForm() + if not form.validate_on_submit(): + return render_template('signup.html', form=form) + + user = User(**form.data) + session.add(user) + session.commit() + flash('New account created.') + login_user(user) + return redirect(url_for(view_after_signup)) + +def redirect_to_doc(doc): + return redirect(url_for('.view_document', hashid=doc.hashid)) + +def get_source_doc(username, hashid): + doc = Item.get_by_hashid(hashid) + if doc and doc.user.username != username: + doc = None + return doc if doc else abort(404) + +def get_item(username, hashid): + doc = Item.get_by_hashid(hashid) + if doc and doc.user.username != username: + doc = None + return doc if doc else abort(404) + +@bp.route('/<username>/<hashid>') +def view_item(username, hashid): + return render_template('view.html', + doc=get_item(username, hashid), + iter_lines=iter_lines) + +@bp.route('/<username>/<hashid>/edit', methods=['GET', 'POST']) +def edit_item(username, hashid): + obj = get_item(current_user.username, hashid) + form = SourceDocForm(obj=obj) + if form.validate_on_submit(): + form.populate_obj(obj) + session.add(obj) + session.commit() + flash('Changes to {} saved.'.format(obj.type)) + return redirect(obj.url) + return 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 render_template('source_doc_text.html', doc=doc, iter_lines=iter_lines) + +@bp.route('/settings/account', methods=['GET', 'POST']) +@login_required +def account_settings(): + form = AccountSettingsForm(obj=current_user) + if form.validate_on_submit(): + form.populate_obj(current_user) + session.add(current_user) + session.commit() + flash('Account details updated.') + return redirect(url_for(request.endpoint)) + return render_template('user/account.html', form=form) + +@bp.route('/new/sourcedoc', methods=['GET', 'POST']) +@login_required +def new_sourcedoc(): + form = SourceDocForm() + if form.validate_on_submit(): + doc = SourceDoc(user=current_user) + form.populate_obj(doc) + session.add(doc) + session.commit() + flash('New document saved.') + return redirect(doc.url) + return render_template('new.html', form=form, title='source document') + +@bp.route('/new/xanalink', methods=['GET', 'POST']) +@login_required +def new_xanalink(): + form = ItemForm() + if form.validate_on_submit(): + obj = XanaLink(user=current_user) + form.populate_obj(obj) + session.add(obj) + session.commit() + flash('New xanalink saved.') + return redirect(obj.url) + return render_template('new.html', form=form, title='xanalink') + +@bp.route('/new/xanadoc', methods=['GET', 'POST']) +@login_required +def new_xanadoc(): + form = ItemForm() + if form.validate_on_submit(): + obj = XanaLink(user=current_user) + form.populate_obj(obj) + session.add(obj) + session.commit() + flash('New xanalink saved.') + return redirect(obj.url) + return render_template('new.html', form=form, title='xanadoc') + +@bp.route('/edit/<filename>', methods=['GET', 'POST']) +@login_required +def edit_source_document(filename): + 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() + flash('Changes to document saved.') + return redirect(doc.url) + return render_template('edit.html', form=form, doc=doc) + +@bp.route('/api/1/get/<username>/<filename>') +def api_get_document(username, filename): + doc = get_source_doc(username, filename) + if not doc: + return abort(404) + 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 jsonify(ret) + +@bp.route('/all_titles') +def get_all_titles(): + titles = XanaLink.get_all_titles() + for k, v in titles.items(): + print(from_external(k), v) + return '' diff --git a/titles.py b/titles.py new file mode 100755 index 0000000..311843a --- /dev/null +++ b/titles.py @@ -0,0 +1,10 @@ +#!/usr/bin/python3 + +from sourcing import create_app +from sourcing.model import XanaLink, from_external + +if __name__ == "__main__": + app = create_app('config.default') + + with app.test_client() as c: + c.get('/all_titles')