commit c2bf966dac771748e81848bc1e9c4aaba6c1a0b7 Author: Mattia Succurro Date: Tue Feb 23 22:54:08 2021 +0100 initial commit (it has come late tho 🙁) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0a9c28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# application content +media/ +**.sqlite +database/ + +# automatically generated garbage +**/__pycache__/ +**.pyc +**~ +**/.\#* +**/\#*\# +ig_api_settings/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a2d4b30 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (C) 2020-2021 Sakuragasaki46 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..25339b7 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Salvi + +Salvi is a simple wiki-like note-taking web application, written in Python using +Flask framework. + +**Warning**: Salvi is designed for personal, individual use. It may not be +suitable as a community or team knowledge base. + +## Features + ++ Write notes on the go, using Markdown syntax ++ Any note can have its own URL ++ Revision history ++ Stored in SQLite databases ++ Material Icons ++ Light/dark theme (requires JS as of now) ++ Works fine even with JavaScript disabled. + +## Requirements + ++ **Python** 3.6+. ++ **Flask** web framework. ++ **Peewee** ORM. + +## Caveats + ++ All pages created are, as of now, viewable and editable by anyone, with no + trace of users and/or passwords. + +## License + +[MIT License](./LICENSE). diff --git a/app.py b/app.py new file mode 100644 index 0000000..ae0b6aa --- /dev/null +++ b/app.py @@ -0,0 +1,686 @@ +# (C) 2020-2021 Sakuragasaki46. +# See LICENSE for copying info. + +''' +A simple wiki-like note webapp. + +Pages are stored in SQLite databases. +Markdown is used for text formatting. + +Application is kept compact, with all its core in a single file. +Extensions are supported, kept in extensions/ folder. +''' + +from flask import Flask, abort, flash, g, jsonify, redirect, request, render_template, send_from_directory +from werkzeug.routing import BaseConverter +from peewee import * +import datetime, re, markdown, uuid, json, importlib, sys, hashlib, html, os, csv, random +from functools import lru_cache, partial +from urllib.parse import quote +from configparser import ConfigParser +try: + import gzip +except ImportError: + gzip = None +try: + from slugify import slugify +except ImportError: + slugify = None + +__version__ = '0.1-dev' + +#### CONSTANTS #### + +APP_BASE_DIR = os.path.dirname(__file__) + +FK = ForeignKeyField + +SLUG_RE = r'[a-z0-9]+(?:-[a-z0-9]+)*' +MAGIC_RE = r'\{\{\s*(' + SLUG_RE + ')\s*:\s*(.*?)\s*\}\}' +REDIRECT_RE = r'\{\{\s*redirect\s*:\s*(\d+)\s*\}\}' + +upload_types = {'jpeg': 1, 'jpg': 1, 'png': 2} +upload_types_rev = {1: 'jpg', 2: 'png'} +UPLOAD_DIR = APP_BASE_DIR + '/media' + +DATABASE_DIR = APP_BASE_DIR + "/database" + +#### DATABASE SCHEMA #### + +database = SqliteDatabase(DATABASE_DIR + '/data.sqlite') + +class BaseModel(Model): + class Meta: + database = database + +class Page(BaseModel): + url = CharField(64, unique=True, null=True) + title = CharField(256) + is_redirect = BooleanField() + touched = DateTimeField() + @property + def latest(self): + if self.revisions: + return self.revisions.order_by(PageRevision.pub_date.desc())[0] + def get_url(self): + return '/' + self.url + '/' if self.url else '/p/{}/'.format(self.id) + def short_desc(self): + text = remove_tags(self.latest.text) + return text[:200] + ('\u2026' if len(text) > 200 else '') + def change_tags(self, new_tags): + old_tags = set(x.name for x in self.tags) + new_tags = set(new_tags) + PageTag.delete().where((PageTag.page == self) & + (PageTag.name << (old_tags - new_tags))).execute() + for tag in (new_tags - old_tags): + PageTag.create(page=self, name=tag) + @property + def prop(self): + return PagePropertyDict(self) + +class PageText(BaseModel): + content = BlobField() + flags = BitField() + is_utf8 = flags.flag(1) + is_gzipped = flags.flag(2) + def get_content(self): + c = self.content + if self.is_gzipped: + c = gzip.decompress(c) + if self.is_utf8: + return c.decode('utf-8') + else: + return c.decode('latin-1') + @classmethod + def create_content(cls, text, treshold=600, search_dup=True): + c = text.encode('utf-8') + use_gzip = len(c) > treshold + if use_gzip and gzip: + c = gzip.compress(c) + if search_dup: + item = cls.get_or_none((cls.content == c) & (cls.is_gzipped == use_gzip)) + if item: + return item + return cls.create( + content=c, + is_utf8=True, + is_gzipped=use_gzip + ) + +class PageRevision(BaseModel): + page = FK(Page, backref='revisions') + user_id = IntegerField(default=0) + comment = CharField(256, default='') + textref = FK(PageText) + pub_date = DateTimeField() + length = IntegerField() + @property + def text(self): + return self.textref.get_content() + def html(self): + return md(self.text) + +class PageTag(BaseModel): + page = ForeignKeyField(Page, backref='tags') + name = CharField(64) + class Meta: + indexes = ( + (('page', 'name'), True), + ) + def popularity(self): + return PageTag.select().where(PageTag.name == self.name).count() + +class PageProperty(BaseModel): + page = ForeignKeyField(Page, backref='page_meta') + key = CharField(64) + value = CharField(8000) + class Meta: + indexes = ( + (('page', 'key'), True), + ) + +# currently experimental +class PagePropertyDict(object): + def __init__(self, page): + self._page = page + def items(self): + for kv in self._page.page_meta: + yield kv.key, kv.value + def __len__(self): + return self._page.page_meta.count() + def keys(self): + for kv in self._page.page_meta: + yield kv.key + __iter__ = keys + def __getitem__(self, key): + try: + return self._page.page_meta.get(PageProperty.key == key).value + except PageProperty.DoesNotExist: + raise KeyError(key) + def get(self, key, default=None): + try: + return self._page.page_meta.get(PageProperty.key == key).value + except PageProperty.DoesNotExist: + return default + def setdefault(self, key, default): + try: + return self._page.page_meta.get(PageProperty.key == key).value + except PageProperty.DoesNotExist: + self[key] = default + return default + def __setitem__(self, key, value): + if key in self: + pp = self._page.page_meta.get(PageProperty.key == key) + pp.value = value + pp.save() + else: + PageProperty.create(page=self._page, key=key, value=value) + def __delitem__(self, key): + PageProperty.delete().where((PageProperty.page == self._page) & + (PageProperty.key == key)).execute() + def __contains__(self, key): + return PageProperty.select().where((PageProperty.page == self._page) & + (PageProperty.key == key)).exists() + + +class Upload(BaseModel): + name = CharField(256) + url_name = CharField(256, null=True) + filetype = SmallIntegerField() + filesize = IntegerField() + upload_date = DateTimeField() + md5 = CharField(32) + @property + def filepath(self): + return '{0}/{1}/{2}{3}.{4}'.format(self.md5[:1], self.md5[:2], self.id, + '-' + self.url_name if self.url_name else '', upload_types_rev[self.filetype]) + @property + def url(self): + return '/media/' + self.filepath + def get_content(self, check=True): + with open(os.path.join(UPLOAD_DIR, self.filepath)) as f: + content = f.read() + if check: + if len(content) != self.filesize: + raise AssertionError('file is corrupted') + if hashlib.md5(content).hexdigest() != self.md5: + raise AssertionError('file is corrupted') + return content + @classmethod + def create_content(cls, name, ext, content): + ext = ext.lstrip('.') + if ext not in upload_types: + raise ValueError('invalid file type') + filetype = upload_types[ext] + name = name[:256] + if slugify: + url_name = slugify(name)[:256] + else: + url_name = None + filemd5 = hashlib.md5(content).hexdigest() + basepath = os.path.join(UPLOAD_DIR, filemd5[:1], filemd5[:2]) + if not os.path.exists(basepath): + os.makedirs(basepath) + obj = cls.create( + name=name, + url_name=url_name, + filetype=filetype, + filesize=len(content), + upload_date=datetime.datetime.now(), + md5=filemd5 + ) + try: + with open(os.path.join(basepath, '{0}{1}.{2}'.format(obj.id, + '-' + url_name if url_name else '', upload_types_rev[filetype] + )), 'wb') as f: + f.write(content) + except OSError: + cls.delete_by_id(obj.id) + raise + return obj + +def init_db(): + database.create_tables([Page, PageText, PageRevision, PageTag, PageProperty, Upload]) + +#### WIKI SYNTAX #### + +magic_word_filters = {} + +def _replace_magic_word(match): + name = match.group(1) + if name not in magic_word_filters: + return match.group() + f = magic_word_filters[name] + try: + return f(*(x.strip() for x in match.group(2).split('|'))) + except Exception: + return '' + +def expand_magic_words(text): + ''' + Replace the special markups in double curly brackets. + + Unknown keywords are not replaced. Valid keywords with invalid arguments are replaced with nothing. + ''' + return re.sub(MAGIC_RE, _replace_magic_word, text) + +def md(text, expand_magic=True, toc=True): + if expand_magic: + text = expand_magic_words(text) + extensions = ['tables', 'footnotes', 'markdown_strikethrough.extension', 'fenced_code', 'sane_lists'] + if toc: + extensions.append('toc') + return markdown.Markdown(extensions=extensions).convert(text) + +# unused, MD already adds anchors by itself +#def make_header_anchor(match): +# tagname, tagattrs, text = match.group(1), match.group(2), match.group(3) +# anchor = quote(remove_tags(text, False)).replace('.', '.2E').replace('%', '.') +# return '<{0} id="{3}"{1}>{2}'.format(tagname, tagattrs, text, anchor) + +def remove_tags(text, convert=True, headings=True): + if headings: + text = re.sub(r'\#[^\n]*', '', text) + if convert: + text = md(text, expand_magic=False, toc=False) + return re.sub(r'<.*?>|\{\{.*?\}\}', '', text) + + +### Magic words ### + +def expand_backto(pageid): + p = Page[pageid] + return '*« Main article: [{}]({}).*'.format(html.escape(p.title), p.get_url()) + +magic_word_filters['backto'] = expand_backto + +def expand_upload(id, *opt): + try: + upload = Upload[id] + except Upload.DoesNotExist: + return '' + if opt: + desc = opt[-1] + else: + desc = None + classname = 'fig-right' + return '
{1}{3}
'.format( + classname, html.escape(upload.name), upload.url, + '
{0}
'.format(md(desc, expand_magic=False)) if desc else '', + upload.id) + +magic_word_filters['media'] = expand_upload + +def make_gallery(items): + result = [] + for upload, desc in items: + result.append(''.format( + upload.id, html.escape(upload.name), upload.url, + '
{0}
'.format(md(desc, expand_magic=False)) if desc else '')) + return '' + +def expand_gallery(*ids): + items = [] + for i in ids: + if ' ' in i: + id, desc = i.split(' ', 1) + else: + id, desc = i, '' + try: + upload = Upload[id] + except Upload.DoesNotExist: + continue + items.append((upload, desc)) + return make_gallery(items) + +magic_word_filters['gallery'] = expand_gallery + +#### I18N #### + +lang_poses = {'en': 1, 'en-US': 1, 'it': 2, 'it-IT': 2} + +def read_strings(): + with open(APP_BASE_DIR + '/strings.csv', encoding='utf-8') as f: + return csv.reader(f) + +@lru_cache(maxsize=1000) +def get_string(lang, name): + with open(APP_BASE_DIR + '/strings.csv', encoding='utf-8') as f: + for line in csv.reader(f): + if not line[0] or line[0].startswith('#'): + continue + if line[0] == name: + ln = lang_poses[lang] + if len(line) > ln and line[ln]: + return line[ln] + elif len(line) > 1: + return line[1] + return '(' + name + ')' + + +#### APPLICATION CONFIG #### + +class SlugConverter(BaseConverter): + regex = SLUG_RE + +def is_valid_url(url): + return re.fullmatch(SLUG_RE, url) + +def is_url_available(url): + return url not in forbidden_urls and not Page.select().where(Page.url == url).exists() + +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' +] + +app = Flask(__name__) +app.secret_key = 'qrdldCcvamtdcnidmtasegasdsedrdqvtautar' +app.url_map.converters['slug'] = SlugConverter + +#### ROUTES #### + +@app.before_request +def _before_request(): + for l in request.headers.get('accept-language', 'it,en').split(','): + if ';' in l: + l, _ = l.split(';') + if l in lang_poses: + lang = l + break + else: + lang = 'en' + g.lang = lang + +@app.context_processor +def _inject_variables(): + return { + 'T': partial(get_string, g.lang) + } + +@app.route('/') +def homepage(): + return render_template('home.html', new_notes=Page.select() + .order_by(Page.touched.desc()).limit(20), + gallery=make_gallery((x, '') for x in Upload.select().order_by(Upload.upload_date.desc()).limit(3))) + +@app.route('/robots.txt') +def robots(): + return send_from_directory(APP_BASE_DIR, 'robots.txt') + +@app.route('/favicon.ico') +def favicon(): + return send_from_directory(APP_BASE_DIR, 'favicon.ico') + +## error handlers ## + +@app.errorhandler(404) +def error_404(body): + return render_template('notfound.html'), 404 + + +# Helpers for page editing. +def savepoint(form, is_preview=False): + if is_preview: + preview = md(form['text']) + else: + preview = None + return render_template('edit.html', pl_url=form['url'], pl_title=form['title'], pl_text=form['text'], pl_tags=form['tags'], preview=preview) + +@app.route('/create/', methods=['GET', 'POST']) +def create(): + if request.method == 'POST': + if request.form.get('preview'): + return savepoint(request.form, is_preview=True) + p_url = request.form['url'] or None + if p_url: + if not is_valid_url(p_url): + flash('Invalid URL. Valid URLs contain only letters, numbers and hyphens.') + return savepoint(request.form) + elif not is_url_available(p_url): + flash('This URL is not available.') + return savepoint(request.form) + p_tags = [x.strip().lower().replace(' ', '-').replace('_', '-').lstrip('#') + for x in request.form.get('tags', '').split(',') if x] + if any(not re.fullmatch(SLUG_RE, x) for x in p_tags): + flash('Invalid tags text. Tags contain only letters, numbers and hyphens, and are separated by comma.') + return savepoint(request.form) + try: + p = Page.create( + url=p_url, + title=request.form['title'], + is_redirect=False, + touched=datetime.datetime.now(), + ) + p.change_tags(p_tags) + except IntegrityError: + flash('An error occurred while saving this revision.') + return savepoint(request.form) + pr = PageRevision.create( + page=p, + user_id=0, + comment='', + textref=PageText.create_content(request.form['text']), + pub_date=datetime.datetime.now(), + length=len(request.form['text']) + ) + return redirect(p.get_url()) + return render_template('edit.html', pl_url=request.args.get('url')) + +@app.route('/edit//', methods=['GET', 'POST']) +def edit(id): + p = Page[id] + if request.method == 'POST': + if request.form.get('preview'): + return savepoint(request.form, is_preview=True) + p_url = request.form['url'] or None + if p_url: + if not is_valid_url(p_url): + flash('Invalid URL. Valid URLs contain only letters, numbers and hyphens.') + return savepoint(request.form) + elif not is_url_available(p_url) and p_url != p.url: + flash('This URL is not available.') + return savepoint(request.form) + p_tags = [x.strip().lower().replace(' ', '-').replace('_', '-').lstrip('#') + for x in request.form.get('tags', '').split(',')] + p_tags = [x for x in p_tags if x] + if any(not re.fullmatch(SLUG_RE, x) for x in p_tags): + flash('Invalid tags text. Tags contain only letters, numbers and hyphens, and are separated by comma.') + return savepoint(request.form) + p.url = p_url + p.title = request.form['title'] + p.touched = datetime.datetime.now() + p.save() + p.change_tags(p_tags) + pr = PageRevision.create( + page=p, + user_id=0, + comment='', + textref=PageText.create_content(request.form['text']), + pub_date=datetime.datetime.now(), + length=len(request.form['text']) + ) + return redirect(p.get_url()) + return render_template('edit.html', pl_url=p.url, pl_title=p.title, pl_text=p.latest.text, pl_tags=','.join(x.name for x in p.tags)) + +@app.route('/p//') +def view_unnamed(id): + try: + p = Page[id] + except Page.DoesNotExist: + abort(404) + if p.url: + if p.url not in forbidden_urls: + return redirect(p.get_url()) + else: + flash('The URL of this page is a reserved URL. Please change it.') + return render_template('view.html', p=p, rev=p.latest) + +@app.route('/p/most_recent/') +@app.route('/p/most_recent//') +def view_most_recent(page=1): + general_query = Page.select().order_by(Page.touched.desc()) + return render_template('listrecent.html', notes=general_query.paginate(page), + page_n=page, total_count=general_query.count(), min=min) + +@app.route('/p/random/') +def view_random(): + page = None + if Page.select().count() < 2: + flash('Too few pages in this site.') + abort(404) + while not page: + try: + page = Page[random.randint(1, Page.select().count())] + except Page.DoesNotExist: + continue + return redirect(page.get_url()) + + +@app.route('//') +def view_named(name): + try: + p = Page.get(Page.url == name) + except Page.DoesNotExist: + abort(404) + return render_template('view.html', p=p, rev=p.latest) + +@app.route('/init-config/tables/') +def init_config_tables(): + init_db() + flash('Tables successfully created.') + return redirect('/') + +@app.route('/history//') +def history(id): + try: + p = Page[id] + except Page.DoesNotExist: + abort(404) + return render_template('history.html', p=p, history=p.revisions.order_by(PageRevision.pub_date.desc())) + +@app.route('/history/revision//') +def view_old(revisionid): + try: + rev = PageRevision[revisionid] + except PageRevision.DoesNotExist: + abort(404) + p = rev.page + return render_template('viewold.html', p=p, rev=rev) + +@app.route('/search/', methods=['GET', 'POST']) +def search(): + if request.method == 'POST': + q = request.form['q'] + include_tags = bool(request.form.get('include-tags')) + query = Page.select().where(Page.title ** ('%' + q + '%')) + if include_tags: + query |= Page.select().join(PageTag, on=PageTag.page + ).where(PageTag.name ** ('%' + q + '%')) + query = query.order_by(Page.touched.desc()) + return render_template('search.html', q=q, pl_include_tags=include_tags, + results=query.paginate(1)) + return render_template('search.html', pl_include_tags=True) + +@app.route('/tags//') +@app.route('/tags///') +def listtag(tag, page=1): + general_query = Page.select().join(PageTag, on=PageTag.page).where(PageTag.name == tag).order_by(Page.touched.desc()) + page_query = general_query.paginate(page) + return render_template('listtag.html', tagname=tag, tagged_notes=page_query, + page_n=page, total_count=general_query.count(), min=min) + +@app.route('/media/') +def media(fp): + return send_from_directory(UPLOAD_DIR, fp) + +@app.route('/upload/', methods=['GET', 'POST']) +def upload(): + if request.method == 'POST': + name = request.form['name'] + file = request.files['file'] + filename = file.filename + ext = os.path.splitext(filename)[1] + content = file.read() + try: + upl = Upload.create_content(name, ext, content) + flash('File uploaded successfully') + return redirect('/upload-info/{}/'.format(upl.id)) + except Exception: + sys.excepthook(*sys.exc_info()) + flash('Unable to upload file. Try again later.') + return render_template('upload.html') + +@app.route('/upload-info//') +def upload_info(id): + upl = Upload[id] + return render_template('uploadinfo.html', upl=upl, type_list=upload_types_rev) + +@app.route('/stats/') +def stats(): + return render_template('stats.html', + notes_count=Page.select().count(), + notes_with_url=Page.select().where(Page.url != None).count(), + upload_count=Upload.select().count(), + revision_count=PageRevision.select().count() + ) + +## easter egg (lol) ## + +MNeaster = { + 15: (22, 2), 16: (22, 2), 17: (23, 3), 18: (23, 4), 19: (24, 5), 20: (24, 5), + 21: (24, 6), 22: (25, 0), 23: (26, 1), 24: (25, 1)} + +def calculate_easter(y): + a, b, c = y % 19, y % 4, y % 7 + M, N = (15, 6) if y < 1583 else MNeaster[y // 100] + d = (19 * a + M) % 30 + e = (2 * b + 4 * c + 6 * d + N) % 7 + if d + e < 10: + return datetime.date(y, 3, d + e + 22) + else: + day = d + e - 9 + if day == 26: + day = 19 + elif day == 25 and d == 28 and e == 6 and a > 10: + day = 18 + return datetime.date(y, 4, day) + +def stash_easter(y): + easter = calculate_easter(y) + natale = datetime.date(y, 12, 25) + avvento1 = natale - datetime.timedelta(days=22 + natale.weekday()) + return dict( + easter = easter, + ceneri = easter - datetime.timedelta(days=47), + ascensione = easter + datetime.timedelta(days=42), + pentecoste = easter + datetime.timedelta(days=49), + avvento1 = avvento1 + ) + +@app.route('/easter/') +@app.route('/easter//') +def easter_y(y=None): + if 'y' in request.args: + return redirect('/easter/' + request.args['y'] + '/') + if y: + if y > 2499: + flash('Years above 2500 A.D. are currently not supported.') + return render_template('easter.html') + return render_template('easter.html', y=y, easter_dates=stash_easter(y)) + else: + return render_template('easter.html') + +#### EXTENSIONS #### + +active_extensions = [] + +for ext in active_extensions: + try: + bp = importlib.import_module('extensions.' + ext).bp + app.register_blueprint(bp) + except Exception: + sys.stderr.write('Extension not loaded: ' + ext + '\n') + sys.excepthook(*sys.exc_info()) + diff --git a/extensions/importexport.py b/extensions/importexport.py new file mode 100644 index 0000000..87dba27 --- /dev/null +++ b/extensions/importexport.py @@ -0,0 +1,70 @@ +from flask import Blueprint, render_template, request +import json, datetime + +from app import Page, PageTag + +bp = Blueprint('importexport', __name__) + +class Exporter(object): + def __init__(self): + self.root = {'pages': [], 'users': {}} + def add_page(self, p, include_history=True, include_users=False): + pobj = {} + pobj['title'] = p.title + pobj['url'] = p.url + pobj['tags'] = [tag.name for tag in p.tags] + hist = [] + for rev in (p.revisions if include_history else [p.latest]): + revobj = {} + revobj['text'] = rev.text + revobj['timestamp'] = rev.pub_date.timestamp() + if include_users: + revobj['user'] = rev.user_id + if rev.user_id not in self.root['users']: + self.root['users'][rev.user_id] = rev.user_info() + else: + revobj['user'] = None + revobj['comment'] = rev.comment + revobj['length'] = rev.length + hist.append(revobj) + pobj['history'] = hist + self.root['pages'].append(pobj) + def add_page_list(self, pl, include_history=True, include_users=False): + for p in pl: + self.add_page(p, include_history=include_history, include_users=include_users) + def export(self): + return json.dumps(self.root) + +@bp.route('/manage/export/', methods=['GET', 'POST']) +def exportpages(): + if request.method == 'POST': + raw_list = request.form['export-list'] + q_list = [] + for item in raw_list.split('\n'): + item = item.strip() + if len(item) < 2: + continue + if item.startswith('+'): + q_list.append(Page.select().where(Page.id == item[1:])) + elif item.startswith('#'): + q_list.append(Page.select().join(PageTag, on=PageTag.page).where(PageTag.name == item[1:])) + elif item.startswith('/'): + q_list.append(Page.select().where(Page.url == item[1:].rstrip('/'))) + else: + q_list.append(Page.select().where(Page.title == item)) + if not q_list: + flash('Failed to export pages: The list is empty!') + return render_template('exportpages.html') + query = q_list.pop(0) + while q_list: + query |= q_list.pop(0) + e = Exporter() + e.add_page_list(query) + return e.export(), {'Content-Type': 'application/json', 'Content-Disposition': 'attachment; ' + + 'filename=export-{}.json'.format(datetime.datetime.now().strftime('%Y%m%d-%H%M%S'))} + return render_template('exportpages.html') + +@bp.route('/manage/import/', methods=['GET', 'POST']) +def importpages(): + return render_template('importpages.html') + diff --git a/extensions/instagram.py b/extensions/instagram.py new file mode 100644 index 0000000..431a239 --- /dev/null +++ b/extensions/instagram.py @@ -0,0 +1,173 @@ +from flask import Blueprint, render_template +from peewee import * +import instagram_private_api, json, os, sys, random, codecs + +database = SqliteDatabase('instagram.sqlite') + +class BaseModel(Model): + class Meta: + database = database + +class InstagramProfile(BaseModel): + p_id = IntegerField() + p_username = CharField(30) + p_full_name = CharField(30) + p_biography = CharField(150) + posts_count = IntegerField() + followers_count = IntegerField() + following_count = IntegerField() + flags = BitField() + pub_date = DateTimeField() + is_verified = flags.flag(1) + is_private = flags.flag(2) + +class InstagramMedia(BaseModel): + user = IntegerField() + pub_date = DateTimeField() + media_url = TextField() + description = CharField(2200) + +def init_db(): + database.create_tables([InstagramProfile, InstagramMedia]) + +def bytes_to_json(python_object): + if isinstance(python_object, bytes): + return {'__class__': 'bytes', + '__value__': codecs.encode(python_object, 'base64').decode()} + raise TypeError(repr(python_object) + ' is not JSON serializable') + +def bytes_from_json(json_object): + if '__class__' in json_object and json_object['__class__'] == 'bytes': + return codecs.decode(json_object['__value__'].encode(), 'base64') + return json_object + +SETTINGS_PATH = 'ig_api_settings' + +def load_settings(username): + with open(os.path.join(SETTINGS_PATH, username + '.json')) as f: + settings = json.load(f, object_hook=bytes_from_json) + return settings + +def save_settings(username, settings): + with open(os.path.join(SETTINGS_PATH, username + '.json'), 'w') as f: + json.dump(settings, f, default=bytes_to_json) + +CLIENTS = [] + +def load_clients(): + try: + with open(os.path.join(SETTINGS_PATH, 'config.txt')) as f: + conf = f.read() + except OSError: + print('Config file not found.') + return + for up in conf.split('\n'): + try: + up = up.split('#')[0].strip() + if not up: + continue + username, password = up.split(':') + try: + settings = load_settings(username) + except Exception: + settings = None + try: + if settings: + device_id = settings.get('device_id') + api = instagram_private_api.Client( + username, password, + settings=settings + ) + else: + api = instagram_private_api.Client( + username, password, + on_login=lambda x: save_settings(username, x.settings) + ) + except (instagram_private_api.ClientCookieExpiredError, + instagram_private_api.ClientLoginRequiredError) as e: + api = instagram_private_api.Client( + username, password, + device_id=device_id, + on_login=lambda x: save_settings(username, x.settings) + ) + CLIENTS.append(api) + except Exception: + sys.excepthook(*sys.exc_info()) + continue + +def make_request(method_name, *args, **kwargs): + exc = None + usable_clients = list(range(len(CLIENTS))) + while usable_clients: + ci = random.choice(usable_clients) + client = CLIENTS[ci] + usable_clients.remove(ci) + try: + method = getattr(client, method_name) + except AttributeError: + raise ValueError('client has no method called {!r}'.format(method_name)) + if not callable(method): + raise ValueError('client has no method called {!r}'.format(method_name)) + try: + return method(*args, **kwargs) + except Exception as e: + exc = e + if exc: + raise exc + else: + raise RuntimeError('no active clients') + +N_FORCE = 0 +N_FALLBACK_CACHE = 1 +N_PREFER_CACHE = 2 +N_OFFLINE = 3 + +def choose_method(online, offline, network): + if network == N_FORCE: + return online() + elif network == N_FALLBACK_CACHE: + try: + return online() + except Exception: + return offline() + elif network == N_PREFER_CACHE: + try: + return offline() + except Exception: + return online() + elif network == N_OFFLINE: + return offline() + +def get_profile_info(username_or_id, network=N_FALLBACK_CACHE): + if isinstance(username_or_id, str): + username, userid = username_or_id, None + elif isinstance(username_or_id, int): + username, userid = None, username_or_id + else: + raise TypeError('invalid username or id') + def online(): + if userid: + data = make_request('user_info', userid) + else: + data = make_request('username_info', username) + return InstagramProfile.create( + p_id = data['user']['pk'], + p_username = data['user']['username'], + p_full_name = data['user']['full_name'], + p_biography = data['user']['biography'], + posts_count = data['user']['media_count'], + followers_count = data['user']['follower_count'], + following_count = data['user']['following_count'], + is_verified = data['user']['is_verified'], + is_private = data['user']['is_private'], + pub_date = datetime.datetime.now() + ) + def offline(): + if userid: + q = InstagramProfile.select().where(InstagramProfile.p_id == userid) + else: + q = InstagramProfile.select().where(InstagramProfile.p_username == username) + return q.order_by(InstagramProfile.pub_date.desc())[0] + return choose_method(online, offline, network) + +load_clients() diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..39f1c59 Binary files /dev/null and b/favicon.ico differ diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..d8b9aa9 --- /dev/null +++ b/robots.txt @@ -0,0 +1,4 @@ +User-Agent: * +Noindex: /edit/ +Noindex: /history/ +Disallow: /accounts/ diff --git a/static/edit.js b/static/edit.js new file mode 100644 index 0000000..a7c1776 --- /dev/null +++ b/static/edit.js @@ -0,0 +1,69 @@ +/* Enhancements to editor. + * + * Editor runs smoothly even with JS disabled ;) */ + +(function(){ + function getFirst(o){return o && o[0]} + + var textInput = getFirst(document.getElementsByClassName('text-input')); + var overTextInput = getFirst(document.getElementsByClassName('over-text-input')); + + overTextInput.innerHTML = [ + ' ', + '? chars', + '', + //'Link page', + ].join(' '); + + // character counter + var oldText = null, originalText = textInput.value; + textInput.oninput = function(){ + var newText = textInput.value; + if(newText != oldText){ + oldText = newText; + + overTextInput.children[0].innerHTML = newText == originalText? ' ' : '(*)'; + overTextInput.children[1].innerHTML = newText.length + ' char' + (newText.length == 1? '' : 's'); + } + } + overTextInput.children[1].innerHTML = originalText.length + ' char' + (originalText.length == 1? '' : 's'); + + // change font of textarea + var otiFontSelect = overTextInput.children[2].children[0]; + otiFontSelect.onchange = function(){ + textInput.className = textInput.className.replace(/\bti-font-\w+\b/, '') + ' ti-font-' + otiFontSelect.value; + }; + + // TODO link selector + /*overTextInput.children[3].onclick = function(){ + + }*/ + + // url validation + var urlInput = getFirst(document.getElementsByClassName('url-input')); + urlInput.onchange = function(){ + if (!/^[a-z0-9-]*$/i.test(urlInput.value)) { + urlInput.classList.add("error"); + } else { + urlInput.classList.remove("error"); + } + } + + // leave confirmation + var saveButton = document.getElementById('save-button'); + saveButton.onclick = function(){ + window.onbeforeunload = null; + } + var previewButton = document.getElementById('preview-button'); + previewButton.onclick = function(){ + window.onbeforeunload = null; + } + window.onbeforeunload = function(){ + if(oldText && oldText != originalText){ + return 'Are you sure you want to leave editing this page?'; + } + } + + // TODO tag editor + var tagsInput = getFirst(document.getElementsByClassName('tags-input')); +})(); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1f7d2af --- /dev/null +++ b/static/style.css @@ -0,0 +1,104 @@ +/* basic styles */ +body{font-family:sans-serif} +.content{margin: 3em 1.6em} + +/* content styles */ +.inner-content{font-family:serif; margin: 0 auto; max-width: 1280px; line-height: 1.5; color: #1f2528} +.inner-content em,.inner-content strong{color: black} +.inner-content h1{color: #99081f} +.inner-content table, .inner-content h2, .inner-content h3, .inner-content h4, .inner-content h5, .inner-content h6{font-family:sans-serif; color: black} +.inner-content h3{margin:0.8em 0} +.inner-content h4{margin:0.6em 0} +.inner-content h5{margin:0.5em 0} +.inner-content h6{margin:0.4em 0} +.inner-content p{text-indent: 1em; margin: .6em 0} +.inner-content blockquote{color:#363636; border-left: 4px solid #ccc;margin-left:0;padding-left:12px} +.inner-content table{border:#ccc 1px solid;border-collapse:collapse} +.inner-content table > * > tr > th, .inner-content table > tr > th {background-color:#f9f9f9;border:#ccc 1px solid;padding:2px} +.inner-content table > * > tr > td, .inner-content table > tr > td {border:#ccc 1px solid;padding:2px} + +/* interface styles */ +.nl-list{list-style:none} +.nl-title{font-size:1.2em; font-weight: 500} +.nl-desc{font-size:0.9em;opacity:.75;font-family:serif} +.nl-new{margin:6px 0 12px 0;display:flex;justify-content:start} +.nl-new > a{margin-right:12px} +input[type="text"]{border:0;border-bottom:3px solid #ccc;font:inherit;color:#181818;background-color:transparent} +input[type="text"]:focus{color:black;border-bottom-color:#09f} +input[type="text"].error{border-bottom-color:#ff1800} +.submit-primary{color:white;background-color:#37b92e;font-family:inherit;border:1px solid #37b92e;font-size:1.2em;height:2em;min-width:8em;border-radius:12px;display:inline-block} +.submit-secondary{color:black;background-color:white;font-family:inherit;border:1px solid #809980;font-size:1.2em;height:2em;min-width:8em;border-radius:12px;display:inline-block} +.flash{background-color:#fff2b4;padding:12px;border-radius:4px;border:1px #ffe660 solid} +.page-tags p{display:inline-block} +.page-tags ul{padding:0;margin:0;list-style:none;display:inline-block} +.page-tags ul > li{padding:6px 12px;display:inline-block;margin:0 4px;border-radius:4px;background-color:aliceblue} +.page-tags .tag-count{color:#3c3;font-size:smaller;font-weight:600} + +/* floating elements */ +.top-menu{list-style:none;padding:0;margin:0;font-size:0.9em;position:absolute;right:0;top:.5em;text-transform:lowercase} +.top-menu li{display:inline-block;padding-right:1em} +.toc{float:right} +@media (max-width:639px){ + .toc{display:none} +} +.backontop{position:fixed;bottom:0;right:0} +@media print{ + .backontop {display:none} +} +#__top{position:absolute;top:0;left:0} + +/* editor */ +input.title-input{overflow:visible;font-weight:bold;font-size:2em;width:100%;margin-top:1em} +.text-input{font:inherit;border-top:0;border-bottom:4px solid #e60;border-left:0;border-right:0;margin-bottom:12px;width:100%;height:20em} +.over-text-input{color:white;background-color:#e60;margin-top:12px;padding:4px} +.over-text-input select{padding: 0;border: 0;margin: 0;background: inherit;color: inherit;font: inherit;} +.text-input.ti-font-sans{font-family: sans-serif} +.text-input.ti-font-serif{font-family: serif} +.text-input.ti-font-monospace{font-family:monospace} + +/* images */ +.fig-right{float:right;clear:right} +.fig-gallery{display:inline-block} +.fig-right img, .fig-gallery img{width:220px} + +/* links */ +a:link{color:#239b89} +a:visited{color:#2f6a5f} +a:hover{color:#0088ff} +.metro-links{padding:12px;color:white;background-color:#333} +.metro-links a{color:white} +.metro-prev{float:left} +.metro-next{float:right} +.metro-links.metro-1{background-color:red} +.metro-links.metro-2{background-color:blue} +.metro-links.metro-3{background-color:green} +.metro-links.metro-4{background-color:orange} +.metro-links.metro-5{background-color:teal} +.metro-links.metro-6{background-color:purple} +.metro-divider{height:1px;background-color:white;clear:both} +.metro-badge{background-color:#333;border-radius:4px;color:white;font-size:80%} + +/* dark theme */ +body.dark, .dark input, .dark textarea{background-color: #1f1f1f; color: white} +.dark .inner-content{color: #e5e5e5} +.dark .inner-content em,.dark .inner-content strong,.dark .inner-content h2,.dark .inner-content h3,.dark .inner-content h4,.dark .inner-content h5,.dark .inner-content h6,.dark .inner-content table{color: white} +.dark .inner-content h1{color:#ff4860} +.dark .inner-content blockquote{color:#cecece;border-left-color:#555} +.dark .inner-content table,.dark .inner-content table > * > tr > th,.dark .inner-content table > * > tr > td,.dark .inner-content table > tr > th,.dark .inner-content table > tr > td{border-color:#555} +.dark .inner-content table > * > tr > th,.dark .inner-content table > tr > th{background-color:#333;} +.dark input[type="text"]{border-bottom-color:#555} +.dark input[type="text"]:focus{border-bottom-color:#4bf;color:white} +.dark input[type="text"].error{border-bottom-color:#e01400} +.dark .submit-primary{background-color: #5d3; border-color: #5d3} +.dark .submit-secondary{color: white; background-color: #1f1f1f; border-color: #9d3} +.dark .page-tags .tag-count{color: #ee0} +.dark .flash{background-color: #771; border-color: #fd2} +.dark .page-tags ul > li{background-color: #555} +.dark .text-input{border-bottom-color: #e60} +.dark .over-text-input{background-color: #e60} +.dark a:link{color:#99cadc} +.dark a:visited{color:#a2e2de} +.dark a:hover{color:#33aaff} +a.dark-theme-toggle-off{display: none} +.dark a.dark-theme-toggle-off{display: inline} +.dark a.dark-theme-toggle-on{display: none} diff --git a/strings.csv b/strings.csv new file mode 100644 index 0000000..9e5dee0 --- /dev/null +++ b/strings.csv @@ -0,0 +1,22 @@ +welcome,Welcome to {0}!,Benvenuti in {0}! +homepage,Homepage,Pagina iniziale +latest-notes,Latest notes,Note più recenti +latest-uploads,Latest uploads,Caricamenti più recenti +new-note,New note,Crea nota +upload-file,Upload file,Carica immagine +easter-date-calc,Easter date calculation,Calcolo della data di Pasqua +easter,Easter,Pasqua +other-dates,Other dates,Altre date +jump-to-actions,Jump to actions,Salta alle azioni +last-changed,Last changed,Ultima modifica +page-id,Page ID,ID pagina +action-edit,Edit,Modifica +action-history,History,Cronologia +tags,Tags,Etichette +old-revision-notice,Showing an old revision of the page as of,"È mostrata una revisione vecchia della pagina, risalente al" +notes-tagged,Notes tagged,Note con etichetta +include-tags,Include tags,Includi etichette +notes-tagged-empty,None found :(,Non c’è nulla :( +search-no-results,No results for,Nessun risultato per +random-page,Random page,Pagina casuale +search,Search,Cerca \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..54ccf2c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,34 @@ + + + + {% set app_name = 'Salvi' %} + {% block title %}{{ app_name }}{% endblock %} + + + + + + + +
+
+ {% for msg in get_flashed_messages() %} +
{{ msg }}
+ {% endfor %} + {% block content %}{% endblock %} +
+
    +
  • brightness_3brightness_5
  • +
  • home
  • +
  • search
  • +
  • shuffle
  • +
  • create
  • +
+ + + {% block scripts %}{% endblock %} + + diff --git a/templates/easter.html b/templates/easter.html new file mode 100644 index 0000000..d095e28 --- /dev/null +++ b/templates/easter.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %}{{ T('easter-date-calc') }} - {{ app_name }}{% endblock %} + +{% block content %} +

{{ T('easter-date-calc') }}

+ +
+
+ + + +
+
+ +{% if easter_dates %} +
+

{{ T('easter') }}: {{ easter_dates['easter'].strftime('%B %-d, %Y') }}

+ +

{{ T('other-dates') }}

+
    +
  • Mercoledì delle Ceneri: {{ easter_dates['ceneri'].strftime('%B %-d, %Y') }}
  • +
  • Ascensione: {{ easter_dates['ascensione'].strftime('%B %-d, %Y') }}
  • +
  • Pentecoste: {{ easter_dates['pentecoste'].strftime('%B %-d, %Y') }}
  • +
  • Prima Domenica d'Avvento: {{ easter_dates['avvento1'].strftime('%B %-d, %Y') }}
  • +
+
+{% endif %} +{% endblock %} diff --git a/templates/edit.html b/templates/edit.html new file mode 100644 index 0000000..d26d458 --- /dev/null +++ b/templates/edit.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block title %}Edit note - {{ app_name }}{% endblock %} + +{% block content %} + +{% if preview %} +

{{ pl_title }} (preview)

+ +
+
+ Remember this is only a preview. + Your changes were not saved yet! + Jump to editing area
+
{{ preview|safe }}
+
+
+{% endif %} + +
+
+ + +
+
+ +
+
+
+

This editor is using Markdown for text formatting (e.g. bold, italic, headers and tables). More info on Markdown.

+
+
+ +
+
+ + +
+
+ + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/exportpages.html b/templates/exportpages.html new file mode 100644 index 0000000..5ff852f --- /dev/null +++ b/templates/exportpages.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block title %}Export pages - {{ app_name }}{% endblock %} + +{% block content %} +

Export pages

+ +

You can export how many pages you want, that will be downloaded in JSON format and can be imported in another {{ app_name }} instance.

+ +

In order to add page to export list, please enter exact title, /url, #tag or +id. Entering a tag will add all pages with that tag to list. Each page or tag is separated by a newline.

+ +
+
+ +
+
+ +
+
+{% endblock %} diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..90c37d0 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Page history - {{ app_name }}{% endblock %} + +{% block content %} +

Page history for "{{ p.title }}"

+ + +{% endblock %} diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..8dfae20 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}{{ T('homepage') }} - {{ app_name }}{% endblock %} + +{% block content %} +

{{ T('welcome').format(app_name) }}

+ +

{{ T('latest-notes') }}

+ + + +

{{ T('latest-uploads') }}

+ +{{ gallery|safe }} +{% endblock %} diff --git a/templates/includes/nl_item.html b/templates/includes/nl_item.html new file mode 100644 index 0000000..a9f01c6 --- /dev/null +++ b/templates/includes/nl_item.html @@ -0,0 +1,14 @@ +

+ {{ n.title }} +

+

{{ n.short_desc() }}

+

Tags: + {% for tag in n.tags %} + {% set tn = tag.name %} + {% if hl_tag_name and tn == hl_tag_name %} + #{{ tn }} + {% else %} + #{{ tn }} + {% endif %} + {% endfor %} +

diff --git a/templates/listrecent.html b/templates/listrecent.html new file mode 100644 index 0000000..f8b7188 --- /dev/null +++ b/templates/listrecent.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +

Notes by date

+ +

Showing results {{ page_n * 20 - 19 }} to {{ min(page_n * 20, total_count) }} of {{ total_count }} total.

+ + +{% endblock %} diff --git a/templates/listtag.html b/templates/listtag.html new file mode 100644 index 0000000..5742b86 --- /dev/null +++ b/templates/listtag.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Notes tagged #{{ tagname }} - {{ app_name }}{% endblock %} + +{% block content %} +

{{ T('notes-tagged') }} #{{ tagname }}

+ +{% if total_count > 0 %} +

Showing results {{ page_n * 20 - 19 }} to {{ min(page_n * 20, total_count) }} of {{ total_count }} total.

+ +
    + {% if page_n > 1 %} +
  • « Previous page
  • + {% endif %} + {% for n in tagged_notes %} +
  • + {{ n.title }} +

    {{ n.short_desc() }}

    +

    Tags: + {% for tag in n.tags %} + {% set tn = tag.name %} + {% if tn == tagname %} + #{{ tn }} + {% else %} + #{{ tn }} + {% endif %} + {% endfor %} +

    +
  • + {% endfor %} + {% if page_n < total_count // 20 %} +
  • Next page »
  • + {% endif %} +
+{% else %} +

{{ T('notes-tagged-empty') }}

+{% endif %} + + +{% endblock %} diff --git a/templates/notfound.html b/templates/notfound.html new file mode 100644 index 0000000..5065bd8 --- /dev/null +++ b/templates/notfound.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block title %}Not found - {{ app_name }}{% endblock %} + +{% block content %} +

Not Found

+ +

The url at {{ request.path }} does not exist.

+{% endblock %} diff --git a/templates/search.html b/templates/search.html new file mode 100644 index 0000000..de79755 --- /dev/null +++ b/templates/search.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}{% if q %}Search results for "{{ q }}"{% else %}Search{% endif %} - {{ app_name }}{% endblock %} + +{% block content %} +

Search

+ +
+
+ + +
+
+ + +
+
+ +{% if results %} +

Search results for {{ q }}

+ +
    + {% for n in results %} +
  • {% include "includes/nl_item.html" %}
  • + {% endfor %} +
+{% elif q %} +

{{ T('search-no-results') }} {{ q }}

+{% endif %} + +{% endblock %} diff --git a/templates/stats.html b/templates/stats.html new file mode 100644 index 0000000..34ca3ae --- /dev/null +++ b/templates/stats.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Statistics - {{ app_name }}{% endblock %} + +{% block content %} +

Statistics

+ +
    +
  • Number of pages: {{ notes_count }}
  • +
  • Number of pages with URL set: {{ notes_with_url }}
  • +
  • Number of uploads: {{ upload_count }}
  • +
  • Number of revisions: {{ revision_count }}
  • +
  • Average revisions per page: {{ (revision_count / notes_count)|round(2) }}
  • +
+{% endblock %} diff --git a/templates/upload.html b/templates/upload.html new file mode 100644 index 0000000..153c6f8 --- /dev/null +++ b/templates/upload.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} +

Upload new file

+ +

Types supported: .jpeg, .jpg, .png.

+ +
+
+ + +
+
+ + +
+
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/uploadinfo.html b/templates/uploadinfo.html new file mode 100644 index 0000000..6eec1fa --- /dev/null +++ b/templates/uploadinfo.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Info on file "{{ upl.name }}" - {{ app_name }}{% endblock %} + +{% block content %} +

Info on file "{{ upl.name }}"

+ +
+ {{ upl.name }} +
+ +

You can include this file in other pages with {{ '{{' }}media:{{ upl.id }}{{ '}}' }}.

+ +

File info

+ +

Type: {{ type_list[upl.filetype] }}

+ +

Upload ID: {{ upl.id }}

+ +

Uploaded on: {{ upl.upload_date.strftime('%B %-d, %Y %H:%M:%S') }}

+ +

Size: {{ upl.filesize }} bytes

+{% endblock %} diff --git a/templates/view.html b/templates/view.html new file mode 100644 index 0000000..de27e83 --- /dev/null +++ b/templates/view.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}{{ p.title }} - {{ app_name }}{% endblock %} + +{% block content %} +

{{ p.title }}

+ + + +{% block history_nav %}{% endblock %} + +
+ {{ rev.html()|safe }} +
+ +{% if p.tags %} +
+

{{ T('tags') }}:

+
    + {% for tag in p.tags %} +
  • #{{ tag.name }} ({{ tag.popularity() }})
  • + {% endfor %} +
+
+{% endif %} +{% endblock %} + +{% block actions %} +{{ T('action-edit') }} - +{{ T('action-history') }} - +{{ T('last-changed') }} - +{{ T('page-id') }}: {{ p.id }} +{% endblock %} diff --git a/templates/viewold.html b/templates/viewold.html new file mode 100644 index 0000000..4699743 --- /dev/null +++ b/templates/viewold.html @@ -0,0 +1,9 @@ +{% extends "view.html" %} + +{% block history_nav %} +
+

{{ T('old-revision-notice') }} + + (ID #{{ rev.id }}). Show latest

+
+{% endblock %}