initial commit (it has come late tho 🙁)

This commit is contained in:
Yusur 2021-02-23 22:54:08 +01:00
commit c2bf966dac
27 changed files with 1618 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# application content
media/
**.sqlite
database/
# automatically generated garbage
**/__pycache__/
**.pyc
**~
**/.\#*
**/\#*\#
ig_api_settings/

19
LICENSE Normal file
View file

@ -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.

32
README.md Normal file
View file

@ -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).

686
app.py Normal file
View file

@ -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}</{0}>'.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 '*&laquo; 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 '<figure class="{0}"><a href="/upload-info/{4}"><img alt="{1}" src="{2}" title="{1}"></a>{3}</figure>'.format(
classname, html.escape(upload.name), upload.url,
'<figcaption>{0}</figcaption>'.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('<figure class="fig-gallery"><a href="/upload-info/{0}"><img alt="{1}" src="{2}" title="{1}"></a>{3}</figure>'.format(
upload.id, html.escape(upload.name), upload.url,
'<figcaption>{0}</figcaption>'.format(md(desc, expand_magic=False)) if desc else ''))
return '<div class="gallery">' + ''.join(result) + '</div>'
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/<int:id>/', 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/<int:id>/')
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/<int:page>/')
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('/<slug:name>/')
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/<int:id>/')
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/<int:revisionid>/')
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/<slug:tag>/')
@app.route('/tags/<slug:tag>/<int:page>/')
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/<path:fp>')
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/<int:id>/')
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/<int:y>/')
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())

View file

@ -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')

173
extensions/instagram.py Normal file
View file

@ -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()

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

4
robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-Agent: *
Noindex: /edit/
Noindex: /history/
Disallow: /accounts/

69
static/edit.js Normal file
View file

@ -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 = [
'<span class="oti-modified">&nbsp;</span>',
'<span class="oti-charcount">? chars</span>',
'<span class="oti-fontselect"><select><option value="sans">Sans-serif</option><option value="serif">Serif</option><option value="monospace">Monospace</option></select></span>',
//'<span class="oti-linkpage">Link page</span>',
].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? '&nbsp;' : '(*)';
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'));
})();

104
static/style.css Normal file
View file

@ -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}

22
strings.csv Normal file
View file

@ -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
1 welcome Welcome to {0}! Benvenuti in {0}!
2 homepage Homepage Pagina iniziale
3 latest-notes Latest notes Note più recenti
4 latest-uploads Latest uploads Caricamenti più recenti
5 new-note New note Crea nota
6 upload-file Upload file Carica immagine
7 easter-date-calc Easter date calculation Calcolo della data di Pasqua
8 easter Easter Pasqua
9 other-dates Other dates Altre date
10 jump-to-actions Jump to actions Salta alle azioni
11 last-changed Last changed Ultima modifica
12 page-id Page ID ID pagina
13 action-edit Edit Modifica
14 action-history History Cronologia
15 tags Tags Etichette
16 old-revision-notice Showing an old revision of the page as of È mostrata una revisione vecchia della pagina, risalente al
17 notes-tagged Notes tagged Note con etichetta
18 include-tags Include tags Includi etichette
19 notes-tagged-empty None found :( Non c’è nulla :(
20 search-no-results No results for Nessun risultato per
21 random-page Random page Pagina casuale
22 search Search Cerca

34
templates/base.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
{% set app_name = 'Salvi' %}
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/style.css">
<!-- material icons -->
<link rel="stylesheet" href="https://cdn.sakuragasaki46.local/common/material-icons.css">
</head>
<body>
<div id="__top"></div>
<div class="content">
{% for msg in get_flashed_messages() %}
<div class="flash">{{ msg }}</div>
{% endfor %}
{% block content %}{% endblock %}
</div>
<ul class="top-menu">
<li class="dark-theme-toggle-anchor"><a href="javascript:toggleDarkTheme(true)" class="dark-theme-toggle-on" title="Dark theme"><span class="material-icons">brightness_3</span></a><a href="javascript:toggleDarkTheme(false)" class="dark-theme-toggle-off" title="Light theme"><span class="material-icons">brightness_5</span></a><script>function toggleDarkTheme(a){document.cookie="dark="+(+a)+";max-age=31556952;path=/";document.body.classList.toggle("dark",a)}if(document.cookie.match(/\bdark=1\b/)){document.body.classList.add("dark")}</script></li>
<li><a href="/" title="{{ T('homepage') }}"><span class="material-icons">home</span></a></li>
<li><a href="/search/" title="{{ T('search') }}"><span class="material-icons">search</span></a></li>
<li><a href="/p/random/" title="{{ T('random-page') }}"><span class="material-icons">shuffle</span></a></li>
<li><a href="/create/" title="{{ T('new-note') }}"><span class="material-icons">create</span></a></li>
</ul>
<div class="footer">
<div class="footer-copyright">&copy; 2020 Sakuragasaki46.</div>
<div class="footer-actions" id="page-actions">{% block actions %}{% endblock %}</div>
</div>
<div class="backontop"><a href="#__top" title="Back on top"><span class="material-icons">arrow_upward</span></a></div>
{% block scripts %}{% endblock %}
</body>
</html>

29
templates/easter.html Normal file
View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}{{ T('easter-date-calc') }} - {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ T('easter-date-calc') }}</h1>
<form>
<div>
<label for="y">Year: </label>
<input type="text" name="y" value="{{ y }}">
<input type="submit" value="Calculate">
</div>
</form>
{% if easter_dates %}
<div class="easter-results">
<p class="easter-date">{{ T('easter') }}: <strong>{{ easter_dates['easter'].strftime('%B %-d, %Y') }}</strong></p>
<h2>{{ T('other-dates') }}</h2>
<ul>
<li>Mercoledì delle Ceneri: <strong>{{ easter_dates['ceneri'].strftime('%B %-d, %Y') }}</strong></li>
<li>Ascensione: <strong>{{ easter_dates['ascensione'].strftime('%B %-d, %Y') }}</strong></li>
<li>Pentecoste: <strong>{{ easter_dates['pentecoste'].strftime('%B %-d, %Y') }}</strong></li>
<li>Prima Domenica d'Avvento: <strong>{{ easter_dates['avvento1'].strftime('%B %-d, %Y') }}</strong></li>
</ul>
</div>
{% endif %}
{% endblock %}

48
templates/edit.html Normal file
View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Edit note - {{ app_name }}{% endblock %}
{% block content %}
{% if preview %}
<h1>{{ pl_title }} (preview)</h1>
<div class="preview-area">
<div class="preview-warning">
Remember this is only a preview.
<strong>Your changes were not saved yet!</strong>
<a href="#editing-area">Jump to editing area</a></div>
<div class="inner-content">{{ preview|safe }}</div>
<div style="clear:both"></div>
</div>
{% endif %}
<form method="POST">
<div>
<label for="title">URL: </label>
<input type="text" name="url" class="url-input" placeholder="No URL" maxlength="64" value="{{ pl_url or '' }}">
</div>
<div>
<input type="text" required name="title" placeholder="Title (required)" class="title-input" maxlength="256" value="{{ pl_title }}">
</div>
<div id="editing-area">
<div class="pre-text-input">
<p>This editor is using Markdown for text formatting (e.g. bold, italic, headers and tables). <a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" rel="nofollow">More info on Markdown</a>.</p>
</div>
<div class="over-text-input"></div>
<textarea name="text" class="text-input">{{ pl_text }}</textarea>
</div>
<div>
<label for="tags">Tags (comma separated):</label>
<input type="text" name="tags" class="tags-input" placeholder="No tags" value="{{ pl_tags }}">
</div>
<div>
<input type="submit" value="Save" id="save-button" class="submit-primary">
<input type="submit" name="preview" value="Preview" id="preview-button" class="submit-secondary">
</div>
</form>
{% endblock %}
{% block scripts %}
<script src="/static/edit.js"></script>
{% endblock %}

View file

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Export pages - {{ app_name }}{% endblock %}
{% block content %}
<h1>Export pages</h1>
<p>You can export how many pages you want, that will be downloaded in JSON format and can be imported in another {{ app_name }} instance.</p>
<p>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.</p>
<form method="POST">
<div>
<textarea style="height:20em;width:100%" name="export-list" placeholder="Title, /url, #tag, or +id, newline-separated"></textarea>
</div>
<div>
<input type="submit" value="Download">
</div>
</form>
{% endblock %}

17
templates/history.html Normal file
View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Page history - {{ app_name }}{% endblock %}
{% block content %}
<h1>Page history for "{{ p.title }}"</h1>
<ul>
{% for rev in history %}
<li><a href="/history/revision/{{ rev.id }}/">
#{{ rev.id }}
&middot;
{{ rev.pub_date.strftime("%B %-d, %Y %H:%M:%S") }}
</a></li>
{% endfor %}
</ul>
{% endblock %}

35
templates/home.html Normal file
View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}{{ T('homepage') }} - {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ T('welcome').format(app_name) }}</h1>
<h2>{{ T('latest-notes') }}</h2>
<ul class="nl-list">
<li class="nl-new">
<a href="/create/"><button class="submit-primary">{{ T('new-note') }}</button></a>
<a href="/upload/"><button class="submit-secondary">{{ T('upload-file') }}</button></a>
</li>
{% for n in new_notes %}
<li>
<a href="{{ n.get_url() }}" class="nl-title">{{ n.title }}</a>
<p class="nl-desc">{{ n.short_desc() }}</p>
{% if n.tags %}
<p class="nl-tags">{{ T('tags') }}:
{% for tag in n.tags %}
{% set tn = tag.name %}
<a href="/tags/{{ tn }}/" class="nl-tag">#{{ tn }}</a>
{% endfor %}
</p>
{% endif %}
</li>
{% endfor %}
<li><a href="/p/most_recent/">Show all</a></li>
</ul>
<h2>{{ T('latest-uploads') }}</h2>
{{ gallery|safe }}
{% endblock %}

View file

@ -0,0 +1,14 @@
<p class="nl-title">
<a href="{{ n.get_url() }}" class="nl-title">{{ n.title }}</a>
</p>
<p class="nl-desc">{{ n.short_desc() }}</p>
<p class="nl-tags">Tags:
{% for tag in n.tags %}
{% set tn = tag.name %}
{% if hl_tag_name and tn == hl_tag_name %}
<strong class="nl-tag-hl">#{{ tn }}</strong>
{% else %}
<a href="/tags/{{ tn }}/" class="nl-tag">#{{ tn }}</a>
{% endif %}
{% endfor %}
</p>

27
templates/listrecent.html Normal file
View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<h1>Notes by date</h1>
<p class="nl-pagination">Showing results <strong>{{ page_n * 20 - 19 }}</strong> to <strong>{{ min(page_n * 20, total_count) }}</strong> of <strong>{{ total_count }}</strong> total.</p>
<ul class="nl-list">
{% if page_n > 1 %}
<li class="nl-prev"><a href="/p/most_recent/{{ page_n - 1 }}">&laquo; Previous page</a></li>
{% endif %}
{% for n in notes %}
<li>
<a href="{{ n.get_url() }}" class="nl-title">{{ n.title }}</a>
<p class="nl-desc">{{ n.short_desc() }}</p>
<p class="nl-tags">Tags:
{% for tag in n.tags %}
<a href="/tags/{{ tag.name }}/" class="nl-tag">#{{ tag.name }}</a>
{% endfor %}
</p>
</li>
{% endfor %}
{% if page_n <= total_count // 20 %}
<li class="nl-next"><a href="/p/most_recent/{{ page_n + 1 }}/">Next page &raquo;</a></li>
{% endif %}
</ul>
{% endblock %}

40
templates/listtag.html Normal file
View file

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Notes tagged #{{ tagname }} - {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ T('notes-tagged') }} #{{ tagname }}</h1>
{% if total_count > 0 %}
<p class="nl-pagination">Showing results <strong>{{ page_n * 20 - 19 }}</strong> to <strong>{{ min(page_n * 20, total_count) }}</strong> of <strong>{{ total_count }}</strong> total.</p>
<ul class="nl-list">
{% if page_n > 1 %}
<li class="nl-prev"><a href="/tags/{{ tagname }}/{{ page_n - 1 }}/">&laquo; Previous page</a></li>
{% endif %}
{% for n in tagged_notes %}
<li>
<a href="{{ n.get_url() }}" class="nl-title">{{ n.title }}</a>
<p class="nl-desc">{{ n.short_desc() }}</p>
<p class="nl-tags">Tags:
{% for tag in n.tags %}
{% set tn = tag.name %}
{% if tn == tagname %}
<strong class="nl-tag-hl">#{{ tn }}</strong>
{% else %}
<a href="/tags/{{ tn }}/" class="nl-tag">#{{ tn }}</a>
{% endif %}
{% endfor %}
</p>
</li>
{% endfor %}
{% if page_n < total_count // 20 %}
<li class="nl-next"><a href="/tags/{{ tagname }}/{{ page_n + 1 }}/">Next page &raquo;</a></li>
{% endif %}
</ul>
{% else %}
<p class="nl-placeholder">{{ T('notes-tagged-empty') }}</p>
{% endif %}
{% endblock %}

9
templates/notfound.html Normal file
View file

@ -0,0 +1,9 @@
{% extends "base.html" %}
{% block title %}Not found - {{ app_name }}{% endblock %}
{% block content %}
<h1>Not Found</h1>
<p>The url at <strong>{{ request.path }}</strong> does not exist.</p>
{% endblock %}

31
templates/search.html Normal file
View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{% if q %}Search results for "{{ q }}"{% else %}Search{% endif %} - {{ app_name }}{% endblock %}
{% block content %}
<h1>Search</h1>
<form method="POST">
<div>
<label for="q">Search for: </label>
<input type="search" name="q" value="{{ q }}" class="search-input">
</div>
<div>
<input type="checkbox" name="include-tags" value="1" {% if pl_include_tags %}checked{% endif %}>
<label for="include-tags">{{ T('include-tags') }}</label>
</div>
</form>
{% if results %}
<h2>Search results for <em>{{ q }}</em></h2>
<ul class="nl-list">
{% for n in results %}
<li>{% include "includes/nl_item.html" %}</li>
{% endfor %}
</ul>
{% elif q %}
<h2>{{ T('search-no-results') }} <em>{{ q }}</em></h2>
{% endif %}
{% endblock %}

15
templates/stats.html Normal file
View file

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Statistics - {{ app_name }}{% endblock %}
{% block content %}
<h1>Statistics</h1>
<ul>
<li>Number of pages: <strong>{{ notes_count }}</strong></li>
<li>Number of pages with URL set: <strong>{{ notes_with_url }}</strong></li>
<li>Number of uploads: <strong>{{ upload_count }}</strong></li>
<li>Number of revisions: <strong>{{ revision_count }}</strong></li>
<li>Average revisions per page: <strong>{{ (revision_count / notes_count)|round(2) }}</strong></li>
</ul>
{% endblock %}

43
templates/upload.html Normal file
View file

@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block content %}
<h1>Upload new file</h1>
<p>Types supported: <strong>.jpeg</strong>, <strong>.jpg</strong>, <strong>.png</strong>.</p>
<form method="POST" enctype="multipart/form-data">
<div>
<label for="name">Name: </label>
<input type="text" id="name-input" name="name" required maxlength="256">
</div>
<div>
<label for="file">File: </label>
<input type="file" id="file-input" name="file" accept="image/jpeg, image/png">
</div>
<div>
<input type="submit" class="submit-primary" value="Upload">
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
(function(){
function last(a){
return a[a.length-1];
}
var fileInput = document.getElementById('file-input');
var nameInput = document.getElementById('name-input');
fileInput.onchange = function(){
var name = last(fileInput.value.split(/[\/\\]/));
if(name.indexOf('.') >= 0){
name = name.replace(/\..*$/, '');
}
nameInput.value = name;
// TODO: add image preview
}
})();
</script>
{% endblock %}

23
templates/uploadinfo.html Normal file
View file

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Info on file "{{ upl.name }}" - {{ app_name }}{% endblock %}
{% block content %}
<h1>Info on file "{{ upl.name }}"</h1>
<div class="upload-frame">
<img src="{{ upl.url }}" alt="{{ upl.name }}">
</div>
<p>You can include this file in other pages with <strong>{{ '{{' }}media:{{ upl.id }}{{ '}}' }}</strong>.</p>
<h2>File info</h2>
<p>Type: {{ type_list[upl.filetype] }}</p>
<p>Upload ID: {{ upl.id }}</p>
<p>Uploaded on: {{ upl.upload_date.strftime('%B %-d, %Y %H:%M:%S') }}</p>
<p>Size: {{ upl.filesize }} bytes</p>
{% endblock %}

33
templates/view.html Normal file
View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}{{ p.title }} - {{ app_name }}{% endblock %}
{% block content %}
<h1>{{ p.title }}</h1>
<div class="jump-to-actions"><a href="#page-actions">{{ T('jump-to-actions') }}</a></div>
{% block history_nav %}{% endblock %}
<div class="inner-content">
{{ rev.html()|safe }}
</div>
{% if p.tags %}
<div class="page-tags">
<p>{{ T('tags') }}:</p>
<ul>
{% for tag in p.tags %}
<li><a href="/tags/{{ tag.name }}/">#{{ tag.name }}</a> <span class="tag-count">({{ tag.popularity() }})</span></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% endblock %}
{% block actions %}
<a href="/edit/{{ p.id }}/">{{ T('action-edit') }}</a> -
<a href="/history/{{ p.id }}/">{{ T('action-history') }}</a> -
{{ T('last-changed') }} <time datetime="{{ rev.pub_date.isoformat() }}">{{ rev.pub_date.strftime('%B %-d, %Y at %H:%M:%S') }}</time> -
{{ T('page-id') }}: {{ p.id }}
{% endblock %}

9
templates/viewold.html Normal file
View file

@ -0,0 +1,9 @@
{% extends "view.html" %}
{% block history_nav %}
<div class="history-nav">
<p>{{ T('old-revision-notice') }}
<time datetime="{{ rev.pub_date.isoformat() }}">{{ rev.pub_date.strftime('%B %-d, %Y at %H:%M:%S') }}</time>
(ID #{{ rev.id }}). <a href="{{ p.get_url() }}">Show latest</a></p>
</div>
{% endblock %}