initial commit (it has come late tho 🙁)
This commit is contained in:
commit
c2bf966dac
27 changed files with 1618 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# application content
|
||||||
|
media/
|
||||||
|
**.sqlite
|
||||||
|
database/
|
||||||
|
|
||||||
|
# automatically generated garbage
|
||||||
|
**/__pycache__/
|
||||||
|
**.pyc
|
||||||
|
**~
|
||||||
|
**/.\#*
|
||||||
|
**/\#*\#
|
||||||
|
ig_api_settings/
|
||||||
19
LICENSE
Normal file
19
LICENSE
Normal 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
32
README.md
Normal 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
686
app.py
Normal 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 '*« 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())
|
||||||
|
|
||||||
70
extensions/importexport.py
Normal file
70
extensions/importexport.py
Normal 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
173
extensions/instagram.py
Normal 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
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 318 B |
4
robots.txt
Normal file
4
robots.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
User-Agent: *
|
||||||
|
Noindex: /edit/
|
||||||
|
Noindex: /history/
|
||||||
|
Disallow: /accounts/
|
||||||
69
static/edit.js
Normal file
69
static/edit.js
Normal 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"> </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? ' ' : '(*)';
|
||||||
|
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
104
static/style.css
Normal 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
22
strings.csv
Normal 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
|
||||||
|
34
templates/base.html
Normal file
34
templates/base.html
Normal 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">© 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
29
templates/easter.html
Normal 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
48
templates/edit.html
Normal 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 %}
|
||||||
20
templates/exportpages.html
Normal file
20
templates/exportpages.html
Normal 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
17
templates/history.html
Normal 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 }}
|
||||||
|
·
|
||||||
|
{{ rev.pub_date.strftime("%B %-d, %Y %H:%M:%S") }}
|
||||||
|
</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
35
templates/home.html
Normal file
35
templates/home.html
Normal 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 %}
|
||||||
14
templates/includes/nl_item.html
Normal file
14
templates/includes/nl_item.html
Normal 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
27
templates/listrecent.html
Normal 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 }}">« 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 »</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
40
templates/listtag.html
Normal file
40
templates/listtag.html
Normal 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 }}/">« 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 »</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="nl-placeholder">{{ T('notes-tagged-empty') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
9
templates/notfound.html
Normal file
9
templates/notfound.html
Normal 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
31
templates/search.html
Normal 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
15
templates/stats.html
Normal 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
43
templates/upload.html
Normal 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
23
templates/uploadinfo.html
Normal 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
33
templates/view.html
Normal 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
9
templates/viewold.html
Normal 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 %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue