diff --git a/app.py b/app.py index 9615406..044d704 100644 --- a/app.py +++ b/app.py @@ -12,7 +12,7 @@ Extensions are supported (?), kept in extensions/ folder. ''' from flask import ( - Flask, abort, flash, g, jsonify, make_response, redirect, request, + Flask, Markup, abort, flash, g, jsonify, make_response, redirect, request, render_template, send_from_directory) from werkzeug.routing import BaseConverter from peewee import * @@ -33,7 +33,7 @@ try: except Exception: markdown_strikethrough = None -__version__ = '0.3.0' +__version__ = '0.4-dev' #### CONSTANTS #### @@ -205,6 +205,20 @@ class PageRevision(BaseModel): return self.textref.get_content() def html(self): return md(self.text) + def human_pub_date(self): + delta = datetime.datetime.now() - self.pub_date + T = partial(get_string, g.lang) + if delta < datetime.timedelta(seconds=60): + return T('just-now') + elif delta < datetime.timedelta(seconds=3600): + return T('n-minutes-ago').format(delta.seconds // 60) + + elif delta < datetime.timedelta(days=1): + return T('n-hours-ago').format(delta.seconds // 3600) + elif delta < datetime.timedelta(days=15): + return T('n-days-ago').format(delta.days) + else: + return self.pub_date.strftime('%B %-d, %Y') class PageTag(BaseModel): page = FK(Page, backref='tags', index=True) @@ -496,13 +510,17 @@ forbidden_urls = [ 'create', 'edit', 'p', 'ajax', 'history', 'manage', 'static', 'media', 'accounts', 'tags', 'init-config', 'upload', 'upload-info', 'about', 'stats', 'terms', 'privacy', 'easter', 'search', 'help', 'circles', - 'protect', + 'protect', 'kt' ] + app = Flask(__name__) app.secret_key = 'qrdldCcvamtdcnidmtasegasdsedrdqvtautar' app.url_map.converters['slug'] = SlugConverter + + + #### ROUTES #### @app.before_request @@ -525,6 +543,12 @@ def _inject_variables(): 'strong': lambda x:Markup('{0}').format(x), } +@app.template_filter() +def linebreaks(text): + text = html.escape(text) + text = text.replace("\n\n", '

').replace('\n', '
') + return Markup(text) + @app.route('/') def homepage(): page_limit = _getconf("appearance","items_per_page",20,cast=int) @@ -848,7 +872,7 @@ def easter_y(y=None): #### EXTENSIONS #### -active_extensions = ['circles'] +active_extensions = ['contactnova'] for ext in active_extensions: try: diff --git a/app_sync.py b/app_sync.py index 25ba22d..1c55434 100644 --- a/app_sync.py +++ b/app_sync.py @@ -68,6 +68,41 @@ def fetch_updated_ids(baseurl): raise RuntimeError("sync unavailable") return r.json()["ids"] +def update_contacts(baseurl): + from extensions.contactnova import Contact + try: + with open(_getconf("config", "database_dir") + "/latest_sync") as f: + last_sync = float(f.read().rstrip("\n")) + except (OSError, ValueError): + last_sync = 946681200.0 # Jan 1, 2000 + r = requests.get(baseurl + "/kt/_jsoninfo/{ts}".format(ts=last_sync)) + if r.status_code >= 400: + raise RuntimeError("sync unavailable") + # update contacts + updated = 0 + for pinfo in r.json()['data']: + p = Contact.get_or_none(Contact.code == pinfo['code']) + if p is None: + p = Contact.create( + code = pinfo['code'], + display_name = pinfo['display_name'], + issues = pinfo['issues'], + status = pinfo['status'], + description = pinfo['description'], + due = datetime.date.fromtimestamp(pinfo['due']) + ) + else: + p.display_name = pinfo['display_name'] + p.issues = pinfo['issues'] + p.status = pinfo['status'] + p.description = pinfo['description'] + p.due = datetime.date.fromtimestamp(pinfo['due']) + p.touched = datetime.datetime.now() + p.save() + updated += 1 + print('\x1b[32m{0} contacts updated :)\x1b[0m'.format(updated)) + + def update_page(p, pageinfo): p.touched = datetime.datetime.fromtimestamp(pageinfo["touched"]) p.url = pageinfo["url"] @@ -123,6 +158,11 @@ def main(): if pageinfo["touched"] > p.touched.timestamp(): update_page(p, pageinfo) passed += 1 + try: + if _getconf("sync", "contacts", None) is not None: + update_contacts(baseurl) + except Exception as e: + print("\x1b[33mContacts not updated - {e} :(\x1b[0m".format(e=e)) with open(DATABASE_DIR + "/last_sync", "w") as fw: fw.write(str(time.time())) if passed > 0 and failed == 0: diff --git a/extensions/circles.py b/extensions/circles.py index 9ddbc05..b3b813e 100644 --- a/extensions/circles.py +++ b/extensions/circles.py @@ -170,8 +170,8 @@ def edit_detail(id): p.type = request.form["type"] p.area = request.form['area'] p.save() - return redirect("/circles") - return render_template("circles/add.html", pl=p) + return redirect(request.form['returnto']) + return render_template("circles/add.html", pl=p, returnto=request.headers.get('Referer', '/circles')) @bp.route('/csv', methods=['GET', 'POST']) def add_csv(): @@ -207,6 +207,16 @@ def statuslist(typ): q = Person.select().where(Person.status == typ).order_by(Person.touched.desc()) return paginate_list(['Orange', 'Yellow', 'Green', ..., 'Red'][typ], q) +@bp.route('/area-') +def arealist(a): + q = Person.select().where(Person.area == a).order_by(Person.status.desc(), Person.touched.desc()) + return paginate_list('Area {}'.format(a), q) + +@bp.route('/no-area') +def noarealist(): + q = Person.select().where(Person.area == 0).order_by(Person.touched.desc()) + return paginate_list('Unassigned area', q) + @bp.route("/stats") def stats(): bq = Person.select() @@ -222,5 +232,10 @@ def stats(): 'Orange': bq.where(Person.status == 0).count(), 'Yellow': bq.where(Person.status == 1).count(), 'Green': bq.where(Person.status == 2).count() - } + }, + area_count={ + k: bq.where(Person.area == k).count() + for k in range(1, 13) + }, + no_area_count=bq.where(Person.area == None).count() ) diff --git a/extensions/contactnova.py b/extensions/contactnova.py new file mode 100644 index 0000000..579a421 --- /dev/null +++ b/extensions/contactnova.py @@ -0,0 +1,214 @@ +# (c) 2021 Sakuragasaki46 +# See LICENSE for copying info. + +''' +Contact Nova extension for Salvi. +''' + +from peewee import * +import datetime +from app import _getconf +from flask import Blueprint, request, redirect, render_template, jsonify, abort +from werkzeug.routing import BaseConverter +import csv +import io +import re +import itertools + +#### HELPERS #### + +class RegExpField(CharField): + def __init__(self, regex, max_length=255, *args, **kw): + super().__init__(max_length, *args, **kw) + self.regex = regex + def db_value(self, value): + value = value.strip() + # XXX %: bug fix for LIKE, but it’s not something regular! + if not '%' in value and not re.fullmatch(self.regex, value): + raise IntegrityError("regexp mismatch: {!r}".format(self.regex)) + return value + +def now7days(): + return datetime.datetime.now() + datetime.timedelta(days=7) + +#### DATABASE SCHEMA #### + +database = SqliteDatabase(_getconf("config", "database_dir") + '/contactnova.sqlite') + +class BaseModel(Model): + class Meta: + database = database + +ST_UNKNOWN = 0 +ST_OK = 1 +ST_ISSUES = 2 + +class Contact(BaseModel): + code = RegExpField(r'[A-Z]\.\d+', 30, unique=True) + display_name = RegExpField('[A-Za-z0-9_. ]+', 50, index=True) + status = IntegerField(default=ST_UNKNOWN, index=True) + issues = CharField(500, default='') + description = CharField(5000, default='') + due = DateField(index=True, default=now7days) + touched = DateTimeField(index=True, default=datetime.datetime.now) + def status_str(self): + return { + 1: "task_alt", + 2: "error_outline", + }.get(self.status, "") + def __repr__(self): + return '<{0.__class__.__name__}: {0.code}>'.format(self) + @classmethod + def next_code(cls, letter): + try: + last_code = Contact.select().where(Contact.code ** (letter + '%')).order_by(Contact.id.desc()).first().code.split('.') + except (Contact.DoesNotExist, AttributeError): + last_code = letter, '0' + code = letter + '.' + str(int(last_code[1]) + 1) + return code + +def init_db(): + database.create_tables([Contact]) + +def create_cli(): + code = Contact.next_code(input('Code letter:')) + + return Contact.create( + code=code, + display_name=input('Display name:'), + due=datetime.datetime.fromisoformat(input('Due date (ISO):')), + description=input('Description (optional):') + ) + +# Helper for command line. +class _CCClass: + def __init__(self, cb=lambda x:x): + self.callback = cb + def __neg__(self): + return create_cli() + def __getattr__(self, value): + code = value[0] + '.' + value[1:] + try: + return self.callback(Contact.get(Contact.code == code)) + except Contact.DoesNotExist: + raise AttributeError(value) from None + def ok(self): + def cb(a): + a.status = 1 + a.save() + return a + return self.__class__(cb) +CC = _CCClass() +del _CCClass + +#### ROUTE HELPERS #### + +class ContactCodeConverter(BaseConverter): + regex = r'[A-Z]\.\d+' + +class ContactMockConverter(BaseConverter): + regex = r'[A-Z]' + +def _register_converters(state): + state.app.url_map.converters['contactcode'] = ContactCodeConverter + state.app.url_map.converters['singleletter'] = ContactMockConverter + +# Helper for pagination. +def paginate_list(cat, q): + pageno = int(request.args.get('page', 1)) + return render_template( + "contactnova/list.html", + cat=cat, + count=q.count(), + people=q.offset(50 * (pageno - 1)).limit(50), + pageno=pageno + ) + +bp = Blueprint('contactnova', __name__, + url_prefix='/kt') +bp.record_once(_register_converters) + +@bp.route('/init-config') +def _init_config(): + init_db() + return redirect('/kt') + +@bp.route('/') +def homepage(): + q = Contact.select().where(Contact.due > datetime.datetime.now()).order_by(Contact.due.desc()) + return paginate_list('All', q) + +@bp.route('/expired') +def expired(): + q = Contact.select().where(Contact.due <= datetime.datetime.now()).order_by(Contact.due) + return paginate_list('Expired', q) + +@bp.route('/ok') +def sanecontacts(): + q = Contact.select().where((Contact.due > datetime.datetime.now()) & + (Contact.status == ST_OK)).order_by(Contact.due) + return paginate_list('Sane', q) + +@bp.route('/') +def singleletter(l): + q = Contact.select().where(Contact.code ** (l + '%')).order_by(Contact.id) + return paginate_list('Series {}'.format(l), q) + +@bp.route('/') +def singlecontact(code): + try: + p = Contact.get(Contact.code == code) + except IntegrityError: + abort(404) + return render_template('contactnova/single.html', p=p) + +@bp.route('/_newcode/') +def newcode(l): + return Contact.next_code(l), {"Content-Type": "text/plain"} + +@bp.route('/new', methods=['GET', 'POST']) +def newcontact(): + if request.method == 'POST': + Contact.create( + code = Contact.next_code(request.form['letter']), + display_name = request.form['display_name'], + status = request.form['status'], + issues = request.form['issues'], + description = request.form['description'], + due = datetime.date.fromisoformat(request.form['due']) + ) + return redirect(request.form.get('returnto','/kt/'+request.form['letter'])) + return render_template('contactnova/new.html', pl_date = now7days()) + +@bp.route('/edit/', methods=['GET', 'POST']) +def editcontact(code): + pl = Contact.get(Contact.code == code) + if request.method == 'POST': + pl.display_name = request.form['display_name'] + pl.issues = request.form['issues'] + pl.status = request.form['status'] + pl.description = request.form['description'] + pl.due = datetime.date.fromisoformat(request.form['due']) + pl.touched = datetime.datetime.now() + pl.save() + return redirect(request.form.get('returnto','/kt/'+pl.code[0])) + return render_template('contactnova/new.html', pl = pl) + +@bp.route('/_jsoninfo/') +def contact_jsoninfo(ts): + tse = str(datetime.datetime.fromtimestamp(ts).isoformat(" ")) + ps = Contact.select().where(Contact.touched >= tse) + return jsonify({ + "ids": [i.code for i in ps], + "data": [ + { + 'code': i.code, + 'display_name': i.display_name, + 'due': datetime.datetime.fromisoformat(i.due.isoformat()).timestamp(), + 'issues': i.issues, + 'description': i.description, + 'status': i.status + } for i in ps + ], + "status": "ok" + }) diff --git a/static/style.css b/static/style.css index 19d3ae5..4dcdcf4 100644 --- a/static/style.css +++ b/static/style.css @@ -41,6 +41,8 @@ input[type="submit"],input[type="reset"],input[type="button"],button{font-family .page-tags .tag-count{color:#3c3;font-size:smaller;font-weight:600} .search-wrapper {display:flex;width:90%;margin:auto} .search-wrapper > input {flex:1} + +/* Circles extension */ .circles-red{color: #e14} .circles-orange{color: #f72} .circles-green{color: #6e4} @@ -56,6 +58,14 @@ input[type="submit"],input[type="reset"],input[type="button"],button{font-family .circles-add-form > div{display:table-row} .circles-add-form > div > *{display:table-cell} .circles-add-form > div > label{text-align:right} +.circles-li-code {font-size: 120%} + +/* ContactNova extension */ +.contactnova-issues {padding-left: 12px; border-left: 4px solid #fc6} +.contactnova-col-code {font-size: 1.25em; font-weight: bold} +.contactnova-status_1 .material-icons {color: #6e4} +.contactnova-status_2 .material-icons {color: #fc6} +.contactnova-list td {vertical-align: middle} /* floating elements */ @@ -132,3 +142,19 @@ body.dark, .dark input, .dark textarea{background-color: #1f1f1f; color: white} a.dark-theme-toggle-off{display: none} .dark a.dark-theme-toggle-off{display: inline} .dark a.dark-theme-toggle-on{display: none} + +/* ?????? */ +.wrap_responsive_cells { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + /* This is better for small screens, once min() is better supported */ + /* grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); */ + gap: 1rem; +} +.wrap_responsive_cells_narrow { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + /* This is better for small screens, once min() is better supported */ + /* grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr)); */ + gap: 2px; +} diff --git a/strings.csv b/strings.csv index f06345a..af52858 100644 --- a/strings.csv +++ b/strings.csv @@ -23,3 +23,7 @@ search,Search,Cerca year,Year,Anno calculate,Calculate,Calcola show-all,Show all,Mostra tutto +just-now,just now,poco fa +n-minutes-ago,{0} minutes ago,{0} minuti fa +n-hours-ago,{0} hours ago,{0} ore fa +n-days-ago,{0} days ago,{0} giorni fa diff --git a/templates/circles/add.html b/templates/circles/add.html index de1c79c..eb2a937 100644 --- a/templates/circles/add.html +++ b/templates/circles/add.html @@ -5,6 +5,7 @@ {% block content %}

+ {% if returnto %}{% endif %}
diff --git a/templates/circles/list.html b/templates/circles/list.html index cb334ae..8b03b4a 100644 --- a/templates/circles/list.html +++ b/templates/circles/list.html @@ -5,8 +5,8 @@ {% block content %}

Showing: {{ cat }}

-
-

Show by:

+
+ Show by:

Type: {% set typ_list = [ 'INTJ', 'INTP', 'INFJ', 'INFP', 'ENTJ', 'ENTP', @@ -26,7 +26,9 @@ {% for t in range(1, 13) %} {{ t }} · {% endfor %} + Unassigned

+

Stats · Add new.

{% if count > people.count() %} @@ -42,7 +44,10 @@ diff --git a/templates/circles/stats.html b/templates/circles/stats.html index 43cfa2e..f1617b0 100644 --- a/templates/circles/stats.html +++ b/templates/circles/stats.html @@ -8,7 +8,7 @@