v4.0a
@@ -1,104 +1,5 @@
-# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -env/ -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# dotenv +__pycache__ +.venv +_scratch .env -# virtualenv -.venv -venv/ -ENV/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -*.ini -tmp.py -tmp/
@@ -1,185 +1,6 @@
-# NASG (Not Another Static Generator...) -This is a tiny static site generator, written in Python, to scratch my own itches. -It is most probably not suitable for anyone else, but feel free to use it for ideas. Keep in mind that the project is licenced under GPL. - -## Why not [insert static generator here]? - -- I'm using embedded XMP metadata in photos, which most of the ones availabe don't handle well; -- writing plugins to existing generators - Pelican, Nicola, etc - might have taken longer and I wanted to extend my Python knowledge -- I wanted to use the best available utilities for some tasks, like `Pandoc` and `exiftool` instead of Python libraries trying to achive the same -- I needed to handle webmentions and comments - -Don't expect anything fancy: my Python Fu has much to learn. -## Install - -### External dependencies - -PHP is in order to use [XRay](https://github.com/aaronpk/XRay/). Besides that, the rest is for `pandoc` and `exiftool`. - -``` -apt-get install pandoc exiftool php7.0-bcmath php7.0-bz2 php7.0-cli php7.0-common php7.0-curl php7.0-gd php7.0-imap php7.0-intl php7.0-json php7.0-mbstring php7.0-mcrypt php7.0-mysql php7.0-odbc php7.0-opcache php7.0-readline php7.0-sqlite3 php7.0-xml php7.0-zip python3 python3-pip python3-dev -``` - -Get XRay: -``` -mkdir /usr/local/lib/php -cd /usr/local/lib/php -wget https://github.com/aaronpk/XRay/releases/download/v1.3.1/xray-app.zip -unzip xray-app.zip -rm xray-app.zip -``` - -## How content is organized - -The directory structure of the "source" is something like this: -``` -├── content -│ ├── category1 (containing YAML + MD files) -│ ├── category2 (containing YAML + MD files) -│ ├── photo (containing jpg files) -│ ├── _category_excluded_from_listing_1 (containing YAML + MD files) - -├── files -│ ├── image (my own pictures) -│ ├── photo -> ../content/photo -│ └── pic (random images) -├── nasg -│ ├── archive.py -│ ├── config.ini -│ ├── db.py -│ ├── LICENSE -│ ├── nasg.py -│ ├── README.md -│ ├── requirements.txt -│ ├── router.py -│ ├── shared.py -│ └── templates -├── static -│ ├── favicon.ico -│ ├── favicon.png -│ └── pgp.asc -└── var - ├── gone.tsv - ├── redirects.tsv - ├── s.sqlite - ├── tokens.json - └── webmention.sqlite -``` - -Content files can be in either YAML and Markdown, with `.md` extension, or JPG with metadata, with `.jpg` extension. - -Inline images in the content are checked against all subdirectories in `files` ; they get their EXIF read and displayed as well if they match the regex in the configuration for the Artist and/or Copyright EXIF fields. - -`gone.tsv` is a simple list of URIs that should return a `410 Gone` message while `redirect.tsv` is a tab separated file of `from to` entries that should be `301` redirected. These go into a magic.php file, so if the host supports executing PHP, it will take care of this. - -## Output - -`nasg.py` generates a `build` directory which will have an directory per entry, with an `index.html`, so urls can be `https://domain.com/filename/`. - -Categories are rendered into `category/category_name`. Pagination is under `category/category_name/page/X`. They include a feed as well, `category/category_name/feed`, in form if an `index.atom` ATOM feed. - -## Webserver configuration - -A minimal nginx configuration for the virtualhost: -``` -# --- Virtual Host --- -upstream {{ domain }} { - server unix:/var/run/php/{{ domain }}.sock; -} - -server { - listen 80; - server_name .{{ domain }}; - rewrite ^ https://$server_name$request_uri redirect; - access_log /dev/null; - error_log /dev/null; -} - -server { - listen 443 ssl http2; - server_name .{{ domain }}; - ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem; - ssl_dhparam dh.pem; - add_header X-Frame-Options "SAMEORIGIN"; - add_header X-Content-Type-Options "nosniff"; - add_header X-XSS-Protection "1; mode=block"; - add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;"; - - root /[path to root]/{{ domain }}; - - location = /favicon.ico { - log_not_found off; - access_log off; - } - - location = /robots.txt { - log_not_found off; - access_log off; - } - - location ~ ^(?<script_name>.+?\.php)(?<path_info>.*)$ { - try_files $uri $script_name =404; - fastcgi_param SCRIPT_FILENAME $document_root$script_name; - fastcgi_param SCRIPT_NAME $script_name; - fastcgi_param PATH_INFO $path_info; - fastcgi_param PATH_TRANSLATED $document_root$path_info; - fastcgi_param QUERY_STRING $query_string; - fastcgi_param REQUEST_METHOD $request_method; - fastcgi_param CONTENT_TYPE $content_type; - fastcgi_param CONTENT_LENGTH $content_length; - fastcgi_param SCRIPT_NAME $script_name; - fastcgi_param REQUEST_URI $request_uri; - fastcgi_param DOCUMENT_URI $document_uri; - fastcgi_param DOCUMENT_ROOT $document_root; - fastcgi_param SERVER_PROTOCOL $server_protocol; - fastcgi_param GATEWAY_INTERFACE CGI/1.1; - fastcgi_param SERVER_SOFTWARE nginx; - fastcgi_param REMOTE_ADDR $remote_addr; - fastcgi_param REMOTE_PORT $remote_port; - fastcgi_param SERVER_ADDR $server_addr; - fastcgi_param SERVER_PORT $server_port; - fastcgi_param SERVER_NAME $server_name; - fastcgi_param HTTP_PROXY ""; - fastcgi_param HTTPS $https if_not_empty; - fastcgi_param SSL_PROTOCOL $ssl_protocol if_not_empty; - fastcgi_param SSL_CIPHER $ssl_cipher if_not_empty; - fastcgi_param SSL_SESSION_ID $ssl_session_id if_not_empty; - fastcgi_param SSL_CLIENT_VERIFY $ssl_client_verify if_not_empty; - fastcgi_param REDIRECT_STATUS 200; - fastcgi_index index.php; - fastcgi_connect_timeout 10; - fastcgi_send_timeout 360; - fastcgi_read_timeout 3600; - fastcgi_buffer_size 512k; - fastcgi_buffers 512 512k; - fastcgi_keep_conn on; - fastcgi_intercept_errors on; - fastcgi_split_path_info ^(?<script_name>.+?\.php)(?<path_info>.*)$; - fastcgi_pass {{ domain }}; - } - - location / { - try_files $uri $uri/ $uri.html $uri/index.html $uri/index.xml $uri/index.atom index.php @rewrites; - } - - location @rewrites { - rewrite ^ /magic.php?$args last; - } - - location ~* \.(css|js|eot|woff|ttf|woff2)$ { - expires 1d; - add_header Cache-Control "public, must-revalidate, proxy-revalidate"; - add_header "Vary" "Accept-Encoding"; - } - - location ~* \.(png|ico|gif|svg|jpg|jpeg|webp|avi|mpg|mpeg|mp4|mp3)$ { - expires 7d; - add_header Cache-Control "public, must-revalidate, proxy-revalidate"; - add_header "Vary" "Accept-Encoding"; - } -} - -``` +magics: +- automatic exif .json "cache" gets generated next to the source file + - updated if the cache timestamp < source file timestamp +- on resize, forcing resize if resized timestamp < source timestamp
@@ -1,939 +0,0 @@
-#!/usr/bin/env python3 - -__author__ = "Peter Molnar" -__copyright__ = "Copyright 2017-2018, Peter Molnar" -__license__ = "GNU LGPLv3 " -__maintainer__ = "Peter Molnar" -__email__ = "mail@petermolnar.net" - -import os -import json -import requests -import glob -import logging -import shutil -import subprocess -import imghdr -import arrow -import csv -import re - -from requests_oauthlib import OAuth1Session -from requests_oauthlib import oauth1_session -from requests_oauthlib import OAuth2Session -from requests_oauthlib import oauth2_session -from oauthlib.oauth2 import BackendApplicationClient - -import shared - - -class LastFM(object): - url = 'http://ws.audioscrobbler.com/2.0/' - - def __init__(self): - self.service = 'lastfm' - self.target = shared.config.get("api_%s" % self.service, 'logfile') - self.params = { - 'method': 'user.getrecenttracks', - 'user': shared.config.get("api_%s" % self.service, 'username'), - 'api_key': shared.config.get("api_%s" % self.service, 'api_key'), - 'format': 'json', - 'limit': '200' - } - if os.path.isfile(self.target): - mtime = os.path.getmtime(self.target) - self.params.update({'from': mtime}) - - def hash2flat(self, data): - time = int(data.get('date').get('uts')) - r = { - 'date': arrow.get(time).format(shared.ARROWFORMAT['iso']), - 'artist': data.get('artist').get('#text'), - 'album': data.get('album').get('#text'), - 'title': data.get('name') - # 'title_mbid': data.get('mbid'), - # 'artist_mbid': data.get('artist').get('mbid'), - # 'album_mbid': data.get('album').get('mbid'), - } - return (time, r) - - def getpaged(self, pagenum): - logging.info('requesting page #%d of paginated results', pagenum) - self.params.update({ - 'page': pagenum - }) - r = requests.get( - self.url, - params=self.params - ) - parsed = json.loads(r.text).get('recenttracks', {}).get('track', []) - return parsed - - def run(self): - r = requests.get(self.url, params=self.params) - js = json.loads(r.text) - js = js.get('recenttracks', {}) - unordered = js.get('track', []) - ordered = {} - - total = int(js.get('@attr').get('totalPages')) - current = int(js.get('@attr').get('page')) - cntr = total - current - - while cntr > 0: - current = current + 1 - paged = self.getpaged(current) - unordered = unordered + paged - cntr = total - current - - for track in unordered: - # happens with nowplaying tracks - if 'date' not in track: - continue - time, data = self.hash2flat(track) - ordered[time] = data - - # no results - if not len(ordered): - return - - ordered = sorted(ordered.items()) - with open(self.target, 'a') as f: - fieldnames = ordered[0][1].keys() - writer = csv.DictWriter(f, fieldnames=fieldnames) - # only write csv header once, when the file is first created - if 'from' not in self.params: - writer.writeheader() - for time, track in ordered: - writer.writerow(track) - - -class Favs(object): - def __init__(self, confgroup): - self.confgroup = confgroup - - @property - def lastpulled(self): - mtime = 0 - d = os.path.join( - shared.config.get('archive', 'favorite'), - "%s-*" % self.confgroup - ) - files = glob.glob(d) - for f in files: - ftime = int(os.path.getmtime(f)) - if ftime > mtime: - mtime = ftime - - mtime = mtime + 1 - logging.debug("last fav timestamp: %s", mtime) - return mtime - - -class FlickrFavs(Favs): - url = 'https://api.flickr.com/services/rest/' - - def __init__(self): - super().__init__('flickr') - self.get_uid() - self.params = { - 'method': 'flickr.favorites.getList', - 'api_key': shared.config.get('api_flickr', 'api_key'), - 'user_id': self.uid, - 'extras': ','.join([ - 'description', - 'geo', - 'tags', - 'owner_name', - 'date_upload', - 'url_o', - 'url_k', - 'url_h', - 'url_b', - 'url_c', - 'url_z', - ]), - 'per_page': 500, # maximim - 'format': 'json', - 'nojsoncallback': '1', - 'min_fave_date': self.lastpulled - } - - def get_uid(self): - params = { - 'method': 'flickr.people.findByUsername', - 'api_key': shared.config.get('api_flickr', 'api_key'), - 'format': 'json', - 'nojsoncallback': '1', - 'username': shared.config.get('api_flickr', 'username'), - } - r = requests.get( - self.url, - params=params - ) - parsed = json.loads(r.text) - self.uid = parsed.get('user', {}).get('id') - - def getpaged(self, offset): - logging.info('requesting page #%d of paginated results', offset) - self.params.update({ - 'page': offset - }) - r = requests.get( - self.url, - params=self.params - ) - parsed = json.loads(r.text) - return parsed.get('photos', {}).get('photo', []) - - def run(self): - r = requests.get(self.url, params=self.params) - js = json.loads(r.text) - js = js.get('photos', {}) - - photos = js.get('photo', []) - - total = int(js.get('pages', 1)) - current = int(js.get('page', 1)) - cntr = total - current - - while cntr > 0: - current = current + 1 - paged = self.getpaged(current) - photos = photos + paged - cntr = total - current - - for photo in photos: - fav = FlickrFav(photo) - if not fav.exists: - fav.run() - # fav.fix_extension() - - -class TumblrFavs(Favs): - url = 'https://api.tumblr.com/v2/user/likes' - - def __init__(self): - super().__init__('tumblr') - self.oauth = TumblrOauth() - self.params = { - 'after': self.lastpulled - } - self.likes = [] - - def getpaged(self, offset): - r = self.oauth.request( - self.url, - params={'offset': offset} - ) - return json.loads(r.text) - - def run(self): - r = self.oauth.request( - self.url, - params=self.params - ) - - js = json.loads(r.text) - total = int(js.get('response', {}).get('liked_count', 20)) - offset = 20 - cntr = total - offset - likes = js.get('response', {}).get('liked_posts', []) - while cntr > 0: - paged = self.getpaged(offset) - likes = likes + paged.get('response', {}).get('liked_posts', []) - offset = offset + 20 - cntr = total - offset - - self.likes = likes - for like in self.likes: - fav = TumblrFav(like) - if not fav.exists: - fav.run() - - -class DAFavs(Favs): - def __init__(self): - from pprint import pprint - super().__init__('deviantart') - self.username = shared.config.get('api_deviantart', 'username'), - self.oauth = DAOauth() - self.likes = [] - self.galid = None - self.params = { - 'limit': 24, # this is the max as far as I can tell - 'mature_content': 'true', - 'username': self.username - } - - def get_favgalid(self): - r = self.oauth.request( - 'https://www.deviantart.com/api/v1/oauth2/collections/folders', - params={ - 'username': self.username, - 'calculate_size': 'true', - 'ext_preload': 'false', - 'mature_content': 'true' - } - ) - js = json.loads(r.text) - for g in js.get('results', []): - if 'Featured' == g.get('name'): - self.galid = g.get('folderid') - break - - @property - def url(self): - return 'https://www.deviantart.com/api/v1/oauth2/collections/%s' % ( - self.galid) - - def getpaged(self, offset): - self.params.update({'offset': offset}) - r = self.oauth.request( - self.url, - self.params - ) - js = json.loads(r.text) - return js - - def getsinglemeta(self, daid): - r = self.oauth.request( - 'https://www.deviantart.com/api/v1/oauth2/deviation/metadata', - params={ - 'deviationids[]': daid, - 'ext_submission': False, - 'ext_camera': False, - 'ext_stats': False, - 'ext_collection': False, - 'mature_content': True, - } - ) - meta = {} - try: - meta = json.loads(r.text) - return meta.get('metadata', []).pop() - except BaseException: - return meta - - def has_more(self, q): - if True == q or 'True' == q or 'true' == q: - return True - return False - - def run(self): - self.get_favgalid() - - r = self.oauth.request( - self.url, - self.params - ) - - js = json.loads(r.text) - favs = js.get('results', []) - has_more = self.has_more(js.get('has_more')) - offset = js.get('next_offset') - while True == has_more: - #logging.info('iterating over DA results with offset %d', offset) - paged = self.getpaged(offset) - new = paged.get('results', []) - if not len(new): - #logging.error('empty results from deviantART, breaking loop') - break - favs = [*favs, *new] - has_more = self.has_more(paged.get('has_more')) - if not has_more: - break - n = int(paged.get('next_offset')) - if not n: - break - offset = n - - self.favs = favs - for fav in self.favs: - f = DAFav(fav) - if not f.exists: - f.fav.update( - {'meta': self.getsinglemeta(fav.get('deviationid'))}) - f.run() - # f.fix_extension() - - -class ImgFav(object): - def __init__(self): - self.imgurl = '' - self.meta = { - 'dt': arrow.utcnow(), - 'title': '', - 'favorite-of': '', - 'tags': [], - 'geo': { - 'latitude': '', - 'longitude': '', - }, - 'author': { - 'name': '', - 'url': '', - }, - } - self.content = '' - - @property - def exists(self): - maybe = glob.glob(self.target.replace('.jpg', '.*')) - if len(maybe): - return True - return False - - def fix_extension(self): - # identify file format - what = imghdr.what(self.target) - # rename file - new = self.target.replace('.jpg', '.%s' % what) - if new != self.target: - shutil.move(self.target, new) - self.target = new - - def pull_image(self): - logging.info("pulling image %s to %s", self.imgurl, self.target) - r = requests.get(self.imgurl, stream=True) - if r.status_code == 200: - with open(self.target, 'wb') as f: - r.raw.decode_content = True - shutil.copyfileobj(r.raw, f) - - def write_exif(self): - what = imghdr.what(self.target) - if 'jpg' != what or 'png' != what: - return - - logging.info('populating EXIF data of %s' % self.target) - tags = list(set(self.meta.get('tags', []))) - dt = self.meta.get('dt').to('utc') - - geo_lat = False - geo_lon = False - if self.meta.get('geo', None): - geo = self.meta.get('geo', None) - lat = geo.get('latitude', None) - lon = geo.get('longitude', None) - if lat and lon and 'null' != lat and 'null' != lon: - geo_lat = lat - geo_lon = lon - - author_name = '' - author_url = '' - if self.meta.get('author', None): - a = self.meta.get('author') - author_name = a.get('name', '') - author_url = a.get('url', '') - author_name = "%s" % author_name - author_url = "%s" % author_url - - params = [ - 'exiftool', - '-overwrite_original', - #'-EXIF:Artist=%s' % author_name[:64], - '-XMP:Copyright=Copyright %s %s (%s)' % ( - dt.format('YYYY'), - author_name, - author_url, - ), - '-XMP:Source=%s' % self.meta.get('favorite-of'), - '-XMP:ReleaseDate=%s' % dt.format('YYYY:MM:DD HH:mm:ss'), - '-XMP:Headline=%s' % self.meta.get('title'), - '-XMP:Description=%s' % self.content, - ] - for t in tags: - params.append('-XMP:HierarchicalSubject+=%s' % t) - params.append('-XMP:Subject+=%s' % t) - if geo_lat and geo_lon: - geo_lat = round(float(geo_lat), 6) - geo_lon = round(float(geo_lon), 6) - - if geo_lat < 0: - GPSLatitudeRef = 'S' - else: - GPSLatitudeRef = 'N' - - if geo_lon < 0: - GPSLongitudeRef = 'W' - else: - GPSLongitudeRef = 'E' - - params.append('-GPSLongitude=%s' % abs(geo_lon)) - params.append('-GPSLatitude=%s' % abs(geo_lat)) - params.append('-GPSLongitudeRef=%s' % GPSLongitudeRef) - params.append('-GPSLatitudeRef=%s' % GPSLatitudeRef) - params.append(self.target) - - p = subprocess.Popen( - params, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout, stderr = p.communicate() - _original = '%s_original' % self.target - if os.path.exists(_original): - os.unlink(_original) - - -class FlickrFav(ImgFav): - url = 'https://api.flickr.com/services/rest/' - - def __init__(self, photo): - self.photo = photo - self.ownerid = photo.get('owner') - self.photoid = photo.get('id') - self.url = "https://www.flickr.com/photos/%s/%s" % ( - self.ownerid, self.photoid) - self.target = os.path.join( - shared.config.get('archive', 'favorite'), - "flickr-%s-%s.jpg" % (self.ownerid, self.photoid) - ) - - def run(self): - - if self.exists: - logging.warning("%s already exists, skipping", self.target) - return - - # the bigger the better, see - # https://www.flickr.com/services/api/misc.urls.html - img = False - for x in ['url_o', 'url_k', 'url_h', 'url_b', 'url_c', 'url_z']: - if x in self.photo: - img = self.photo.get(x) - break - - if not img: - logging.error("image url was empty for %s, skipping fav", self.url) - return - self.imgurl = img - self.pull_image() - self.meta = { - 'dt': arrow.get( - self.photo.get('date_faved', - arrow.utcnow().timestamp - ) - ), - 'title': '%s' % shared.PandocNG( - self.photo.get('title', '') - ).txt.rstrip(), - 'favorite-of': self.url, - 'tags': self.photo.get('tags', '').split(' '), - 'geo': { - 'latitude': self.photo.get('latitude', ''), - 'longitude': self.photo.get('longitude', ''), - }, - 'author': { - 'name': self.photo.get('ownername'), - 'url': 'https://www.flickr.com/people/%s' % ( - self.photo.get('owner') - ), - }, - } - - self.content = shared.PandocNG( - self.photo.get('description', {}).get('_content', '') - ).txt - - self.fix_extension() - self.write_exif() - - -class DAFav(ImgFav): - def __init__(self, fav): - self.fav = fav - self.deviationid = fav.get('deviationid') - #logging.info('working on %s', self.deviationid) - self.url = fav.get('url') - self.title = fav.get('title', False) or self.deviationid - self.author = self.fav.get('author').get('username') - self.target = os.path.join( - shared.config.get('archive', 'favorite'), - "deviantart-%s-%s.jpg" % ( - shared.slugfname(self.title), - shared.slugfname(self.author) - ) - ) - - self.imgurl = None - if 'content' in fav: - if 'src' in fav['content']: - self.imgurl = fav.get('content').get('src') - elif 'preview' in fav: - if 'src' in fav['preview']: - self.imgurl = fav.get('preview').get('src') - self.imgurl = fav.get('content', {}).get('src') - - def run(self): - if not self.imgurl: - logging.error( - 'imgurl is empty for deviantart %s', - self.deviationid) - return - - self.pull_image() - - self.meta = { - 'dt': arrow.get( - self.fav.get('published_time', - arrow.utcnow().timestamp - ) - ), - 'title': '%s' % shared.PandocNG( - self.title - ).txt.rstrip(), - 'favorite-of': self.url, - 'tags': [t.get('tag_name') for t in self.fav.get('meta', {}).get('tags', [])], - 'author': { - 'name': self.author, - 'url': 'https://%s.deviantart.com' % (self.author), - }, - } - c = "%s" % self.fav.get('meta', {}).get('description', '') - self.content = shared.PandocNG(c).txt - self.fix_extension() - self.write_exif() - - -class TumblrFav(object): - def __init__(self, like): - self.like = like - self.blogname = like.get('blog_name') - self.postid = like.get('id') - self.target = os.path.join( - shared.config.get('archive', 'favorite'), - "tumblr-%s-%s.jpg" % (self.blogname, self.postid) - ) - self.url = like.get('post_url') - self.images = [] - - @property - def exists(self): - maybe = glob.glob(self.target.replace('.jpg', '_0.*')) - if len(maybe): - return True - return False - - def run(self): - content = "%s" % self.like.get('caption', '') - title = self.like.get('summary', '').strip() - if not len(title): - title = self.like.get('slug', '').strip() - if not len(title): - title = shared.slugfname(self.like.get('post_url')) - - meta = { - 'dt': arrow.get( - self.like.get('liked_timestamp', - self.like.get('date', - arrow.utcnow().timestamp - ) - ) - ), - 'title': title, - 'favorite-of': self.url, - 'tags': self.like.get('tags'), - 'author': { - 'name': self.like.get('blog_name'), - 'url': 'http://%s.tumblr.com' % self.like.get('blog_name') - }, - } - - icntr = 0 - for p in self.like.get('photos', []): - img = ImgFav() - img.target = self.target.replace('.jpg', '_%d.jpg' % icntr) - img.imgurl = p.get('original_size').get('url') - img.content = content - img.meta = meta - img.pull_image() - img.fix_extension() - img.write_exif() - icntr = icntr + 1 - - -class TwitterFav(object): - def __init__(self, like): - self.like = like - self.postid = like.get('id') - self.target = os.path.join( - shared.config.get('archive', 'favorite'), - "twitter-%s.jpg" % (self.postid) - ) - - @property - def exists(self): - maybe = glob.glob(self.target.replace('.jpg', '_*.*')) - if len(maybe): - return True - return False - - def run(self): - content = "%s" % self.like.get('text', '') - title = self.like.get('id') - user = self.like.get('user') - - meta = { - 'dt': arrow.get( - self.like.get('created_at'), - shared.ARROWFORMAT.get('twitter') - ), - 'title': title, - 'favorite-of': "https://twitter.com/%s/status/%s" % ( - user.get('id'), - self.like.get('id') - ), - 'tags': self.like.get('hashtags'), - 'author': { - 'name': user.get('name'), - 'username': user.get('screen_name'), - 'id': user.get('id'), - 'url': 'http://twitter.com/%s' % user.get('screen_name') - }, - } - - for p in self.like.get('entities', {}).get('media', []): - img = ImgFav() - img.imgurl = p.get('media_url_https') - - img.target = self.target.replace( - '.jpg', - '_%s.jpg' % p.get('id') - ) - img.content = content - img.meta = meta - img.pull_image() - img.fix_extension() - img.write_exif() - - -class TwitterFavs(Favs): - url = 'https://api.twitter.com/1.1/favorites/list.json' - - def __init__(self): - super().__init__('twitter') - self.oauth = TwitterOauth() - self.params = { - 'user_id': shared.config.get('api_twitter', 'userid'), - 'count': 200 - } - - @property - def lastpulled(self): - lastid = 0 - d = os.path.join( - shared.config.get('archive', 'favorite'), - "%s-*" % self.confgroup - ) - files = glob.glob(d) - for f in files: - tweetid = int(re.sub( - '.*twitter-(?P<tweetid>[0-9]+)_.*', - '\g<tweetid>', - f - )) - if tweetid > lastid: - lastid = tweetid - - logging.debug("last fav id: %s", lastid) - return lastid - - def run(self): - if self.lastpulled > 0: - self.params.update({ - 'since_id': self.lastpulled - }) - - r = self.oauth.request( - self.url, - params=self.params - ) - - for like in json.loads(r.text): - fav = TwitterFav(like) - if not fav.exists: - fav.run() - - -class Oauth2Flow(object): - token_url = '' - - def __init__(self, service): - self.service = service - self.key = shared.config.get("api_%s" % service, 'api_key') - self.secret = shared.config.get("api_%s" % service, 'api_secret') - client = BackendApplicationClient( - client_id=self.key - ) - client.prepare_request_body(scope=['browse']) - oauth = OAuth2Session(client=client) - token = oauth.fetch_token( - token_url=self.token_url, - client_id=self.key, - client_secret=self.secret - ) - self.client = OAuth2Session( - self.key, - token=token - ) - - def request(self, url, params={}): - return self.client.get(url, params=params) - - -class DAOauth(Oauth2Flow): - token_url = 'https://www.deviantart.com/oauth2/token' - - def __init__(self): - super().__init__('deviantart') - - -class Oauth1Flow(object): - request_token_url = '' - access_token_url = '' - authorize_url = '' - - def __init__(self, service): - self.service = service - self.key = shared.config.get("api_%s" % service, 'api_key') - self.secret = shared.config.get("api_%s" % service, 'api_secret') - self.tokendb = shared.TokenDB() - self.t = self.tokendb.get_service(self.service) - self.oauth_init() - - def oauth_init(self): - if not self.t: - self.request_oauth_token() - - t = self.tokendb.get_token(self.t) - if not t.get('access_token', None) or not t.get( - 'access_token_secret', None): - self.request_access_token() - - def request_oauth_token(self): - client = OAuth1Session( - self.key, - client_secret=self.secret, - callback_uri="%s/oauth1/" % shared.config.get('site', 'url') - ) - r = client.fetch_request_token(self.request_token_url) - logging.debug('setting token to %s', r.get('oauth_token')) - self.t = r.get('oauth_token') - logging.debug('updating secret to %s', r.get('oauth_token_secret')) - self.tokendb.update_token( - self.t, - oauth_token_secret=r.get('oauth_token_secret') - ) - self.tokendb.set_service( - self.service, - self.t - ) - - existing = self.tokendb.get_token(self.t) - verified = existing.get('verifier', None) - while not verified: - logging.debug('verifier missing for %s', self.t) - self.auth_url(existing) - self.tokendb.refresh() - existing = self.tokendb.get_token(self.t) - verified = existing.get('verifier', None) - - def auth_url(self, existing): - t = self.tokendb.get_token(self.t) - client = OAuth1Session( - self.key, - client_secret=self.secret, - resource_owner_key=self.t, - resource_owner_secret=t.get('oauth_token_secret'), - callback_uri="%s/oauth1/" % shared.config.get('site', 'url') - ) - input('Visit: %s and press any key after' % ( - client.authorization_url(self.authorize_url) - )) - - def request_access_token(self): - try: - t = self.tokendb.get_token(self.t) - client = OAuth1Session( - self.key, - client_secret=self.secret, - callback_uri="%s/oauth1/" % shared.config.get('site', 'url'), - resource_owner_key=self.t, - resource_owner_secret=t.get('oauth_token_secret'), - verifier=t.get('verifier') - ) - r = client.fetch_access_token(self.access_token_url) - self.tokendb.update_token( - self.t, - access_token=r.get('oauth_token'), - access_token_secret=r.get('oauth_token_secret') - ) - except oauth1_session.TokenRequestDenied as e: - logging.error( - 'getting access token was denied, clearing former oauth tokens and re-running everyting') - self.tokendb.clear_service(self.service) - self.oauth_init() - - def request(self, url, params): - t = self.tokendb.get_token(self.t) - client = OAuth1Session( - self.key, - client_secret=self.secret, - resource_owner_key=t.get('access_token'), - resource_owner_secret=t.get('access_token_secret') - ) - return client.get(url, params=params) - - -class FlickrOauth(Oauth1Flow): - request_token_url = 'https://www.flickr.com/services/oauth/request_token' - access_token_url = 'https://www.flickr.com/services/oauth/access_token' - authorize_url = 'https://www.flickr.com/services/oauth/authorize' - - def __init__(self): - super().__init__('flickr') - - -class TumblrOauth(Oauth1Flow): - request_token_url = 'https://www.tumblr.com/oauth/request_token' - access_token_url = 'https://www.tumblr.com/oauth/access_token' - authorize_url = 'https://www.tumblr.com/oauth/authorize' - - def __init__(self): - super().__init__('tumblr') - - -class TwitterOauth(Oauth1Flow): - request_token_url = 'https://api.twitter.com/oauth/request_token' - access_token_url = 'https://api.twitter.com/oauth/access_token' - authorize_url = 'https://api.twitter.com/oauth/authorize' - - def __init__(self): - super().__init__('twitter') - - -if __name__ == '__main__': - logging.basicConfig(level=20) - - if shared.config.has_section('api_flickr'): - flickr = FlickrFavs() - flickr.run() - - if shared.config.has_section('api_tumblr'): - tumblr = TumblrFavs() - tumblr.run() - - if shared.config.has_section('api_deviantart'): - da = DAFavs() - da.run() - - if shared.config.has_section('api_lastfm'): - lfm = LastFM() - lfm.run() - - if shared.config.has_section('api_twitter'): - tw = TwitterFavs() - tw.run()
@@ -1,124 +0,0 @@
-[common] -base = ~/ -domain = domain.com -build = ~/web -files = files - -[dirs] -content = ${common:base}/content -tmpl = ${common:base}/nasg/templates -files = ${common:base}/${common:files} -static = ${common:base}/static -var = ${common:base}/var -archive = ${common:base}/archive -comment = ${common:base}/comment - -[archive] -favorite = ${dirs:archive}/favorite - -[photo] -regex = (author name) -watermark = ${dirs:tmpl}/watermark.png -default = 720 - -[downsize] -;90 = s -;360 = m -720 = z -1280 = b - -[crop] -;90 = true - -[var] -searchdb = ${dirs:var}/s.sqlite -webmentiondb = ${dirs:var}/webmention.sqlite -redirects = ${dirs:content}/redirects.tsv -gone = ${dirs:content}/gone.tsv -tokendb = ${dirs:var}/tokens.json -cache = ${dirs:var}/cache - -[site] -title = -url = https://${common:domain} -lang = en -domains = ${common:domain} -feed = feed -appendwith = author websub - -[tip_label] -paypal = PayPal -monzo = Monzo (UK) - -[tip_value] -paypal = £3 -monzo = £3 - -[tip_url] -paypal = https://paypal.me/username/3GBP -monzo = https://monzo.me/username/3 - -[websub] -hub = https://hubname.superfeedr.com/ - -[display] -pagination = 12 -norender = page - -[licence] -default = by-nc-nd - -[author] -name = Author Name -email = author@domain.com -url = ${site:url} -avatar = ${site:url}/avatar.jpg -gpg = pgp.asc -sip = sip.account@domain.com -xmpp = xmpp.account@domain.com -flickr = flickr username -github = github username -icq = ICQ username -telegram = Telegram username - -[api_flickr] -api_key = -api_secret = -username = - -[api_500px] -api_key = -api_secret = - -[api_tumblr] -api_key = -api_secret = - -[api_deviantart] -api_key = -api_secret = -username = - -[listener] -host = 127.0.0.1 -port = 8008 - -[api_telegram] -api_token = -chat_id = - -[api_spotify] -api_key = -api_secret = - -[api_lastfm] -api_key = -api_secret = -username = -logfile = ${dirs:archive}/lastfm.csv - -[api_twitter] -api_key = -api_secret = -username = -userid =
@@ -1,203 +0,0 @@
-#!/usr/bin/env python3 - -__author__ = "Peter Molnar" -__copyright__ = "Copyright 2017-2018, Peter Molnar" -__license__ = "GNU LGPLv3 " -__maintainer__ = "Peter Molnar" -__email__ = "mail@petermolnar.net" - -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.mime.image import MIMEImage -from email.header import Header -import email.charset -from email.generator import Generator -from io import StringIO -import mimetypes -from email.mime.base import MIMEBase -from email.encoders import encode_base64 -import email.utils - -import time -import getpass -import socket -import shutil -import requests -import tempfile -import atexit -import os -import re -import smtplib -import logging -from shared import PandocNG - - -class Letter(object): - def __init__(self, sender=None, recipient=None, subject='', text=''): - self.sender = sender or (getpass.getuser(), socket.gethostname()) - self.recipient = recipient or self.sender - - self.tmp = tempfile.mkdtemp( - 'envelope_', - dir=tempfile.gettempdir() - ) - atexit.register( - shutil.rmtree, - os.path.abspath(self.tmp) - ) - self.text = text - self.subject = subject - self.images = [] - self.ready = None - self.time = time.time() - self.headers = {} - - @property - def _html(self): - return PandocNG(self.text).html - - @property - def _tmpl(self): - return "<html><head></head><body>%s</body></html>" % (self._html) - - def __pull_image(self, img): - fname = os.path.basename(img) - i = { - 'url': img, - 'name': fname, - 'tmp': os.path.join(self.tmp, fname), - } - - logging.debug("pulling image %s", i['url']) - r = requests.get(i['url'], stream=True) - if r.status_code == 200: - with open(i['tmp'], 'wb') as f: - logging.debug("writing image %s", i['tmp']) - r.raw.decode_content = True - shutil.copyfileobj(r.raw, f) - if not isinstance(self.images, list): - self.images = [] - self.images.append(i) - - def __pull_images(self): - mdmatch = re.compile( - r'!\[.*\]\((.*?\.(?:jpe?g|png|gif)(?:\s+[\'\"]?.*?[\'\"]?)?)\)' - r'(?:\{.*?\})?' - ) - [self.__pull_image(img) for img in mdmatch.findall(self.text)] - - def __attach_images(self): - self.__pull_images() - for i in self.images: - cid = 'cid:%s' % (i['name']) - logging.debug("replacing %s with %s", i['url'], cid) - self.text = self.text.replace(i['url'], cid) - - def make(self, inline_images=True): - if inline_images: - self.__attach_images() - - # Python, by default, encodes utf-8 in base64, which makes plain text - # mail painful; this overrides and forces Quoted Printable. - # Quoted Printable is still awful, but better, and we're going to - # force the mail to be 8bit encoded. - # Note: enforcing 8bit breaks compatibility with ancient mail clients. - email.charset.add_charset( - 'utf-8', - email.charset.QP, - email.charset.QP, - 'utf-8' - ) - - mail = MIMEMultipart('alternative') - - # --- setting headers --- - self.headers = { - 'Subject': Header(re.sub(r"\r?\n?$", "", self.subject, 1), 'utf-8').encode(), - 'To': email.utils.formataddr(self.recipient), - 'From': email.utils.formataddr(self.sender), - 'Date': email.utils.formatdate(self.time, localtime=True) - } - - for k, v in self.headers.items(): - mail.add_header(k, "%s" % v) - logging.debug("headers: %s", self.headers) - - # --- adding plain text --- - text = self.text - _text = MIMEText(text, 'text', _charset='utf-8') - # --- - # this is the part where we overwrite the way Python thinks: - # force the text to be the actual, unencoded, utf-8. - # Note:these steps breaks compatibility with ancient mail clients. - _text.replace_header('Content-Transfer-Encoding', '8bit') - _text.replace_header('Content-Type', 'text/plain; charset=utf-8') - _text.set_payload(self.text) - # --- - logging.debug("text: %s", _text) - mail.attach(_text) - - # --- HTML bit --- - # this is where it gets tricky: the HTML part should be a 'related' - # wrapper, in which the text and all the related images are sitting - _envelope = MIMEMultipart('related') - - html = self._tmpl - _html = MIMEText(html, 'html', _charset='utf-8') - # --- - # see above under 'adding plain text' - _html.replace_header('Content-Transfer-Encoding', '8bit') - _html.replace_header('Content-Type', 'text/html; charset=utf-8') - _html.set_payload(html) - # --- - logging.debug("HTML: %s", _html) - _envelope.attach(_html) - - for i in self.images: - mimetype, encoding = mimetypes.guess_type(i['tmp']) - mimetype = mimetype or 'application/octet-stream' - mimetype = mimetype.split('/', 1) - attachment = MIMEBase(mimetype[0], mimetype[1]) - with open(i['tmp'], 'rb') as img: - attachment.set_payload(img.read()) - img.close() - os.unlink(i['tmp']) - - encode_base64(attachment) - attachment.add_header( - 'Content-Disposition', - 'inline', - filename=i['name'] - ) - attachment.add_header( - 'Content-ID', - '<%s>' % (i['name']) - ) - - _envelope.attach(attachment) - - # add the whole html + image pack to the mail - mail.attach(_envelope) - - str_io = StringIO() - g = Generator(str_io, False) - g.flatten(mail) - - self.ready = str_io.getvalue().encode('utf-8') - - def send(self): - if not self.ready: - logging.error('this mail is not ready') - return - - try: - s = smtplib.SMTP('127.0.0.1', 25) - # unless you do the encode, you'll get: - # File "/usr/local/lib/python3.5/smtplib.py", line 850, in sendmail - # msg = _fix_eols(msg).encode('ascii') - # UnicodeEncodeError: 'ascii' codec can't encode character '\xa0' - # in position 1073: ordinal not in range(128) - s.sendmail(self.headers['From'], self.headers['To'], self.ready) - s.quit() - except Exception as e: - logging.error('sending mail failed with error: %s', e)
@@ -0,0 +1,134 @@
+import re +import subprocess +import json +import os + +EXIFDATE = re.compile( + r'^(?P<year>[0-9]{4}):(?P<month>[0-9]{2}):(?P<day>[0-9]{2})\s+' + r'(?P<time>[0-9]{2}:[0-9]{2}:[0-9]{2})$' +) + +class Exif(dict): + def __init__(self, fpath): + self.fpath = fpath + self._read() + + @property + def cfile(self): + return os.path.join( + os.path.dirname(self.fpath), + ".%s.json" % (os.path.basename(self.fpath)) + ) + + @property + def _is_cached(self): + if os.path.exists(self.cfile): + mtime = os.path.getmtime(self.fpath) + ctime = os.path.getmtime(self.cfile) + if ctime >= mtime: + return True + return False + + def _read(self): + if not self._is_cached: + self._call_exiftool() + self._cache_update() + else: + self._cache_read() + + def _cache_update(self): + with open(self.cfile, 'wt') as f: + f.write(json.dumps(self, indent=4, sort_keys=True)) + + def _cache_read(self): + with open(self.cfile, 'rt') as f: + data = json.loads(f.read()) + for k, v in data.items(): + self[k] = self.exifdate2rfc(v) + + def _call_exiftool(self): + """ + Why like this: the # on some of the params forces exiftool to + display values like decimals, so the latitude / longitude params + can be used and parsed in a sane way + + If only -json is passed, it gets everything nicely, but in the default + format, which would require another round to parse + + """ + cmd = ( + "exiftool", + '-sort', + '-json', + '-MIMEType', + '-FileType', + '-FileName', + '-FileSize#', + '-ModifyDate', + '-CreateDate', + '-DateTimeOriginal', + '-ImageHeight', + '-ImageWidth', + '-Aperture', + '-FOV', + '-ISO', + '-FocalLength', + '-FNumber', + '-FocalLengthIn35mmFormat', + '-ExposureTime', + '-Model', + '-GPSLongitude#', + '-GPSLatitude#', + '-LensID', + '-LensSpec', + '-Lens', + '-ReleaseDate', + '-Description', + '-Headline', + '-HierarchicalSubject', + '-Copyright', + '-Artist', + self.fpath + ) + + p = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout, stderr = p.communicate() + if stderr: + raise OSError("Error reading EXIF:\n\t%s\n\t%s", cmd, stderr) + + exif = json.loads(stdout.decode('utf-8').strip()).pop() + if 'ReleaseDate' in exif and 'ReleaseTime' in exif: + exif['DateTimeRelease'] = "%s %s" % ( + exif.get('ReleaseDate'), exif.get('ReleaseTime')[:8] + ) + del(exif['ReleaseDate']) + del(exif['ReleaseTime']) + + for k, v in exif.items(): + self[k] = self.exifdate2rfc(v) + + def exifdate2rfc(self, value): + """ converts and EXIF date string to RFC 3339 format + + :param value: EXIF date (2016:05:01 00:08:24) + :type arg1: str + :return: RFC 3339 string with UTC timezone 2016-05-01T00:08:24+00:00 + :rtype: str + """ + if not isinstance(value, str): + return value + match = EXIFDATE.match(value) + if not match: + return value + return "%s-%s-%sT%s+00:00" % ( + match.group('year'), + match.group('month'), + match.group('day'), + match.group('time') + )
@@ -6,629 +6,339 @@ __license__ = "GNU LGPLv3 "
__maintainer__ = "Peter Molnar" __email__ = "mail@petermolnar.net" +import glob import os +import time +from functools import lru_cache as cached import re +import imghdr import logging -import json -import glob -import argparse -import shutil -from urllib.parse import urlparse import asyncio +from shutil import copy2 as cp from math import ceil -import csv -import html -import frontmatter -import requests +from urllib.parse import urlparse +from collections import OrderedDict, namedtuple import arrow import langdetect import wand.image -from emoji import UNICODE_EMOJI +import jinja2 +import frontmatter +import markdown from feedgen.feed import FeedGenerator -import shared - - -class MagicPHP(object): - ''' router PHP generator ''' - name = 'index.php' - - def __init__(self): - # init 'gone 410' array - self.gones = [] - f_gone = shared.config.get('var', 'gone') - if os.path.isfile(f_gone): - with open(f_gone) as csvfile: - reader = csv.reader(csvfile, delimiter=' ') - for row in reader: - self.gones.append(row[0]) - # init manual redirects array - self.redirects = [] - f_redirect = shared.config.get('var', 'redirects') - if os.path.isfile(f_redirect): - with open(f_redirect) as csvfile: - reader = csv.reader(csvfile, delimiter=' ') - for row in reader: - self.redirects.append((row[0], row[1])) - - @property - def phpfile(self): - return os.path.join( - shared.config.get('common', 'build'), - self.name - ) - - async def render(self): - logging.info('saving %s', self.name) - o = self.phpfile - tmplfile = "%s.html" % (self.__class__.__name__) - r = shared.j2.get_template(tmplfile).render({ - 'site': shared.site, - 'redirects': self.redirects, - 'gones': self.gones - }) - with open(o, 'wt') as out: - logging.debug('writing file %s', o) - out.write(r) - - -class NoDupeContainer(object): - ''' Base class to hold keys => data dicts with errors on dupes ''' - - def __init__(self): - self.data = {} - self.default = None +from bleach import clean +from emoji import UNICODE_EMOJI +import exiftool +import settings - def append(self, key, value): - # all clear - if key not in self.data: - self.data.update({key: value}) - return +import sys +from pprint import pprint - # problem - logging.error( - "duplicate key error when populating %s: %s", - self.__class__.__name__, - key - ) - logging.error( - "current: %s", - self.data.get(key) - ) - logging.error( - "problem: %s", - value - ) +MarkdownImage = namedtuple( + 'MarkdownImage', + ['match', 'alt', 'fname', 'title', 'css'] +) - return - - # TODO: return ordered version of data +J2 = jinja2.Environment( + loader=jinja2.FileSystemLoader(searchpath=settings.paths.get('tmpl')), + lstrip_blocks=True, + trim_blocks=True +) - def __getitem__(self, key): - return self.data.get(key, self.default) +RE_MDIMG = re.compile( + r'(?P<match>!\[(?P<alt>[^\]]+)?\]\((?P<fname>[^\s]+)' + r'(?:\s[\'\"](?P<title>[^\"\']+)[\'\"])?\)(?:{(?P<css>[^\}]+)\})?)', + re.IGNORECASE +) - def __setitem__(self, key, value): - return self.append(key, value) +RE_HTTP = re.compile( + r'^https?://', + re.IGNORECASE +) - def __contains__(self, key): - if key in self.data.keys(): - return True - return False +MD = markdown.Markdown( + output_format='xhtml5', + extensions=[ + 'extra', + 'codehilite', + 'headerid', + 'urlize' + ], +) - def __len__(self): - return len(self.data.keys()) - def __next__(self): - try: - r = self.data.next() - except BaseException: - raise StopIteration() - return r - - def __iter__(self): - for k, v in self.data.items(): - yield (k, v) - return - - -class FContainer(NoDupeContainer): - """ This is a container that holds a lists of files based on Container so - it errors on duplicate slugs and is popolated with recorsive glob """ - - def __init__(self, dirs, extensions=['*']): - super().__init__() - files = [] - for ext in extensions: - for p in dirs: - files.extend(glob.iglob( - os.path.join(p, '*.%s' % (ext)), - recursive=True - )) - # eliminate duplicates - files = list(set(files)) - for fpath in files: - fname = os.path.basename(fpath) - self.append(fname, fpath) - - -class Content(FContainer): - """ This is a container that holds markdown files that are parsed when the - container is populated on the fly; based on FContainer which is a Container - """ - - def __init__(self): - dirs = [os.path.join(shared.config.get('dirs', 'content'), "**")] - extensions = ['md', 'jpg'] - super().__init__(dirs, extensions) - for fname, fpath in self.data.items(): - self.data.update({fname: Singular(fpath)}) - - -class Category(NoDupeContainer): - """ A Category which holds pubtime (int) => Singular data """ - indexfile = 'index.html' - feedfile = 'index.xml' - feeddir = 'feed' - pagedir = 'page' - taxonomy = 'category' - - def __init__(self, name='', is_front=False): - self.name = name - self.topics = NoDupeContainer() - self.is_front = is_front - super().__init__() - - def append(self, post): - if len(post.tags) == 1: - topic = post.tags[0] - if topic not in self.topics: - t = NoDupeContainer() - self.topics.append(topic, t) - t = self.topics[topic] - t.append(post.pubtime, post) - return super().append(post.pubtime, post) - +class MarkdownDoc(object): @property - def mtime(self): - return int(sorted(self.data.keys(), reverse=True)[0]) + @cached() + def _parsed(self): + with open(self.fpath, mode='rt') as f: + logging.debug('parsing YAML+MD file %s', self.fpath) + meta, txt = frontmatter.parse(f.read()) + return(meta, txt) @property - def is_uptodate(self): - index = os.path.join(self.path_paged(), self.indexfile) - if not os.path.isfile(index): - return False - mtime = os.path.getmtime(index) - if mtime == self.mtime: - return True - return False + def meta(self): + return self._parsed[0] @property - def title(self): - if self.is_front: - prepend = shared.config.get('site', 'title') - else: - prepend = self.name - return ' - '.join([ - prepend, - shared.config.get('common', 'domain') - ]) + def content(self): + return self._parsed[1] @property - def is_altrender(self): - return os.path.exists( - os.path.join( - shared.config.get('dirs', 'tmpl'), - "%s_%s.html" % ( - self.__class__.__name__, - self.name - ) - ) - ) + @cached() + def html_content(self): + c = "%s" % (self.content) + if hasattr(self, 'images') and len(self.images): + for match, img in self.images.items(): + c = c.replace(match, str(img)) + return MD.reset().convert(c) - @property - def url(self): - if self.name: - url = "/%s/%s/" % ( - self.taxonomy, - self.name, - ) - else: - url = '/' - return url - def path_paged(self, page=1, feed=False): - x = shared.config.get('common', 'build') +class Comment(MarkdownDoc): + def __init__(self, fpath): + self.fpath = fpath - if self.name: - x = os.path.join( - x, - self.taxonomy, - self.name, - ) - - if page == 1: - if feed: - x = os.path.join(x, self.feeddir) + @property + def dt(self): + maybe = self.meta.get('date') + if maybe: + dt = arrow.get(maybe) else: - x = os.path.join(x, self.pagedir, "%s" % page) + dt = arrow.get(os.path.getmtime(self.fpath)) + return dt - if not os.path.isdir(x): - os.makedirs(x) - return x + @property + def target(self): + t = urlparse(self.meta.get('target')) + return t.path.rstrip('/').strip('/').split('/')[-1] - def write_html(self, path, content): - with open(path, 'wt') as out: - logging.debug('writing file %s', path) - out.write(content) - os.utime(path, (self.mtime, self.mtime)) + @property + def source(self): + return self.meta.get('source') - async def render(self): - if self.is_altrender: - self.render_onepage() - else: - self.render_paginated() - self.render_feed() + @property + def author(self): + r = { + 'name': urlparse(self.source).hostname, + 'url': self.source + } + author = self.meta.get('author') + if not author: + return r + if 'name' in author: + r.update({ + 'name': self.meta.get('author').get('name') + }) + elif 'url' in author: + r.update({ + 'name': urlparse(self.meta.get('author').get('url')).hostname + }) + return r - def render_onepage(self): - years = {} - for k in list(sorted(self.data.keys(), reverse=True)): - post = self.data[k] - year = int(arrow.get(post.pubtime).format('YYYY')) - if year not in years: - years.update({year: []}) - years[year].append(post.tmplvars) + @property + def type(self): + if len(self.content): + maybe = clean(self.content, strip=True) + if maybe in UNICODE_EMOJI: + return maybe + return self.meta.get('type', 'webmention') - tmplvars = { - 'taxonomy': { - 'add_welcome': self.is_front, - 'title': self.title, - 'name': self.name, - 'lastmod': arrow.get(self.mtime).format( - shared.ARROWFORMAT['rcf'] - ), - 'url': self.url, - 'feed': "%s/%s/" % ( - self.url, - shared.config.get('site', 'feed') - ), - }, - 'site': shared.site, - 'by_year': years + @property + def tmplvars(self): + return { + 'author': self.author, + 'source': self.source, + 'pubtime': self.dt.format(settings.dateformat.get('iso')), + 'pubdate': self.dt.format(settings.dateformat.get('display')), + 'html': self.html_content, + 'type': self.type } - dirname = self.path_paged(1) - o = os.path.join(dirname, self.indexfile) - logging.info( - "Rendering category %s to %s", - self.name, - o - ) - tmplfile = "%s_%s.html" % ( - self.__class__.__name__, - self.name - ) - r = shared.j2.get_template(tmplfile).render(tmplvars) - self.write_html(o, r) - def render_feed(self): - start = 0 - end = int(shared.config.getint('display', 'pagination')) - posttmpls = [ - self.data[k].tmplvars - for k in list(sorted( - self.data.keys(), - reverse=True - ))[start:end] - ] - dirname = self.path_paged(1, feed=True) - o = os.path.join(dirname, self.feedfile) - logging.info( - "Rendering feed of category %s to %s", - self.name, - o - ) + +class Gone(object): + """ + Gone object for delete entries + """ - flink = "%s%s%s" % ( - shared.config.get('site', 'url'), - self.url, - shared.config.get('site', 'feed') + def __init__(self, fpath): + self.fpath = fpath + self.address, self.fext = os.path.splitext( + os.path.basename(self.fpath) ) - fg = FeedGenerator() - fg.id(flink) - fg.link( - href=flink, - rel='self' - ) - fg.title(self.title) - fg.author({ - 'name': shared.site.get('author').get('name'), - 'email': shared.site.get('author').get('email') - }) - fg.logo('%s/favicon.png' % shared.site.get('url')) - fg.updated(arrow.get(self.mtime).to('utc').datetime) - for p in reversed(posttmpls): - link = '%s/%s/' % (shared.site.get('url'), p.get('slug')) - dt = arrow.get(p.get('pubtime')).to('utc') + @property + def nginx(self): + return (self.address, 'return 410') - content = p.get('html') - if p.get('photo'): - content = "%s\n\n%s" % (p.get('photo'), content) - fe = fg.add_entry() - fe.id(link) - fe.link(href=link) - fe.title(p.get('title')) - fe.published(dt.datetime) - fe.updated(dt.datetime) - fe.content( - content, - type='CDATA' - ) - fe.rights('%s %s %s' % ( - dt.format('YYYY'), - shared.site.get('author').get('name'), - p.get('licence').get('text') - )) - if p.get('enclosure'): - enclosure = p.get('enclosure') - fe.enclosure( - enclosure.get('url'), - "%d" % enclosure.get('size'), - enclosure.get('mime') - ) +class Redirect(object): + """ + Redirect object for entries that moved + """ - with open(o, 'wb') as f: - f.write(fg.atom_str(pretty=True)) + def __init__(self, fpath): + self.fpath = fpath + self.source, self.fext = os.path.splitext(os.path.basename(self.fpath)) - # with open(o.replace('.xml', '.rss'), 'wb') as f: - # f.write(fg.rss_str(pretty=True)) + @property + @cached() + def target(self): + target = '' + with open(self.fpath, 'rt') as f: + target = f.read().strip() + if not RE_HTTP.match(target): + target = "%s/%s" % (settings.site.get('url'), target) + return target - # ping pubsub - r = requests.post( - shared.site.get('websub').get('hub'), - data={ - 'hub.mode': 'publish', - 'hub.url': flink - } - ) - logging.info(r.text) + @property + def nginx(self): + return (self.source, 'return 301 %s' % (self.target)) - def render_paginated(self): - pagination = shared.config.getint('display', 'pagination') - pages = ceil(len(self.data) / pagination) - page = 1 - while page <= pages: - add_welcome = False - if (self.is_front and page == 1): - add_welcome = True - # list relevant post templates - start = int((page - 1) * pagination) - end = int(start + pagination) - posttmpls = [ - self.data[k].tmplvars - for k in list(sorted( - self.data.keys(), - reverse=True - ))[start:end] - ] - # define data for template - # TODO move the pagination links here, the one in jinja - # is overcomplicated - tmplvars = { - 'taxonomy': { - 'add_welcome': add_welcome, - 'title': self.title, - 'name': self.name, - 'page': page, - 'total': pages, - 'perpage': pagination, - 'lastmod': arrow.get(self.mtime).format( - shared.ARROWFORMAT['rcf'] - ), - 'url': self.url, - 'feed': "%s/%s/" % ( - self.url, - shared.config.get('site', 'feed') - ), - }, - 'site': shared.site, - 'posts': posttmpls, - } - # render HTML - dirname = self.path_paged(page) - o = os.path.join(dirname, self.indexfile) - logging.info( - "Rendering page %d/%d of category %s to %s", - page, - pages, - self.name, - o - ) - tmplfile = "%s.html" % (self.__class__.__name__) - r = shared.j2.get_template(tmplfile).render(tmplvars) - self.write_html(o, r) - page = page + 1 - - -class Singular(object): - indexfile = 'index.html' - +class Singular(MarkdownDoc): + """ + A Singular object: a complete representation of a post, including + all it's comments, files, images, etc + """ def __init__(self, fpath): - logging.debug("initiating singular object from %s", fpath) self.fpath = fpath + n = os.path.dirname(fpath) + self.name = os.path.basename(n) + self.category = os.path.basename(os.path.dirname(n)) self.mtime = os.path.getmtime(self.fpath) - self.stime = self.mtime - self.fname, self.fext = os.path.splitext(os.path.basename(self.fpath)) - self.category = os.path.basename(os.path.dirname(self.fpath)) - self._images = NoDupeContainer() - if self.fext == '.md': - with open(self.fpath, mode='rt') as f: - self.fm = frontmatter.parse(f.read()) - self.meta, self.content = self.fm - self.photo = None - elif self.fext == '.jpg': - self.photo = WebImage(self.fpath) - self.meta = self.photo.fm_meta - self.content = self.photo.fm_content - self.photo.inline = False - self.photo.cssclass = 'u-photo' + @property + @cached() + def files(self): + """ + An array of files present at the same directory level as + the Singular object, excluding hidden (starting with .) and markdown + (ending with .md) files + """ + return [ + k + for k in glob.glob(os.path.join(os.path.dirname(self.fpath), '*.*')) + if not k.endswith('.md') and not k.startswith('.') + ] - def init_extras(self): - self.receive_webmentions() - c = self.comments + @property + @cached() + def comments(self): + """ + An dict of Comment objects keyed with their path, populated from the + same directory level as the Singular objects + """ + comments = OrderedDict() + files = [ + k + for k in glob.glob(os.path.join(os.path.dirname(self.fpath), '*.md')) + if os.path.basename(k) != 'index.md' + ] + for f in files: + c = Comment(f) + comments[c.dt.timestamp] = c + return comments - # note: due to SQLite locking, this will not be async for now - def receive_webmentions(self): - wdb = shared.WebmentionQueue() - queued = wdb.get_queued(self.url) - for incoming in queued: - wm = Webmention( - incoming.get('source'), - incoming.get('target'), - incoming.get('dt') + @property + @cached() + def images(self): + """ + A dict of WebImage objects, populated by: + - images that are present in the Markdown content + - and have an actual image file at the same directory level as + the Singular object + """ + images = {} + for match, alt, fname, title, css in RE_MDIMG.findall(self.content): + mdimg = MarkdownImage(match, alt, fname, title, css) + imgpath = os.path.join( + os.path.dirname(self.fpath), + fname ) - wm.receive() - wdb.entry_done(incoming.get('id')) - wdb.finish() - - def queue_webmentions(self): - if self.is_future: - return - wdb = shared.WebmentionQueue() - for target in self.urls_to_ping: - if not wdb.exists(self.url, target, self.published): - wdb.queue(self.url, target) - else: - logging.debug( - "not queueing - webmention already queued from %s to %s", - self.url, - target) - wdb.finish() + if imgpath in self.files: + if imghdr.what(imgpath): + images.update({match: WebImage(imgpath, mdimg, self)}) + return images @property - def urls_to_ping(self): - urls = [x.strip() - for x in shared.REGEX.get('urls').findall(self.content)] - if self.is_reply: - urls.append(self.is_reply) - for url in self.syndicate: - urls.append(url) - r = {} - for link in urls: - parsed = urlparse(link) - if parsed.netloc in shared.config.get('site', 'domains'): - continue - if link in r: - continue - r.update({link: True}) - return r.keys() + def is_front(self): + """ + Returns if the post should be displayed on the front + """ + if self.category in settings.site.get('on_front'): + return True + return False @property - def redirects(self): - r = self.meta.get('redirect', []) - r.append(self.shortslug) - return list(set(r)) + def is_photo(self): + """ + This is true if there is a file, with the same name as the entry's + directory - so, it's slug -, and that that image believes it's a a + photo. + """ + maybe = self.fpath.replace("index.md", "%s.jpg" % (self.name)) + if maybe in self.images and self.images[maybe].is_photo: + return True + return False @property - def is_uptodate(self): - for f in [self.htmlfile]: - if not os.path.isfile(f): - return False - mtime = os.path.getmtime(f) - if mtime < self.stime: - return False - return True + def summary(self): + return self.meta.get('summary', '') @property - def is_old(self): - tdiff = arrow.utcnow() - arrow.get(self.mtime) - return (tdiff.days > 2 * 365) + @cached() + def html_summary(self): + return markdown.Markdown( + output_format='html5', + extensions=[ + 'extra', + 'codehilite', + 'headerid', + ], + ).convert(self.summary) @property - def htmlfile(self): - return os.path.join( - shared.config.get('common', 'build'), - self.fname, - self.indexfile + def title(self): + if self.is_reply: + return "RE: %s" % self.is_reply + return self.meta.get( + 'title', + arrow.get( + self.published).format( + settings.dateformat.get('display')) ) @property - def images(self): - if self.photo: - self._images.append(self.fname, self.photo) - # add inline images - for shortcode, alt, fname, title, css in self.inline_images: - # this does the appending automatically - im = self._find_image(fname) - - return self._images + def tags(self): + return self.meta.get('tags', []) @property - def comments(self): - comments = NoDupeContainer() - cfiles = [] - lookin = [*self.redirects, self.fname] - for d in lookin: - maybe = glob.glob( - os.path.join( - shared.config.get('dirs', 'comment'), - d, - '*.md' - ) - ) - cfiles = [*cfiles, *maybe] - for cpath in cfiles: - cmtime = os.path.getmtime(cpath) - if cmtime > self.stime: - self.stime = cmtime + def syndicate(self): + urls = self.meta.get('syndicate', []) + if self.is_photo: + urls.append("https://brid.gy/publish/flickr") + return urls - c = Comment(cpath) - comments.append(c.mtime, c) - return comments + # def baseN(self, num, b=36, + # numerals="0123456789abcdefghijklmnopqrstuvwxyz"): + # """ + # Creates short, lowercase slug for a number (an epoch) passed + # """ + #num = int(num) + # return ((num == 0) and numerals[0]) or ( + # self.baseN( + #num // b, + # b, + # numerals + # ).lstrip(numerals[0]) + numerals[num % b] + # ) - @property - def replies(self): - r = {} - for mtime, c in self.comments: - if c.type == 'webmention': - r.update({mtime: c.tmplvars}) - return sorted(r.items()) - - @property - def reactions(self): - r = {} - for mtime, c in self.comments: - if c.type == 'webmention': - continue - if c.type not in r: - r[c.type] = {} - r[c.type].update({mtime: c.tmplvars}) - - for icon, comments in r.items(): - r[icon] = sorted(comments.items()) - return r - - @property - def exif(self): - if not self.photo: - return {} - return self.photo.exif + # @property + # def shortslug(self): + # return self.baseN(self.published.timestamp) @property def published(self): - return arrow.get(self.meta.get('published', self.mtime)) - - @property - def updated(self): - u = self.meta.get('updated', False) - if u: - u = arrow.get(u) - return u - - @property - def pubtime(self): - return int(self.published.timestamp) + return arrow.get(self.meta.get('published')) @property def is_reply(self):@@ -636,45 +346,21 @@ return self.meta.get('in-reply-to', False)
@property def is_future(self): - now = arrow.utcnow().timestamp - if self.pubtime > now: + if self.published.timestamp > arrow.utcnow().timestamp: return True return False @property def licence(self): - l = shared.config.get( - 'licence', - self.category, - fallback=shared.config.get('licence', 'default',) - ) - return { - 'text': 'CC %s 4.0' % l.upper(), - 'url': 'https://creativecommons.org/licenses/%s/4.0/' % l, - } - - @property - def corpus(self): - corpus = "\n".join([ - "%s" % self.meta.get('title', ''), - "%s" % self.fname, - "%s" % self.meta.get('summary', ''), - "%s" % self.content, - ]) - - if self.photo: - corpus = corpus + "\n".join(self.tags) - - return corpus + if self.category in settings.licence: + return settings.licence[self.category] + return settings.site.get('licence') @property def lang(self): - # default is English, this will only be changed if the try - # succeeds and actually detects a language lang = 'en' try: lang = langdetect.detect("\n".join([ - self.fname, self.meta.get('title', ''), self.content ]))@@ -682,226 +368,171 @@ except BaseException:
pass return lang - def _find_image(self, fname): - fname = os.path.basename(fname) - pattern = os.path.join( - shared.config.get('dirs', 'files'), - '**', - fname + @property + def url(self): + return "%s/%s/" % ( + settings.site.get('url'), + self.name ) - logging.debug('trying to locate image %s in %s', fname, pattern) - maybe = glob.glob(pattern) - - if not maybe: - logging.error('image not found: %s', fname) - return None - - maybe = maybe.pop() - logging.debug('image found: %s', maybe) - if fname not in self._images: - im = WebImage(maybe) - self._images.append(fname, im) - return self._images[fname] @property - def inline_images(self): - return shared.REGEX['mdimg'].findall(self.content) + def replies(self): + r = OrderedDict() + for mtime, c in self.comments.items(): + if c.type in ['webmention', 'in-reply-to']: + r[mtime] = c.tmplvars + return r @property - def url(self): - return "%s/%s/" % (shared.config.get('site', 'url'), self.fname) + def reactions(self): + r = OrderedDict() + for mtime, c in self.comments.items(): + if c.type in ['webmention', 'in-reply-to']: + continue + t = "%s" % (c.type) + if t not in r: + r[t] = OrderedDict() + r[t][mtime] = c.tmplvars + return r @property - def body(self): - body = "%s" % (self.content) - # get inline images, downsize them and convert them to figures - for shortcode, alt, fname, title, css in self.inline_images: - #fname = os.path.basename(fname) - im = self._find_image(fname) - if not im: - continue - - im.alt = alt - im.title = title - im.cssclass = css - body = body.replace(shortcode, str(im)) - return body + @cached() + def tmplvars(self): + return { + 'title': self.title, + 'category': self.category, + 'lang': self.lang, + 'slug': self.name, + 'is_reply': self.is_reply, + 'summary': self.summary, + 'html_summary': self.html_summary, + 'html_content': self.html_content, + 'pubtime': self.published.format(settings.dateformat.get('iso')), + 'pubdate': self.published.format(settings.dateformat.get('display')), + 'year': int(self.published.format('YYYY')), + 'licence': self.licence, + 'replies': self.replies, + 'reactions': self.reactions, + 'syndicate': self.syndicate, + 'url': self.url, + } @property - def html(self): - # return shared.Pandoc().convert(html) - return shared.PandocNG("%s" % (self.body)).html + def template(self): + return "%s.j2.html" % (self.__class__.__name__) @property - def title(self): - maybe = self.meta.get('title', False) - if maybe: - return maybe - if self.is_reply: - return "RE: %s" % self.is_reply - return self.published.format(shared.ARROWFORMAT['display']) + def renderdir(self): + return os.path.join( + settings.paths.get('build'), + self.name + ) @property - def review(self): - return self.meta.get('review', False) + def renderfile(self): + return os.path.join(self.renderdir, 'index.html') @property - def summary(self): - s = self.meta.get('summary', '') - if not s: - return s - if not hasattr(self, '_summary'): - #self._summary = shared.Pandoc().convert(s) - self._summary = shared.PandocNG(s).html + def exists(self): + if settings.args.get('force'): + return False + elif not os.path.exists(self.renderfile): + return False + elif self.mtime > os.path.getmtime(self.renderfile): + return False + else: + return True - return self._summary + async def render(self): + if self.exists: + return + r = J2.get_template(self.template).render({ + 'post': self.tmplvars, + 'site': settings.site, + 'author': settings.author, + 'meta': settings.meta, + 'licence': settings.licence, + 'tips': settings.tips, + 'labels': settings.labels + }) + if not os.path.isdir(self.renderdir): + logging.info("creating directory: %s", self.renderdir) + os.makedirs(self.renderdir) + with open(self.renderfile, 'wt') as f: + logging.info("rendering to %s", self.renderfile) + f.write(r) - @property - def shortslug(self): - return shared.baseN(self.pubtime) - @property - def syndicate(self): - urls = self.meta.get('syndicate', []) - if self.photo and self.photo.is_photo: - urls.append("https://brid.gy/publish/flickr") - return urls +class WebImage(object): + def __init__(self, fpath, mdimg, parent): + logging.debug("loading image: %s", fpath) + self.mdimg = mdimg + self.fpath = fpath + self.parent = parent + self.mtime = os.path.getmtime(self.fpath) + self.fname, self.fext = os.path.splitext(os.path.basename(fpath)) + self.resized_images = [ + (k, self.Resized(self, k)) + for k in settings.photo.get('sizes').keys() + if k < max(self.width, self.height) + ] + if not len(self.resized_images): + self.resized_images.append(( + max(self.width, self.height), + self.Resized(self, max(self.width, self.height)) + )) - @property - def tags(self): - return self.meta.get('tags', []) + def __str__(self): + if len(self.mdimg.css): + return self.mdimg.match + tmpl = J2.get_template("%s.j2.html" % (self.__class__.__name__)) + return tmpl.render({ + 'src': self.displayed.relpath, + 'href': self.linked.relpath, + 'width': self.displayed.width, + 'height': self.displayed.height, + 'title': self.title, + 'caption': self.caption, + 'exif': self.exif, + 'is_photo': self.is_photo, + }) @property - def description(self): - return html.escape(self.meta.get('summary', '')) + @cached() + def meta(self): + return exiftool.Exif(self.fpath) @property - def oembedvars(self): - if not hasattr(self, '_oembedvars'): - self._oembedvars = { - "version": "1.0", - "type": "link", - "title": self.title, - "url": "%s/%s/" % (shared.site.get('url'), self.fname), - "author_name": shared.site.get('author').get('name'), - "author_url": shared.site.get('author').get('url'), - "provider_name": shared.site.get('title'), - "provider_url": shared.site.get('url'), - } - if self.photo: - self._oembedvars.update({ - "type": "photo", - "width": self.photo.tmplvars.get('width'), - "height": self.photo.tmplvars.get('height'), - "url": self.photo.tmplvars.get('src'), - }) - return self._oembedvars + def caption(self): + if len(self.mdimg.alt): + return self.mdimg.alt + else: + return self.meta.get('Description', '') @property - def tmplvars(self): - # very simple caching because we might use this 4 times: - # post HTML, category, front posts and atom feed - if not hasattr(self, '_tmplvars'): - self._tmplvars = { - 'title': self.title, - 'pubtime': self.published.format( - shared.ARROWFORMAT['iso'] - ), - 'pubdate': self.published.format( - shared.ARROWFORMAT['display'] - ), - 'pubrfc': self.published.format( - shared.ARROWFORMAT['rcf'] - ), - 'category': self.category, - 'html': self.html, - 'lang': self.lang, - 'slug': self.fname, - 'shortslug': self.shortslug, - 'licence': self.licence, - 'is_reply': self.is_reply, - 'age': int(self.published.format('YYYY')) - int(arrow.utcnow().format('YYYY')), - 'summary': self.summary, - 'description': self.description, - 'replies': self.replies, - 'reactions': self.reactions, - 'syndicate': self.syndicate, - 'tags': self.tags, - 'photo': False, - 'enclosure': False, - 'review': self.review, - 'is_old': self.is_old - } - if self.photo: - self._tmplvars.update({ - 'photo': str(self.photo), - 'enclosure': { - 'mime': self.photo.mime_type, - 'size': self.photo.mime_size, - 'url': self.photo.href - } - }) - - return self._tmplvars - - async def render(self): - logging.info('rendering %s', self.fname) - o = self.htmlfile - - tmplfile = "%s.html" % (self.__class__.__name__) - r = shared.j2.get_template(tmplfile).render({ - 'post': self.tmplvars, - 'site': shared.site, - }) - - d = os.path.dirname(o) - if not os.path.isdir(d): - logging.debug('creating directory %s', d) - os.makedirs(d) - with open(o, 'wt') as out: - logging.debug('writing file %s', o) - out.write(r) - # use the comment time, not the source file time for this - os.utime(o, (self.stime, self.stime)) - # oembed = os.path.join( - #shared.config.get('common', 'build'), - # self.fname, - # 'oembed.json' - # ) - # with open(oembed, 'wt') as out: - #logging.debug('writing oembed file %s', oembed) - # out.write(json.dumps(self.oembedvars)) + def title(self): + if len(self.mdimg.title): + return self.mdimg.title + else: + return self.meta.get('Headline', self.fname) - def __repr__(self): - return "%s/%s" % (self.category, self.fname) + @property + def tags(self): + return list(set(self.meta.get('Subject', []))) - -class WebImage(object): - def __init__(self, fpath): - logging.info("parsing image: %s", fpath) - self.fpath = fpath - self.mtime = os.path.getmtime(self.fpath) - bname = os.path.basename(fpath) - self.fname, self.fext = os.path.splitext(bname) - self.title = '' - self.alt = bname - self.target = '' - self.cssclass = '' + @property + def published(self): + return arrow.get( + self.meta.get('ReleaseDate', self.meta.get('ModifyDate')) + ) @property - def fm_content(self): - return self.meta.get('Description', '') + def width(self): + return int(self.meta.get('ImageWidth')) @property - def fm_meta(self): - return { - 'published': self.meta.get( - 'ReleaseDate', - self.meta.get('ModifyDate') - ), - 'title': self.meta.get('Headline', self.fname), - 'tags': list(set(self.meta.get('Subject', []))), - } + def height(self): + return int(self.meta.get('ImageHeight')) @property def mime_type(self):@@ -909,92 +540,61 @@ return str(self.meta.get('MIMEType', 'image/jpeg'))
@property def mime_size(self): - if self.is_downsizeable: - try: - return int(self.sizes[-1][1]['fsize']) - except Exception as e: - pass - return int(self.meta.get('FileSize')) + return os.path.getsize(self.linked.fpath) @property - def href(self): - if len(self.target): - return self.target - - if not self.is_downsizeable: - return False + def displayed(self): + ret = self.resized_images[0][1] + for size, r in self.resized_images: + if size == settings.photo.get('default'): + ret = r + return ret - return self.sizes[-1][1]['url'] + @property + def linked(self): + m = 0 + ret = self.resized_images[0][1] + for size, r in self.resized_images: + if size > m: + m = size + ret = r + return ret @property def src(self): - # is the image is too small to downsize, it will be copied over - # so the link needs to point at - src = "/%s/%s" % ( - shared.config.get('common', 'files'), - "%s%s" % (self.fname, self.fext) - ) - - if self.is_downsizeable: - try: - src = [ - e for e in self.sizes - if e[0] == shared.config.getint('photo', 'default') - ][0][1]['url'] - except BaseException: - pass - return src + return self.displayed.url @property - def meta(self): - if not hasattr(self, '_exif'): - # reading EXIF is expensive enough even with a static generator - # to consider caching it, so I'll do that here - cpath = os.path.join( - shared.config.get('var', 'cache'), - "%s.exif.json" % self.fname - ) - - if os.path.exists(cpath): - cmtime = os.path.getmtime(cpath) - if cmtime >= self.mtime: - with open(cpath, 'rt') as f: - self._exif = json.loads(f.read()) - return self._exif - - self._exif = shared.ExifTool(self.fpath).read() - if not os.path.isdir(shared.config.get('var', 'cache')): - os.makedirs(shared.config.get('var', 'cache')) - with open(cpath, 'wt') as f: - f.write(json.dumps(self._exif)) - return self._exif + def href(self): + return self.linked.url @property def is_photo(self): - # missing regex from config - if 'photo' not in shared.REGEX: - logging.debug('%s photo regex missing from config') + r = settings.photo.get('re_author', None) + if not r: return False - cpr = self.meta.get('Copyright', '') art = self.meta.get('Artist', '') - # both Artist and Copyright missing from EXIF if not cpr and not art: - logging.debug('%s Artist or Copyright missing from EXIF') return False - # we have regex, Artist and Copyright, try matching them - pattern = re.compile(shared.config.get('photo', 'regex')) - if pattern.search(cpr) or pattern.search(art): + if r.search(cpr) or r.search(art): return True - - logging.debug('%s patterns did not match') return False @property def exif(self): - exif = {} + exif = { + 'camera': '', + 'aperture': '', + 'shutter_speed': '', + 'focallength': '', + 'iso': '', + 'lens': '', + 'geo_latitude': '', + 'geo_longitude': '', + } if not self.is_photo: return exif@@ -1022,109 +622,25 @@ exif[ekey] = maybe
break return exif - @property - def sizes(self): - sizes = [] - _max = max( - int(self.meta.get('ImageWidth')), - int(self.meta.get('ImageHeight')) - ) - - for size in shared.config.options('downsize'): - if _max < int(size): - continue - - name = '%s_%s%s' % ( - self.fname, - shared.config.get('downsize', size), - self.fext - ) - - fpath = os.path.join( - shared.config.get('common', 'build'), - shared.config.get('common', 'files'), - name - ) - - exists = os.path.isfile(fpath) - # in case there is a downsized image compare against the main - # file's mtime and invalidate the existing if it's older - if exists: - mtime = os.path.getmtime(fpath) - if self.mtime > mtime: - exists = False - - smeta = { - 'fpath': fpath, - 'exists': False, - 'url': "%s/%s/%s" % ( - shared.config.get('site', 'url'), - shared.config.get('common', 'files'), - name - ), - 'crop': shared.config.getboolean( - 'crop', - size, - fallback=False - ), - 'fsize': int(self.meta.get('FileSize')) - } - - if os.path.isfile(fpath): - smeta.update({ - 'exists': True, - 'fsize': os.path.getsize(fpath) - }) - - sizes.append(( - int(size), - smeta - )) - return sorted(sizes, reverse=False) - - @property - def is_downsizeable(self): - """ Check if the image is large enought to downsize it """ - ftype = self.meta.get('FileType', None) - if not ftype: - return False - elif ftype.lower() != 'jpeg' and ftype.lower() != 'png': - return False - - _max = max( - int(self.meta.get('ImageWidth')), - int(self.meta.get('ImageHeight')) - ) - _min = shared.config.getint('photo', 'default') - if _max > _min: - return True - - return False - def _maybe_watermark(self, img): - """ Composite image by adding watermark file over it """ - if not self.is_photo: - logging.debug("not watermarking: not a photo") return img - wmarkfile = shared.config.get('photo', 'watermark') - if not os.path.isfile(wmarkfile): - logging.debug("not watermarking: watermark not found") + wmarkfile = settings.paths.get('watermark') + if not os.path.exists(wmarkfile): return img - logging.debug("%s is a photo, applying watermarking", self.fpath) with wand.image.Image(filename=wmarkfile) as wmark: - if img.width > img.height: - w = img.width * 0.2 + if self.width > self.height: + w = self.width * 0.2 h = wmark.height * (w / wmark.width) - x = img.width - w - (img.width * 0.01) - y = img.height - h - (img.height * 0.01) + x = self.width - w - (self.width * 0.01) + y = self.height - h - (self.height * 0.01) else: - w = img.height * 0.16 + w = self.height * 0.16 h = wmark.height * (w / wmark.width) - x = img.width - h - (img.width * 0.01) - y = img.height - w - (img.height * 0.01) + x = self.width - h - (self.width * 0.01) + y = self.height - w - (self.height * 0.01) w = round(w) h = round(h)@@ -1132,605 +648,439 @@ x = round(x)
y = round(y) wmark.resize(w, h) - if img.width <= img.height: + if self.width <= self.height: wmark.rotate(-90) img.composite(image=wmark, left=x, top=y) - return img - def _copy(self): - fname = "%s%s" % (self.fname, self.fext) - fpath = os.path.join( - shared.config.get('common', 'build'), - shared.config.get('common', 'files'), - fname - ) - if os.path.isfile(fpath): - mtime = os.path.getmtime(fpath) - if self.mtime <= mtime: - return - logging.info("copying %s to build dir", fname) - shutil.copy(self.fpath, fpath) + async def downsize(self): + need = False + for size, resized in self.resized_images: + if not resized.exists or settings.args.get('regenerate'): + need = True + break + if not need: + return - def _intermediate_dimension(self, size, width, height, crop=False): - """ Calculate intermediate resize dimension and return a tuple of width, height """ - ratio = max(width, height) / min(width, height) - horizontal = True if (width / height) >= 1 else False - - # panorama: reverse "horizontal" because the limit should be on - # the shorter side, not the longer, and make it a bit smaller, than - # the actual limit - # 2.39 is the wide angle cinematic view: anything wider, than that - # is panorama land - if ratio > 2.4 and not crop: - size = int(size * 0.6) - horizontal = not horizontal + with wand.image.Image(filename=self.fpath) as img: + img.auto_orient() + img = self._maybe_watermark(img) + for size, resized in self.resized_images: + if not resized.exists or settings.args.get('regenerate'): + logging.info( + "resizing image: %s to size %d", + os.path.basename(self.fpath), + size + ) + await resized.make(img) - if (horizontal and not crop) \ - or (not horizontal and crop): - w = size - h = int(float(size / width) * height) - else: - h = size - w = int(float(size / height) * width) - return (w, h) + class Resized: + def __init__(self, parent, size, crop=False): + self.parent = parent + self.size = size + self.crop = crop - def _intermediate(self, img, size, target, crop=False): - if img.width < size and img.height < size: - return False + @property + def suffix(self): + return settings.photo.get('sizes').get(self.size, '') - with img.clone() as thumb: - width, height = self._intermediate_dimension( - size, - img.width, - img.height, - crop + @property + def fname(self): + return "%s%s%s" % ( + self.parent.fname, + self.suffix, + self.parent.fext ) - thumb.resize(width, height) - if crop: - thumb.liquid_rescale(size, size, 1, 1) + @property + def fpath(self): + return os.path.join( + self.parent.parent.renderdir, + self.fname + ) - if self.meta.get('FileType', 'jpeg').lower() == 'jpeg': - thumb.compression_quality = 94 - thumb.unsharp_mask( - radius=1, - sigma=0.5, - amount=0.7, - threshold=0.5 + @property + def url(self): + return "%s/%s/%s" % ( + settings.site.get('url'), + self.parent.parent.name, + "%s%s%s" % ( + self.parent.fname, + self.suffix, + self.parent.fext ) - thumb.format = 'pjpeg' - - # this is to make sure pjpeg happens - with open(target, 'wb') as f: - logging.info("writing %s", target) - thumb.save(file=f) + ) - @property - def needs_downsize(self): - needed = False - for (size, downsized) in self.sizes: - if downsized.get('exists', False): - logging.debug( - "size %d exists: %s", - size, - downsized.get('fpath') - ) - continue - logging.debug( - "size %d missing: %s", - size, - downsized.get('fpath') + @property + def relpath(self): + return "%s/%s" % ( + self.parent.parent.renderdir.replace( + settings.paths.get('build'), '' + ), + self.fname ) - needed = True - return needed - async def downsize(self): - if not self.is_downsizeable: - return self._copy() + @property + def exists(self): + if os.path.isfile(self.fpath): + if os.path.getmtime(self.fpath) >= self.parent.mtime: + return True + return False - if not self.needs_downsize and not shared.config.getboolean( - 'params', 'regenerate'): - return + @property + def width(self): + return self.dimensions[0] - build_files = os.path.join( - shared.config.get('common', 'build'), - shared.config.get('common', 'files'), - ) + @property + def height(self): + return self.dimensions[1] - if not os.path.isdir(build_files): - os.makedirs(build_files) + @property + def dimensions(self): + width = self.parent.width + height = self.parent.height + size = self.size - logging.info("downsizing %s%s", self.fname, self.fext) - with wand.image.Image(filename=self.fpath) as img: - img.auto_orient() - img = self._maybe_watermark(img) - for (size, downsized) in self.sizes: - self._intermediate( - img, - size, - downsized['fpath'], - downsized['crop'] - ) + ratio = max(width, height) / min(width, height) + horizontal = True if (width / height) >= 1 else False - @property - def src_size(self): - width = int(self.meta.get('ImageWidth')) - height = int(self.meta.get('ImageHeight')) + # panorama: reverse "horizontal" because the limit should be on + # the shorter side, not the longer, and make it a bit smaller, than + # the actual limit + # 2.39 is the wide angle cinematic view: anything wider, than that + # is panorama land + if ratio > 2.4 and not self.crop: + size = int(size * 0.6) + horizontal = not horizontal - if not self.is_downsizeable: - return width, height + if (horizontal and not self.crop) \ + or (not horizontal and self.crop): + w = size + h = int(float(size / width) * height) + else: + h = size + w = int(float(size / height) * width) + return (w, h) - return self._intermediate_dimension( - shared.config.getint('photo', 'default'), - width, - height - ) + async def make(self, original): + if not os.path.isdir(os.path.dirname(self.fpath)): + os.makedirs(os.path.dirname(self.fpath)) - @property - def tmplvars(self): - src_width, src_height = self.src_size + with original.clone() as thumb: + thumb.resize(self.width, self.height) - return { - 'src': self.src, - 'width': src_width, - 'height': src_height, - 'target': self.href, - 'css': self.cssclass, - 'title': self.title, - 'alt': self.alt, - 'exif': self.exif, - 'is_photo': self.is_photo, - 'author': self.meta.get('Artist', ''), - } + if self.crop: + thumb.liquid_rescale(self.size, self.size, 1, 1) - def __repr__(self): - return "Image: %s, photo: %r, EXIF: %s" % ( - self.fname, self.is_photo, self.exif - ) + if self.parent.meta.get('FileType', 'jpeg').lower() == 'jpeg': + thumb.compression_quality = 94 + thumb.unsharp_mask( + radius=1, + sigma=0.5, + amount=0.7, + threshold=0.5 + ) + thumb.format = 'pjpeg' - def __str__(self): - tmplfile = "%s.html" % (self.__class__.__name__) - return shared.j2.get_template(tmplfile).render({ - 'photo': self.tmplvars - }) + # this is to make sure pjpeg happens + with open(self.fpath, 'wb') as f: + logging.info("writing %s", self.fpath) + thumb.save(file=f) -class Comment(object): - def __init__(self, fpath): - logging.debug("initiating comment object from %s", fpath) - self.fpath = fpath - self.mtime = os.path.getmtime(self.fpath) - with open(self.fpath, mode='rt') as f: - self.fm = frontmatter.parse(f.read()) - self.meta, self.content = self.fm +class AsyncWorker(object): + def __init__(self): + self._tasks = [] + self._loop = asyncio.get_event_loop() - @property - def dt(self): - return arrow.get(self.meta.get('date')) + def append(self, job): + task = self._loop.create_task(job) + self._tasks.append(task) - @property - def html(self): - #html = "%s" % (self.content) - # return shared.Pandoc().convert(html) - return shared.PandocNG("%s" % (self.content)).html + def run(self): + w = asyncio.wait(self._tasks, return_when=asyncio.FIRST_EXCEPTION) + self._loop.run_until_complete(w) - @property - def target(self): - t = urlparse(self.meta.get('target')) - return t.path.rstrip('/').strip('/').split('/')[-1] - @property - def source(self): - return self.meta.get('source') - - @property - def author(self): - r = { - 'name': urlparse(self.source).hostname, - 'url': self.source - } - - author = self.meta.get('author') - if not author: - return r - - if 'name' in author: - r.update({'name': self.meta.get('author').get('name')}) - elif 'url' in author: - r.update( - {'name': urlparse(self.meta.get('author').get('url')).hostname}) - +class NginxConf(dict): + def __str__(self): + r = '' + for key in self: + r = "%slocation /%s { %s; }\n" % (r, key, self[key]) return r - @property - def type(self): - # caching, because calling Pandoc is expensive - if not hasattr(self, '_type'): - self._type = 'webmention' - t = self.meta.get('type', 'webmention') - if t != 'webmention': - self._type = '★' - - if len(self.content): - #maybe = shared.Pandoc('plain').convert(self.content) - maybe = shared.PandocNG(self.content).txt - if maybe in UNICODE_EMOJI: - self._type = maybe - return self._type - - @property - def tmplvars(self): - if not hasattr(self, '_tmplvars'): - self._tmplvars = { - 'author': self.author, - 'source': self.source, - 'pubtime': self.dt.format(shared.ARROWFORMAT['iso']), - 'pubdate': self.dt.format(shared.ARROWFORMAT['display']), - 'html': self.html, - 'type': self.type - } - return self._tmplvars - - def __repr__(self): - return "Comment from %s for %s" % ( - self.source, self.target + def save(self): + fpath = os.path.join( + settings.paths.get('build'), + '.nginx.conf' ) - - def __str__(self): - tmplfile = "%s.html" % (__class__.__name__) - return shared.j2.get_template(tmplfile).render({ - 'comment': self.tmplvars - }) + with open(fpath, 'wt') as f: + f.write(str(self)) -class Webmention(object): - def __init__(self, source, target, dt=arrow.utcnow().timestamp): - self.source = source - self.target = target - self.dt = arrow.get(dt).to('utc') - logging.info( - "processing webmention %s => %s", - self.source, - self.target - ) - self._source = None +class Category(dict): + def __init__(self, name=''): + self.name = name + self.page = 1 - def send(self): - rels = shared.XRay(self.target).set_discover().parse() - endpoint = False - if 'rels' not in rels: - logging.debug("no rel found for %s", self.target) - return True - for k in rels.get('rels').keys(): - if 'webmention' in k: - endpoint = rels.get('rels').get(k).pop() - break - if not endpoint: - logging.debug("no endpoint found for %s", self.target) - return True - logging.info( - "Sending webmention to endpoint: %s, source: %s, target: %s", - endpoint, - self.source, - self.target, - ) - try: - p = requests.post( - endpoint, - data={ - 'source': self.source, - 'target': self.target - } + def __setitem__(self, key, value): + if key in self: + raise LookupError( + "key '%s' already exists, colliding posts are: %s vs %s" % ( + key, + self[key].fpath, + value.fpath, + ) ) - if p.status_code == requests.codes.ok: - logging.info("webmention sent") - return True - elif p.status_code == 400 and 'brid.gy' in self.target: - logging.warning( - "potential bridgy duplicate: %s %s", - p.status_code, - p.text) - return True - else: - logging.error( - "webmention failure: %s %s", - p.status_code, - p.text) - return False - except Exception as e: - logging.error("sending webmention failed: %s", e) - return False + dict.__setitem__(self, key, value) - def receive(self): - head = requests.head(self.source) - if head.status_code == 410: - self._delete() - return - elif head.status_code != requests.codes.ok: - logging.error( - "webmention source failure: %s %s", - head.status_code, - self.source - ) - return + def get_posts(self, start=0, end=-1): + return [ + self[k].tmplvars + for k in self.sortedkeys[start:end] + ] - self._source = shared.XRay(self.source).parse() - if 'data' not in self._source: - logging.error( - "no data found in webmention source: %s", - self.source) - return - self._save() + @property + def sortedkeys(self): + return list(sorted(self.keys(), reverse=True)) - def _delete(self): - if os.path.isfile(self.fpath): - logging.info("Deleting webmention %s", self.fpath) - os.unlink(self.fpath) - return + @property + def display(self): + return settings.categorydisplay.get(self.name, '') - def _save(self): - fm = frontmatter.loads('') - fm.content = self.content - fm.metadata = self.meta - with open(self.fpath, 'wt') as f: - logging.info("Saving webmention to %s", self.fpath) - f.write(frontmatter.dumps(fm)) - return + @property + def title(self): + if len(self.name): + return "%s - %s" % (self.name, settings.site.get('domain')) + else: + return settings.site.get('title') @property - def relation(self): - r = 'webmention' - k = self._source.get('data').keys() - for maybe in ['in-reply-to', 'repost-of', 'bookmark-of', 'like-of']: - if maybe in k: - r = maybe - break - return r + def url(self): + if len(self.name): + url = "/category/%s/" % (self.name) + else: + url = '/' + return url @property - def meta(self): - if not hasattr(self, '_meta'): - self._meta = { - 'author': self._source.get('data').get('author'), - 'type': self.relation, - 'target': self.target, - 'source': self.source, - 'date': self._source.get('data').get('published'), - } - return self._meta + def template(self): + return "%s.j2.html" % (self.__class__.__name__) @property - def content(self): - if 'content' not in self._source.get('data'): - return '' - elif 'html' in self._source.get('data').get('content'): - what = self._source.get('data').get('content').get('html') - elif 'text' in self._source.get('data').get('content'): - what = self._source.get('data').get('content').get('text') + def renderdir(self): + if len(self.name): + return os.path.join( + settings.paths.get('build'), + 'category', + self.name + ) else: - return '' - # return shared.Pandoc('html').convert(what) - return shared.PandocNG(what).html + return settings.paths.get('build') @property - def fname(self): - return "%d-%s.md" % ( - self.dt.timestamp, - shared.slugfname(self.source) - ) + def tmplvars(self): + return { + 'name': self.name, + 'display': self.display, + 'url': self.url, + 'feed': "%s%s/" % (self.url, 'feed'), + 'title': self.title + } @property - def fpath(self): - tdir = os.path.join( - shared.config.get('dirs', 'comment'), - self.target.rstrip('/').strip('/').split('/')[-1] - ) - if not os.path.isdir(tdir): - os.makedirs(tdir) - return os.path.join( - tdir, - self.fname - ) + def mtime(self): + return self[self.sortedkeys[0]].mtime + @property + def exists(self): + if settings.args.get('force'): + return False + renderfile = os.path.join(self.renderdir, 'index.html') + if not os.path.exists(renderfile): + return False + elif self.mtime > os.path.getmtime(renderfile): + return False + else: + return True -class Worker(object): - def __init__(self): - self._tasks = [] - self._loop = asyncio.get_event_loop() + def ping_websub(self): + return + # TODO aiohttp? + ## ping pubsub + #r = requests.post( + #shared.site.get('websub').get('hub'), + #data={ + #'hub.mode': 'publish', + #'hub.url': flink + #} + #) + #logging.info(r.text) - def append(self, job): - task = self._loop.create_task(job) - self._tasks.append(task) + def render_feed(self): + logging.info('rendering category "%s" ATOM feed', self.name) + start = 0 + end = int(settings.site.get('pagination')) - def run(self): - w = asyncio.wait(self._tasks) - self._loop.run_until_complete(w) - self._loop.close() + dirname = os.path.join(self.renderdir,'feed') + if not os.path.isdir(dirname): + os.makedirs(dirname) + fg = FeedGenerator() -def setup(): - """ parse input parameters and add them as params section to config """ - parser = argparse.ArgumentParser(description='Parameters for NASG') + flink = "%s%sfeed/" % (settings.site.get('url'), self.url) - booleanparams = { - 'regenerate': 'force downsizing images', - 'force': 'force rendering HTML', - } + fg.id(flink) + fg.link(href=flink, rel='self') + fg.title(self.title) - for k, v in booleanparams.items(): - parser.add_argument( - '--%s' % (k), - action='store_true', - default=False, - help=v - ) - - parser.add_argument( - '--loglevel', - default='warning', - help='change loglevel' - ) - - if not shared.config.has_section('params'): - shared.config.add_section('params') - - params = vars(parser.parse_args()) - for k, v in params.items(): - shared.config.set('params', k, str(v)) - - # remove the rest of the potential loggers - while len(logging.root.handlers) > 0: - logging.root.removeHandler(logging.root.handlers[-1]) - - logging.basicConfig( - level=shared.LLEVEL[shared.config.get('params', 'loglevel')], - format='%(asctime)s - %(levelname)s - %(message)s' - ) - - -def youngest_mtime(root): - youngest = 0 - files = glob.glob(os.path.join(root, '**'), recursive=True) - for f in files: - mtime = os.path.getmtime(f) - if mtime > youngest: - youngest = mtime - return youngest + fg.author({ + 'name': settings.author.get('name'), + 'email': settings.author.get('email') + }) + fg.logo('%s/favicon.png' % settings.site.get('url')) -def build(): - setup() + fg.updated(arrow.get(self.mtime).to('utc').datetime) - worker = Worker() - content = Content() - sdb = shared.SearchDB() - magic = MagicPHP() - - collector_front = Category(is_front=True) - collector_categories = NoDupeContainer() - sitemap = {} - - for f, post in content: - logging.info("PARSING %s", f) - post.init_extras() - post.queue_webmentions() - - # add to sitemap - sitemap.update({post.url: post.mtime}) - - # extend redirects - for r in post.redirects: - magic.redirects.append((r, post.fname)) - - # add post to search, if needed - if not sdb.is_uptodate(post.fname, post.mtime): - sdb.append( - post.fname, - post.corpus, - post.mtime, - post.url, - post.category, - post.title + for post in self.get_posts(start,end): + dt = arrow.get(post.get('pubtime')) + fe = fg.add_entry() + fe.id(post.get('url')) + fe.link(href=post.get('url')) + fe.title(post.get('title')) + fe.published(dt.datetime) + fe.content( + post.get('html_content'), + type='CDATA' ) - # add render task, if needed - if not post.is_uptodate or shared.config.getboolean('params', 'force'): - worker.append(post.render()) + fe.rights('%s %s %s' % ( + post.get('licence').upper(), + settings.author.get('name'), + dt.format('YYYY') + )) + #if p.get('enclosure'): + #enclosure = p.get('enclosure') + #fe.enclosure( + #enclosure.get('url'), + #"%d" % enclosure.get('size'), + #enclosure.get('mime') + #) + atom = os.path.join(dirname, 'index.xml') + with open(atom, 'wb') as f: + logging.info('writing file: %s', atom) + f.write(fg.atom_str(pretty=True)) - # collect images to downsize - for fname, im in post.images: - worker.append(im.downsize()) - # skip adding future posts to any category - if post.is_future: - continue + def render_page(self, pagenum=1, pages=1): + if self.display == 'flat': + start = 1 + end = -1 + else: + pagination = int(settings.site.get('pagination')) + start = int((pagenum - 1) * pagination) + end = int(start + pagination) - # skip categories starting with _ - if post.category.startswith('_'): - continue + posts = self.get_posts(start, end) + r = J2.get_template(self.template).render({ + 'site': settings.site, + 'author': settings.author, + 'meta': settings.meta, + 'licence': settings.licence, + 'tips': settings.tips, + 'labels': settings.labels, + 'category': self.tmplvars, + 'pages': { + 'current': pagenum, + 'total': pages, + }, + 'posts': posts, + }) + if pagenum > 1: + renderdir = os.path.join(self.renderdir, 'page', str(pagenum)) + else: + renderdir = self.renderdir + if not os.path.isdir(renderdir): + os.makedirs(renderdir) + renderfile = os.path.join(renderdir, 'index.html') + with open(renderfile, 'wt') as f: + f.write(r) - # get the category otherwise - if post.category not in collector_categories: - c = Category(post.category) - collector_categories.append(post.category, c) + async def render(self): + if self.exists: + return + + if self.display == 'flat': + pagination = len(self) else: - c = collector_categories[post.category] + pagination = int(settings.site.get('pagination')) - # add post to category - c.append(post) + pages = ceil(len(self) / pagination) + page = 1 + while page <= pages: + self.render_page(page, pages) + page = page + 1 + self.render_feed() + self.ping_websub() - # add post to front - collector_front.append(post) - # write search db - sdb.finish() +def make(): + start = int(round(time.time() * 1000)) + content = settings.paths.get('content') - # render front - if not collector_front.is_uptodate or \ - shared.config.getboolean('params', 'force'): - worker.append(collector_front.render()) + nginxrules = NginxConf() + for e in glob.glob(os.path.join(content, '*', '*.lnk')): + post = Redirect(e) + location, rule = post.nginx + nginxrules[location] = rule + for e in glob.glob(os.path.join(content, '*', '*.ptr')): + post = Gone(e) + location, rule = post.nginx + nginxrules[location] = rule + nginxrules.save() - # render categories - for name, c in collector_categories: - if not c.is_uptodate or shared.config.getboolean('params', 'force'): - worker.append(c.render()) + worker = AsyncWorker() + categories = {} + categories['/'] = Category() + sitemap = OrderedDict() + for e in sorted(glob.glob(os.path.join(content, '*', '*', 'index.md'))): + post = Singular(e) + if post.category not in categories: + categories[post.category] = Category(post.category) + c = categories[post.category] + c[post.published.timestamp] = post + if post.is_front: + c = categories['/'] + c[post.published.timestamp] = post + for i in post.images.values(): + worker.append(i.downsize()) + worker.append(post.render()) + sitemap[post.url] = post.mtime - # add magic.php rendering - worker.append(magic.render()) + for category in categories.values(): + worker.append(category.render()) - # do all the things! worker.run() + logging.info('worker finished') - # send webmentions - this is synchronous due to the SQLite locking - wdb = shared.WebmentionQueue() - for out in wdb.get_outbox(): - wm = Webmention( - out.get('source'), - out.get('target'), - out.get('dt') + # copy static + for e in glob.glob(os.path.join(content, '*.*')): + t = os.path.join( + settings.paths.get('build'), + os.path.basename(e) ) - if wm.send(): - wdb.entry_done(out.get('id')) - wdb.finish() + if os.path.exists(t) and os.path.getmtime(e) <= os.path.getmtime(t): + continue + cp(e, t) - # copy static - logging.info('copying static files') - src = shared.config.get('dirs', 'static') - for item in os.listdir(src): - s = os.path.join(src, item) - stime = os.path.getmtime(s) - d = os.path.join(shared.config.get('common', 'build'), item) - dtime = 0 - if os.path.exists(d): - dtime = os.path.getmtime(d) + # dump sitemap + t = os.path.join(settings.paths.get('build'), 'sitemap.txt') + with open(t, 'wt') as f: + f.write("\n".join(sorted(sitemap.keys()))) - if not os.path.exists(d) or shared.config.getboolean( - 'params', 'force') or dtime < stime: - logging.debug("copying static file %s to %s", s, d) - shutil.copy2(s, d) - if '.html' in item: - url = "%s/%s" % (shared.config.get('site', 'url'), item) - sitemap.update({ - url: os.path.getmtime(s) - }) - - # dump sitemap, if needed - sitemapf = os.path.join( - shared.config.get( - 'common', - 'build'), - 'sitemap.txt') - sitemap_update = True - if os.path.exists(sitemapf): - if int(max(sitemap.values())) <= int(os.path.getmtime(sitemapf)): - sitemap_update = False - - if sitemap_update: - logging.info('writing updated sitemap') - with open(sitemapf, 'wt') as smap: - smap.write("\n".join(sorted(sitemap.keys()))) - + end = int(round(time.time() * 1000)) + logging.info('process took %d ms' % (end - start)) if __name__ == '__main__': - build() + make()
@@ -0,0 +1,27 @@
+arrow==0.12.1 +bleach==2.1.3 +certifi==2018.4.16 +chardet==3.0.4 +decorator==4.3.0 +emoji==0.5.0 +feedgen==0.7.0 +html5lib==1.0.1 +idna==2.7 +Jinja2==2.10 +langdetect==1.0.7 +lxml==4.2.3 +Markdown==2.6.11 +markdown-urlize==0.2.0 +MarkupSafe==1.0 +Pygments==2.2.0 +python-dateutil==2.7.3 +python-frontmatter==0.4.2 +PyYAML==3.13 +requests==2.19.1 +six==1.11.0 +unicode-slugify==0.1.3 +Unidecode==1.0.22 +urllib3==1.23 +validators==0.12.2 +Wand==0.4.4 +webencodings==0.5.1
@@ -1,109 +0,0 @@
-#!/usr/bin/env python3 - -__author__ = "Peter Molnar" -__copyright__ = "Copyright 2017-2018, Peter Molnar" -__license__ = "GNU LGPLv3 " -__maintainer__ = "Peter Molnar" -__email__ = "mail@petermolnar.net" - -from sanic import Sanic -import sanic.response -#from sanic.log import log as logging -import logging -import validators -import urllib.parse -import shared -import envelope -import socket - -if __name__ == '__main__': - # log_config=None prevents creation of access_log and error_log files - # since I'm running this from systemctl it already goes into syslog - app = Sanic('router') - # this is read only this way! - sdb = shared.SearchDB() - - @app.route("/oauth1", methods=["GET"]) - async def oauth1(request): - token = request.args.get('oauth_token') - verifier = request.args.get('oauth_verifier') - logging.info( - "incoming oauth request: token was %s ; verifier was %s", - token, - verifier) - tokendb = shared.TokenDB() - tokendb.update_token( - token, - verifier=verifier - ) - return sanic.response.text("OK", status=200) - - @app.route("/search", methods=["GET"]) - async def search(request): - query = request.args.get('s') - r = sdb.html(query) - response = sanic.response.html(r, status=200) - return response - - @app.route("/micropub", methods=["POST", "GET"]) - async def micropub(request): - return sanic.response.text("Not Implemented", status=501) - - @app.route("/micropub-auth", methods=["POST", "GET"]) - async def micropub_auth(request): - return sanic.response.text("Not Implemented", status=501) - - @app.route("/micropub-token", methods=["POST", "GET"]) - async def micropub_token(request): - return sanic.response.text("Not Implemented", status=501) - - @app.route("/webmention", methods=["POST"]) - async def webmention(request): - source = request.form.get('source') - target = request.form.get('target') - - # validate urls - if not validators.url(source): - return sanic.response.text('Invalide source url', status=400) - if not validators.url(target): - return sanic.response.text('Invalide target url', status=400) - - # check if our site is actually the target for the webmention - _target = urllib.parse.urlparse(target) - if _target.hostname not in shared.config.get('site', 'domains'): - return sanic.response.text('target domain is not me', status=400) - - # ignore selfpings - _source = urllib.parse.urlparse(source) - if _source.hostname in shared.config.get('site', 'domains'): - return sanic.response.text('selfpings are not allowed', status=400) - - # it is unfortunate that I need to init this every time, but - # otherwise it'll become read-only for reasons I'm yet to grasp - # the actual parsing will be done at site generation time - wdb = shared.WebmentionQueue() - wdb.maybe_queue(source, target) - - # telegram notification, if set - l = envelope.Letter( - sender=( - 'NASG', - 'nasg@%s' % socket.getfqdn() - ), - recipient=( - shared.config.get('author', 'name'), - shared.config.get('author', 'email') - ), - subject="[webmention] from %s" % _source.hostname, - text='incoming webmention from %s to %s' % ( - source, - target - ) - ) - l.make() - l.send() - response = sanic.response.text("Accepted", status=202) - return response - - #app.run(host="127.0.0.1", port=9002, log_config=None) - app.run(host="127.0.0.1", port=9002)
@@ -1,705 +0,0 @@
-#!/usr/bin/env python3 - -__author__ = "Peter Molnar" -__copyright__ = "Copyright 2017-2018, Peter Molnar" -__license__ = "GNU LGPLv3 " -__maintainer__ = "Peter Molnar" -__email__ = "mail@petermolnar.net" - -import configparser -import os -import re -import glob -import logging -import subprocess -import json -import sqlite3 -import requests -from slugify import slugify -import jinja2 -from inspect import getsourcefile -import sys -import arrow - - -class CMDLine(object): - def __init__(self, executable): - self.executable = self._which(executable) - if self.executable is None: - raise OSError('No %s found in PATH!' % executable) - return - - @staticmethod - def _which(name): - for d in os.environ['PATH'].split(':'): - which = glob.glob(os.path.join(d, name), recursive=True) - if which: - return which.pop() - return None - - -class XRay(CMDLine): - cmd_prefix = 'chdir("/usr/local/lib/php/xray"); include("vendor/autoload.php"); $xray = new p3k\XRay();' - - def __init__(self, url): - super().__init__('php') - self.url = url - self.target = '' - self.cmd = ( - self.executable, - '-r', - '%s; echo(json_encode($xray->parse("%s")));' % ( - self.cmd_prefix, - self.url - ) - ) - - def set_receive(self, target): - self.cmd = ( - self.executable, - '-r', - '%s; echo(json_encode($xray->parse("%s")));' % ( - self.cmd_prefix, - self.url, - target - ) - ) - return self - - def set_discover(self): - self.cmd = ( - self.executable, - '-r', - '%s; echo(json_encode($xray->rels("%s")));' % ( - self.cmd_prefix, - self.url, - ) - ) - return self - - def parse(self): - logging.debug('pulling %s with XRay', self.url) - p = subprocess.Popen( - self.cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - stdout, stderr = p.communicate() - if stderr: - logging.error("Error with XRay: %s", stderr) - - return json.loads(stdout.decode('utf-8').strip()) - - -class PandocNG(CMDLine): - """ Pandoc command line call with piped in- and output """ - i_md = "markdown+" + "+".join([ - 'backtick_code_blocks', - 'auto_identifiers', - 'fenced_code_attributes', - 'definition_lists', - 'grid_tables', - 'pipe_tables', - 'strikeout', - 'superscript', - 'subscript', - 'markdown_in_html_blocks', - 'shortcut_reference_links', - 'autolink_bare_uris', - 'raw_html', - 'link_attributes', - 'header_attributes', - 'footnotes', - ]) - o_md = "markdown-" + "-".join([ - 'raw_html', - 'native_divs', - 'native_spans', - ]) - - def __init__(self, raw): - super().__init__('pandoc') - self.raw = raw - - def _convert(self, i, o): - cmd = ( - self.executable, - '-o-', - '--from=%s' % i, - '--to=%s' % o - ) - logging.debug('converting string with Pandoc') - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - stdout, stderr = p.communicate(input=self.raw.encode()) - if stderr: - logging.error( - "Error during pandoc covert:\n\t%s\n\t%s", - cmd, - stderr - ) - return stdout.decode('utf-8').strip() - - @property - def html(self): - return self._convert( - i=self.i_md, - o="html5" - ) - - @property - def md(self): - return self._convert( - i="html5", - o=self.o_md - ) - - @property - def txt(self): - return self._convert( - i=self.i_md, - o="plain" - ) - - -class ExifTool(CMDLine): - def __init__(self, fpath): - self.fpath = fpath - super().__init__('exiftool') - - @staticmethod - def exifdate2iso(value): - """ converts and EXIF date string to ISO 8601 format - - :param value: EXIF date (2016:05:01 00:08:24) - :type arg1: str - :return: ISO 8601 string with UTC timezone 2016-05-01T00:08:24+0000 - :rtype: str - """ - if not isinstance(value, str): - return value - match = REGEX['exifdate'].match(value) - if not match: - return value - return "%s-%s-%sT%s+0000" % ( - match.group('year'), - match.group('month'), - match.group('day'), - match.group('time') - ) - - def read(self): - cmd = ( - self.executable, - '-sort', - '-json', - '-MIMEType', - '-FileType', - '-FileName', - '-FileSize#', - '-ModifyDate', - '-CreateDate', - '-DateTimeOriginal', - '-ImageHeight', - '-ImageWidth', - '-Aperture', - '-FOV', - '-ISO', - '-FocalLength', - '-FNumber', - '-FocalLengthIn35mmFormat', - '-ExposureTime', - '-Copyright', - '-Artist', - '-Model', - '-GPSLongitude#', - '-GPSLatitude#', - '-LensID', - '-LensSpec', - '-Lens', - '-ReleaseDate', - '-Description', - '-Headline', - '-HierarchicalSubject', - self.fpath - ) - - logging.debug('reading EXIF from %s', self.fpath) - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - stdout, stderr = p.communicate() - if stderr: - logging.error("Error reading EXIF:\n\t%s\n\t%s", cmd, stderr) - - exif = json.loads(stdout.decode('utf-8').strip()).pop() - if 'ReleaseDate' in exif and 'ReleaseTime' in exif: - exif['DateTimeRelease'] = "%s %s" % ( - exif.get('ReleaseDate'), exif.get('ReleaseTime')[:8]) - del(exif['ReleaseDate']) - del(exif['ReleaseTime']) - - for k, v in exif.items(): - exif[k] = self.exifdate2iso(v) - - return exif - - -class BaseDB(object): - def __init__(self, fpath): - self.db = sqlite3.connect(fpath) - self.db.execute('PRAGMA auto_vacuum = INCREMENTAL;') - self.db.execute('PRAGMA journal_mode = MEMORY;') - self.db.execute('PRAGMA temp_store = MEMORY;') - self.db.execute('PRAGMA locking_mode = NORMAL;') - self.db.execute('PRAGMA synchronous = FULL;') - self.db.execute('PRAGMA encoding = "UTF-8";') - - def __exit__(self): - self.finish() - - def finish(self): - cursor = self.db.cursor() - cursor.execute('PRAGMA auto_vacuum;') - self.db.close() - - -class TokenDB(object): - def __init__(self, uuid='tokens'): - self.db = config.get('var', 'tokendb') - self.tokens = {} - self.refresh() - - def refresh(self): - self.tokens = {} - if os.path.isfile(self.db): - with open(self.db, 'rt') as f: - self.tokens = json.loads(f.read()) - - def save(self): - with open(self.db, 'wt') as f: - f.write(json.dumps( - self.tokens, indent=4, sort_keys=True - )) - - def get_token(self, token): - return self.tokens.get(token, None) - - def get_service(self, service): - token = self.tokens.get(service, None) - return token - - def set_service(self, service, tokenid): - self.tokens.update({ - service: tokenid - }) - self.save() - - def update_token(self, - token, - oauth_token_secret=None, - access_token=None, - access_token_secret=None, - verifier=None): - - t = self.tokens.get(token, {}) - if oauth_token_secret: - t.update({ - 'oauth_token_secret': oauth_token_secret - }) - if access_token: - t.update({ - 'access_token': access_token - }) - if access_token_secret: - t.update({ - 'access_token_secret': access_token_secret - }) - if verifier: - t.update({ - 'verifier': verifier - }) - - self.tokens.update({ - token: t - }) - self.save() - - def clear(self): - self.tokens = {} - self.save() - - def clear_service(self, service): - t = self.tokens.get(service) - if t: - del(self.tokens[t]) - del(self.tokens[service]) - self.save() - - -class SearchDB(BaseDB): - tmplfile = 'Search.html' - - def __init__(self): - self.fpath = "%s" % config.get('var', 'searchdb') - super().__init__(self.fpath) - cursor = self.db.cursor() - cursor.execute('''CREATE VIRTUAL TABLE IF NOT EXISTS data USING FTS5( - id, - corpus, - mtime, - url, - category, - title, - tokenize = 'porter' - )''') - self.db.commit() - - def __exit__(self): - self.finish() - - def finish(self): - cursor = self.db.cursor() - cursor.execute('''PRAGMA auto_vacuum;''') - self.db.close() - - def append(self, id, corpus, mtime, url, category, title): - mtime = int(mtime) - logging.debug("adding %s to searchdb", id) - cursor = self.db.cursor() - cursor.execute('''DELETE FROM data WHERE id=?''', (id,)) - cursor.execute('''INSERT OR IGNORE INTO data (id, corpus, mtime, url, category, title) VALUES (?,?,?,?,?,?);''', ( - id, - corpus, - mtime, - url, - category, - title - )) - self.db.commit() - - def is_uptodate(self, fname, mtime): - mtime = int(mtime) - ret = {} - cursor = self.db.cursor() - cursor.execute('''SELECT mtime - FROM data - WHERE id = ? AND mtime = ?''', - (fname, mtime) - ) - rows = cursor.fetchall() - - if len(rows): - logging.debug("%s is up to date in searchdb", fname) - return True - - logging.debug("%s is out of date in searchdb", fname) - return False - - def search_by_query(self, query): - query = query.replace('-', ' + ') - logging.info("query is: %s", query) - ret = {} - cursor = self.db.cursor() - cursor.execute('''SELECT - id, category, url, title, snippet(data, 1, '', '', '[...]', 24) - FROM data - WHERE data MATCH ? - ORDER BY category, rank;''', (query,)) - rows = cursor.fetchall() - for r in rows: - r = { - 'id': r[0], - 'category': r[1], - 'url': r[2], - 'title': r[3], - 'txt': r[4], - } - - category = r.get('category') - if category not in ret: - ret.update({category: {}}) - - maybe_fpath = os.path.join( - config.get('dirs', 'content'), - category, - "%s.*" % r.get('id') - ) - #fpath = glob.glob(maybe_fpath).pop() - ret.get(category).update({ - r.get('id'): { - # 'fpath': fpath, - 'url': r.get('url'), - 'title': r.get('title'), - 'txt': r.get('txt') - } - }) - return ret - - def cli(self, query): - results = self.search_by_query(query) - for c, items in sorted(results.items()): - print("%s:" % c) - for fname, data in sorted(items.items()): - print(" %s" % data.get('fpath')) - print(" %s" % data.get('url')) - print("") - - def html(self, query): - tmplvars = { - 'results': self.search_by_query(query), - 'term': query - } - return j2.get_template(self.tmplfile).render(tmplvars) - - -class WebmentionQueue(BaseDB): - tsform = 'YYYY-MM-DD HH:mm:ss' - - def __init__(self): - self.fpath = "%s" % config.get('var', 'webmentiondb') - super().__init__(self.fpath) - cursor = self.db.cursor() - cursor.execute(''' - CREATE TABLE IF NOT EXISTS `queue` ( - `id` INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE, - `timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - `source` TEXT NOT NULL, - `target` TEXT NOT NULL, - `status` INTEGER NOT NULL DEFAULT 0, - `mtime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - ''') - self.db.commit() - - def __exit__(self): - self.finish() - - def finish(self): - self.db.close() - - def exists(self, source, target, dt=arrow.now()): - logging.debug( - 'checking webmention existence for source: %s ; target: %s', - source, - target - ) - cursor = self.db.cursor() - cursor.execute( - '''SELECT id,timestamp FROM queue WHERE source=? AND target=? ORDER BY timestamp DESC LIMIT 1''', - (source, target) - ) - - rows = cursor.fetchall() - if not rows: - return False - - row = rows.pop() - if arrow.get(row[1], self.tsform).timestamp >= dt.timestamp: - return int(row[0]) - else: - return False - - def queue(self, source, target): - logging.debug("Queueing webmention: %s to %s", source, target) - cursor = self.db.cursor() - cursor.execute( - '''INSERT INTO queue (source,target) VALUES (?,?);''', ( - source, - target - ) - ) - r = cursor.lastrowid - self.db.commit() - return r - - def requeue(self, id): - logging.debug('setting %s webmention to undone', id) - cursor = self.db.cursor() - cursor.execute("UPDATE queue SET status = 0 where ID=?", (id,)) - self.db.commit() - - def get_queued(self, fname=None): - logging.debug('getting queued webmentions for %s', fname) - ret = [] - cursor = self.db.cursor() - if fname: - cursor.execute( - '''SELECT * FROM queue WHERE target LIKE ? AND status = 0''', - ('%' + fname + '%',) - ) - else: - cursor.execute( - '''SELECT * FROM queue WHERE status = 0''' - ) - - rows = cursor.fetchall() - for r in rows: - ret.append({ - 'id': r[0], - 'dt': r[1], - 'source': r[2], - 'target': r[3], - }) - return ret - - def entry_done(self, id): - logging.debug('setting %s webmention to done', id) - cursor = self.db.cursor() - cursor.execute("UPDATE queue SET status = 1 where ID=?", (id,)) - self.db.commit() - - def maybe_queue(self, source, target): - exists = self.exists(source, target) - cursor = self.db.cursor() - if exists: - self.requeue(exists) - return exists - - return self.queue(source, target) - - def get_outbox(self): - logging.debug('getting queued outgoing webmentions') - cursor = self.db.cursor() - ret = [] - cursor.execute( - '''SELECT id,timestamp,source,target FROM queue WHERE source LIKE ? AND status = 0''', - ('%' + config.get('common', 'domain') + '%',) - ) - rows = cursor.fetchall() - for r in rows: - ret.append({ - 'id': r[0], - 'dt': arrow.get(r[1], self.tsform).timestamp, - 'source': r[2], - 'target': r[3], - }) - return ret - - -def __expandconfig(): - c = configparser.ConfigParser( - interpolation=configparser.ExtendedInterpolation(), - allow_no_value=True - ) - conffile = os.path.join( - os.path.dirname(os.path.abspath(getsourcefile(lambda: 0))), - 'config.ini' - ) - c.read(conffile) - - for s in c.sections(): - for o in c.options(s): - curr = c.get(s, o) - if 'photo' == s and 'regex' == o: - REGEX.update({'photo': re.compile(curr)}) - c.set(s, o, os.path.expanduser(curr)) - return c - - -def baseN(num, b=36, numerals="0123456789abcdefghijklmnopqrstuvwxyz"): - """ Used to create short, lowercase slug for a number (an epoch) passed """ - num = int(num) - return ((num == 0) and numerals[0]) or ( - baseN( - num // b, - b, - numerals - ).lstrip(numerals[0]) + numerals[num % b] - ) - - -def slugfname(url): - return "%s" % slugify( - re.sub(r"^https?://(?:www)?", "", url), - only_ascii=True, - lower=True - )[:200] - - -def __setup_sitevars(): - SiteVars = {} - section = 'site' - for o in config.options(section): - SiteVars.update({o: config.get(section, o)}) - - # this should be a nice recursive function instead - # extra site section - nope, because it relies on order - # and author won't get appended - for section in config.get('site', 'appendwith').split(): - SiteVars.update({section: {}}) - for o in config.options(section): - SiteVars[section].update({o: config.get(section, o)}) - if not config.get(section, 'appendwith', fallback=False): - continue - # subsections - for sub in config.get(section, 'appendwith').split(): - SiteVars[section].update({sub: {}}) - for o in config.options(sub): - SiteVars[section][sub].update({o: config.get(sub, o)}) - - tips = {} - for s in config.sections(): - if s.startswith('tip_'): - for key in config.options(s): - if key not in tips: - tips.update({key: {}}) - tips[key].update({s.replace('tip_', ''): config.get(s, key)}) - - SiteVars.update({'tips': tips}) - return SiteVars - - -ARROWFORMAT = { - 'iso': 'YYYY-MM-DDTHH:mm:ssZ', - 'display': 'YYYY-MM-DD HH:mm', - 'rcf': 'ddd, DD MMM YYYY HH:mm:ss Z', - 'twitter': 'ddd MMM DD HH:mm:ss Z YYYY' -} - -LLEVEL = { - 'critical': 50, - 'error': 40, - 'warning': 30, - 'info': 20, - 'debug': 10 -} - -REGEX = { - 'exifdate': re.compile( - r'^(?P<year>[0-9]{4}):(?P<month>[0-9]{2}):(?P<day>[0-9]{2})\s+' - r'(?P<time>[0-9]{2}:[0-9]{2}:[0-9]{2})$' - ), - 'cleanurl': re.compile(r"^https?://(?:www)?"), - 'urls': re.compile( - r'\s+https?\:\/\/?[a-zA-Z0-9\.\/\?\:@\-_=#]+' - r'\.[a-zA-Z0-9\.\/\?\:@\-_=#]*' - ), - 'mdimg': re.compile( - r'(?P<shortcode>\!\[(?P<alt>[^\]]+)\]\((?P<fname>[^\s]+)' - r'(?:\s[\'\"](?P<title>[^\"\']+)[\'\"])?\)(?:\{(?P<css>[^\}]+)\})?)', - re.IGNORECASE - ) -} - -config = __expandconfig() - -j2 = jinja2.Environment( - loader=jinja2.FileSystemLoader( - searchpath=config.get('dirs', 'tmpl') - ), - lstrip_blocks=True -) - -site = __setup_sitevars()
@@ -1,122 +0,0 @@
-{% include 'block_header_open.html' %} - <title>{{ taxonomy.title }}</title> - <link rel="alternate" type="application/atom+xml" title="{{ taxonomy.title }} feed" href="{{ taxonomy.feed }}" /> - <link rel="self" href="{{ site.url }}{{ taxonomy.feed }}/" /> - -{% include 'block_header_close.html' %} - -{% if taxonomy.add_welcome %} -<aside class="siteinfo limit"> - <p>Hi!</p> - <p>Your are on a personal homepage. It has been my home one the Internet for many years, although the URL had changed a few times. It's <a href="https://indieweb.org/">IndieWeb</a>-compatible, and all content is produced by me. </p> - <p>This page is a feed of everything that gets on the site.</p> -</aside> -{% endif %} - -<section class="content-body h-feed"> - <nav> - <p class="follow"> - <a title="follow {{ taxonomy.title }}" rel="feed" href="{{ site.url }}{{ taxonomy.feed }}"> - <svg class="icon" width="16" height="16"> - <use xlink:href="#icon-rss" /> - </svg> - </a> - </p> - </nav> - - <h1 class="p-name">{{ taxonomy.name }}</h1> - {% for post in posts %} - <article class="h-entry hentry" lang="{{ post.lang }}"> - <header> - <h2>{% include 'Singular_title.html' %}</h2> - </header> - - - {% if post.summary %} - <div class="e-summary entry-summary"> - {{ post.summary }} - <span class="more"> - <a href="/{{ post.slug }}" title="{{ post.title }}"> - {% if post.lang == 'hu' %}Tovább »{% else %}Continue »{% endif %} - </a> - </span> - </div> - {% else %} - <div class="e-content entry-content"> - {% if post.photo %} - {{ post.photo }} - {% endif %} - {{ post.html }} - </div> - {% endif %} - </article> - - {% endfor %} -</section> - -{% if taxonomy.total > 1 %} - - {# based on: http://dev.dbl-a.com/symfony-2-0/symfony2-and-twig-pagination/ #} - <nav class="pagination"> - <ul> - {% if taxonomy.page > 1 %} - {% set prev = taxonomy.page - 1 %} - <li> - <a rel="prev" href="{{ taxonomy.url }}page/{{ prev }}">«</a> - </li> - <li> - <a rel="prev" href="{{ taxonomy.url }}">1</a> - </li> - {% endif %} - - {% if taxonomy.page - 4 > 0 %} - <li> - <span class="dots">…</span> - </li> - {% endif %} - - - {% if ( taxonomy.page - 1 > 1 ) %} - <li> - <a href="{{ taxonomy.url }}page/{{ taxonomy.page - 1 }}">{{ taxonomy.page - 1 }}</a> - </li> - {% endif %} - - - <li> - <span class="current">{{ taxonomy.page }}</span> - </li> - - - {% if ( taxonomy.page + 1 <= taxonomy.total -1 ) %} - <li> - <a href="{{ taxonomy.url }}page/{{ taxonomy.page + 1 }}">{{ taxonomy.page + 1 }}</a> - </li> - {% endif %} - - - {% if taxonomy.page + 3 < taxonomy.total %} - <li> - <span class="dots">…</span> - </li> - {% endif %} - - - {% if taxonomy.page != taxonomy.total %} - <li> - <a href="{{ taxonomy.url }}page/{{ taxonomy.total }}">{{ taxonomy.total }}</a> - </li> - {% endif %} - - {% if taxonomy.page < taxonomy.total %} - {% set next = taxonomy.page + 1 %} - <li> - <a rel="next" href="{{ taxonomy.url }}page/{{ next }}">»</a> - </li> - {% endif %} - </ul> - </nav> - -{% endif %} - -{% include 'block_footer.html' %}
@@ -0,0 +1,145 @@
+{% extends "base.j2.html" %} +{% block lang %}{% endblock %} +{% block title %}{{ category.title }}{% endblock %} +{% block meta %} + <link rel="alternate" type="application/atom+xml" title="{{ category.title }} feed" href="{{ site.url }}{{ category.feed }}" /> +{% endblock %} +{% block content %} +<section class="content-body h-feed"> + <header> + <nav> + <p class="follow"> + <a title="follow {{ category.title }}" rel="feed" href="{{ site.url }}{{ category.feed }}"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-rss" /> + </svg> + </a> + </p> + </nav> + <h1 class="p-name">{{ category.name }}</h1> + </header> + + +{% set year = [0] %} +{% for post in posts %} + {% set _year = year.pop() %} + {% if category.display == 'flat' and _year != post.year %} + <h2>{{ post.year }}</h2> + {% endif %} + {% set _ = year.append(post.year)%} + + <article class="h-entry hentry singular" lang="{{ post.lang }}"> + <header> + {% if category.display == 'flat' %} + <h3> + {% else %} + <h2> + {% endif %} + {% if post.is_reply %} + <span class="p-name"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-reply" /> + </svg> + <a href="/{{ post.slug }}/" class="u-url"> + RE: + </a> + <a href="{{ post.is_reply }}" class="u-in-reply-to"> + {{ post.is_reply }} + </a> + </span> + {% else %} + <a href="/{{ post.slug }}/" title="{{ post.title }}"> + <span class="entry-title p-name">{{ post.title }}</span> + </a> + {% endif %} + {% if category.display == 'flat' %} + </h3> + {% else %} + </h2> + {% endif %} + </header> + + {% if post.summary %} + <div class="e-summary entry-summary"> + {{ post.summary }} + <p class="more"> + <a href="/{{ post.slug }}" title="{{ post.title }}"> + {% if post.lang == 'hu' %}Tovább »{% else %}Continue »{% endif %} + </a> + </p> + </div> + {% else %} + <div class="e-content entry-content"> + {{ post.html_content }} + </div> + {% endif %} + </article> +{% endfor %} +</section> +{% endblock %} +{% block pagination %} +{% if pages.total > 1 %} + + {# based on: http://dev.dbl-a.com/symfony-2-0/symfony2-and-twig-pagination/ #} + <nav class="pagination"> + <ul> + {% if pages.current > 1 %} + {% set prev = pages.current - 1 %} + <li> + <a rel="prev" href="{{ category.url }}page/{{ prev }}">«</a> + </li> + <li> + <a rel="prev" href="{{ category.url }}">1</a> + </li> + {% endif %} + + {% if pages.current - 4 > 0 %} + <li> + <span class="dots">…</span> + </li> + {% endif %} + + + {% if ( pages.current - 1 > 1 ) %} + <li> + <a href="{{ category.url }}page/{{ pages.current - 1 }}">{{ pages.current - 1 }}</a> + </li> + {% endif %} + + + <li> + <span class="current">{{ pages.current }}</span> + </li> + + + {% if ( pages.current + 1 <= pages.total -1 ) %} + <li> + <a href="{{ category.url }}page/{{ pages.current + 1 }}">{{ pages.current + 1 }}</a> + </li> + {% endif %} + + + {% if pages.current + 3 < pages.total %} + <li> + <span class="dots">…</span> + </li> + {% endif %} + + + {% if pages.current != pages.total %} + <li> + <a href="{{ category.url }}page/{{ pages.total }}">{{ pages.total }}</a> + </li> + {% endif %} + + {% if pages.current < pages.total %} + {% set next = pages.current + 1 %} + <li> + <a rel="next" href="{{ category.url }}page/{{ next }}">»</a> + </li> + {% endif %} + </ul> + </nav> + +{% endif %} +{% endblock %}
@@ -1,53 +0,0 @@
-{% include 'block_header_open.html' %} - <title>{{ taxonomy.title }}</title> - <link rel="alternate" type="application/atom+xml" title="{{ taxonomy.title }} feed" href="{{ taxonomy.feed }}" /> - <link rel="self" href="{{ site.url }}{{ taxonomy.feed }}/" /> - -{% include 'block_header_close.html' %} - -<section class="content-body h-feed"> - <header> - <nav> - <p class="follow"> - <a title="follow {{ taxonomy.title }}" rel="feed" href="{{ site.url }}{{ taxonomy.feed }}"> - <svg class="icon" width="16" height="16"> - <use xlink:href="#icon-rss" /> - </svg> - </a> - </p> - </nav> - <h1 class="p-name">{{ taxonomy.name }}</h1> - </header> - -{% for year in by_year.keys()|sort(reverse=True) %} - <h2>{{ year }}</h2> - {% for post in by_year[year]|sort(attribute='pubtime',reverse=True) %} - <article class="h-entry hentry" lang="{{ post.lang }}"> - <header> - <h3>{% include 'Singular_title.html' %}</h3> - </header> - - {% if post.summary %} - <div class="e-summary entry-summary"> - {{ post.summary }} - <span class="more"> - <a href="/{{ post.slug }}" title="{{ post.title }}"> - {% if post.lang == 'hu' %}Tovább »{% else %}Continue »{% endif %} - </a> - </span> - <br class="clear" /> - </div> - {% else %} - <div class="e-content entry-content"> - {% if post.photo %} - {{ post.photo }} - {% endif %} - {{ post.html }} - </div> - {% endif %} - </article> - {% endfor %} -{% endfor %} -</section> - -{% include 'block_footer.html' %}
@@ -1,27 +0,0 @@
-<li class="h-entry p-comment"> -{% if 'webmention' != comment.type %} - <span class="reaction"> - <a class="u-url" href="{{ comment.source }}">{{ comment.type }} </a> - </span> -{% endif %} - <time class="dt-published" datetime="{{ comment.pubtime }}"> - {{ comment.pubdate }} - </time> from - <span class="p-author h-card"> - {% if comment.author.url %} - <a class="url u-url" href="{{ comment.author.url }}"> - <span class="p-name fn">{{ comment.author.name }}</span> - </a> - {% else %} - <span class="p-name fn">{{ comment.author.name }}</span> - {% endif %} - </span><br /> -{% if 'webmention' == comment.type %} - <span class="source"> - <svg class="icon" width="16" height="16"> - <use xlink:href="#icon-link"></use> - </svg> - <a class="u-url" href="{{ comment.source }}">{{ comment.source }}</a> - </span> -{% endif %} -</li>
@@ -1,90 +0,0 @@
-<?php - -function redirect_to($uri) { - header('HTTP/1.1 301 Moved Permanently'); - if (preg_match("/^https?/", $uri)) - $target = $uri; - else - $target = '{{ site.url }}/'. trim($uri, '/') . '/'; - header("Location: ". $target); - exit; -} - -function gone($uri) { - header('HTTP/1.1 410 Gone'); - die('<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"/> - <meta content="width=device-width,initial-scale=1,minimum-scale=1" name="viewport"/> - <title>Gone</title> - </head> - <body> -<h1>This content was deleted.</h1> - </body> -</html>'); -} - -function notfound() { - header('HTTP/1.0 404 Not Found'); - die('<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8"/> - <meta content="width=device-width,initial-scale=1,minimum-scale=1" name="viewport"/> - <title>Not found</title> - </head> - <body> - -<h1>This was not found.</h1> -<h2>Please search for it instead.</h2> -<p> -<form action="/search" class="search-form" method="get" role="search"> - <label for="search">Search</label> - <input id="s" name="s" placeholder="search..." title="Search for:" type="search" value=""/> - <input type="submit" value="OK"/> -</form> -</p> - </body> -</html>'); -} - -function maybe_redirect($uri) { - if (file_exists("./$uri/index.html")) { - redirect_to($uri); - } -} - -$redirects = array( -{% for (from, to) in redirects %} - "{{ from }}" => "{{ to }}", -{%- endfor %} -); - -$gone = array( -{% for gone in gones %} - "{{ gone }}" => true, -{%- endfor %} -); - -$uri = filter_var($_SERVER['REQUEST_URI'], FILTER_SANITIZE_URL); -$uri = str_replace('../', '', $uri); -$uri = str_replace('/feed/', '', $uri); -$uri = str_replace('/atom/', '', $uri); -$uri = trim($uri, '/'); - -if (isset($gone[$uri])) - gone($uri); -elseif (isset($redirects[$uri])) - redirect_to($redirects[$uri]); -// replace _ with - and look for a file -elseif (strstr($uri, '_')) - maybe_redirect(str_replace('_', '-', $uri)); -// try getting rid of -by-xyz -elseif (stristr($uri,'-by-')) - maybe_redirect(preg_replace('/(.*?)-by-.*$/i','${1}',$uri)); -// try getting rid of -2, WordPress artifacts -elseif (stristr($uri,'-2')) - maybe_redirect(preg_replace('/(.*?)-2$/i','${1}',$uri)); -else - notfound();
@@ -1,56 +0,0 @@
-<!DOCTYPE html> -<html> -<head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1" /> - <style media="all"> - {% include 'style-dark.css' %} - </style> - <style media="none"> - {% include 'style-light.css' %} - </style> - <title>Search results for "{{ term }}"</title> -</head> - -<body> - -<header class="content-header" id="main-header"> - <nav class="content-navigation"> - <ul> - <li> - <a title="back to site" href="/"> - « back to the site - </a> - </li> - </ul> - </nav> - - <form role="search" method="get" class="search-form" action="/search"> - <label for="search" class="hide">Search</label> - <input type="search" class="search-field" placeholder="search..." value="{{ term }}" name="s" id="s" title="Search for:"> - <input type="submit" class="search-submit" value="Go ➡"> - </form> - - <br class="clear" /> -</header> - -<section class="content-body"> - <h1 class="p-name hide">Search results for "{{ term }}"</h1> - -{% for category, posts in results.items() %} - <details open="open" class="search-section"> - <summary>{{ category }} [{{ posts|length }}]</summary> - <ol> - {% for fname, post in posts.items() %} - <li> - <p><a href="{{ post.url }}">{{ post.url }}</a></p> - <p>{% if post.title|e %}{{ post.txt }}{% else %}{{ post.title }}{% endif %}</p> - </li> - {% endfor %} - </ol> - </details> -{% endfor %} -</section> - -</body> -</html>
@@ -1,181 +0,0 @@
-{% include 'block_header_open.html' %} - - <title>{{ post.title }}</title> - <meta name="author" content="{{ site.author.name }}"> - <meta name="keywords" content="{{ post.tags|join(',') }}"> - <meta name="description" content="{{ post.description|e }}"> - - <link rel="canonical" href="{{ site.url }}/{{ post.slug }}/" /> - <link rel="shortlink" href="{{ site.url }}/{{ post.shortslug }}" /> - <link rel="license" href="{{ post.licence.url }}" /> - <link rel="alternate" type="application/json+oembed" href="{{ site.url }}/{{ post.slug }}/oembed.json" /> - -{% include 'block_header_close.html' %} - -<section class="content-body"> - <article class="h-entry hentry singular" lang="{{ post.lang }}"> - <header> - <h1>{% include 'Singular_title.html' %}</h1> - </header> - -{% if post.category == 'article' and post.is_old %} - <div class="warning"> - <p> - This article is older, than 2 years. It might be outdated. - </p> - </div> -{% endif %} - -{% if post.review %} - <div class="h-review hreview"> - <h2>Review summary</h2> - <p> - <a href="{{ post.review.url }}" class="p-name url fn p-item h-product">{{ post.review.title }}</a> - </p> - <p> - <span class="rating"> - <span class="value">{{ post.review.rating }}</span> - out of - <span class="best">5</span> - </span> - </p> - <p class="p-summary">{{ post.review.summary }}</p> - </div> -{% endif %} - -{% if post.summary %} - <div class="e-summary entry-summary"> - {{ post.summary }} - </div> -{% endif %} - - <div class="e-content entry-content"> - <div class="content-inner"> - {% if post.photo %} - {{ post.photo }} - {% endif %} - {{ post.html }} - </div> - </div> - - <footer> - <dl> - <dt>Published</dt> - <dd class="published"> - <time class="dt-published" datetime="{{ post.pubtime }}">{{ post.pubdate }}</time> - </dd> - <dt>Author</dt> - <dd> - {% include 'block_author.html' %} - </dd> - <dt>Entry URL</dt> - <dd> - <a class="u-url u-uuid" rel="bookmark" href="{{ site.url}}/{{ post.slug }}/">{{ site.url}}/{{ post.slug }}/</a> - </dd> - <dt>License</dt> - <dd class="license"> - {% if post.licence.text == 'CC BY 4.0' %} - <a rel="license" href="https://creativecommons.org/licenses/by/4.0/" class="u-license">CC BY 4.0</a> - <ul> - <li>you can share it</li> - <li>you can republish it</li> - <li>you can modify it, but you need to indicate the modifications</li> - <li>you can use it for commercial purposes</li> - <li>you always need to make a link back here</li> - </ul> - {% elif post.licence.text == 'CC BY-NC 4.0' %} - <a rel="license" href="https://creativecommons.org/licenses/by-nc/4.0/" class="u-license">CC BY-NC 4.0</a> - <ul> - <li>you can share it</li> - <li>you can republish it</li> - <li>you can modify it, but you need to indicate the modifications</li> - <li>you can't use it for commercial purposes</li> - <li>you always need to make a link back here</li> - </ul> - For commercial use, please contact me. - {% else %} - <a rel="license" href="https://creativecommons.org/licenses/by-nc-nd/4.0/" class="u-license">CC BY-NC-ND 4.0</a> - <ul> - <li>you can share it</li> - <li>you can't modify it</li> - <li>you can't republish it</li> - <li>you can't use it for commercial purposes</li> - <li>you always need to make a link back here</li> - </ul> - For commercial use, please contact me. - {% endif %} - </dd> - <dt class="noprint">Leave a tip</dt> - <dd class="donation"> - <p> - {% if post.category == 'photo' %} - Did you like this photo?<br />Leave a tip! If you're interested in prints, please get in touch. - {% elif post.category == 'article' %} - Did you find this article useful?<br />Support me, so I can write more like this.<br />If you want my help for your project, get in touch. - {% elif post.category == 'journal' %} - Did you like this entry?<br />Encourage me to write more of them. - {% else %} - Did you like what you read?<br />Leave a tip!</a> - {% endif %} - </p> - <ul> - {% for method, data in site.tips.items() %} - <li> - <a rel="payment" title="pay {{ site.author.name }} via {{ data.label }} {{ data.value }}" href="{{ data.url }}"> - {{ data.value }} - <span class="method"> - <svg class="icon" width="16" height="16"> - <use xlink:href="#icon-{{ method }}"></use> - </svg> - with {{ data.label }} - </span> - </a> - </li> - {% endfor %} - </ul> - </dd> - </dl> - </footer> - - -{% if post.syndicate|length %} - <section class="syndication"> - {% for url in post.syndicate %} - <a href="{{ url }}" class="u-syndication"></a> - {% endfor %} - </section> -{% endif %} - - -{% if post.replies|length %} - <section class="replies"> - <h2><a id="replies"></a>Replies</h2> - <ol> - {% for mtime, comment in post.replies %} - {% include 'Comment.html' %} - {% endfor %} - </ol> - </section> -{% endif %} -{% if post.reactions|length %} - <section class="reactions"> - <h2><a id="reactions"></a>Reactions</h2> - <dl> - {% for character, comments in post.reactions.items() %} - <dt>{{ character }}</dt> - <dd> - <ul> - {% for mtime, comment in comments %} - {% include 'Comment.html' %} - {% endfor %} - </ul> - </dd> - {% endfor %} - </dl> - </section> -{% endif %} - </article> -</section> - - -{% include 'block_footer.html' %}
@@ -0,0 +1,7 @@
+{% extends "base.j2.html" %} +{% block meta %} + <meta name="author" content="{{ author.name }} <{{ author.email }}>" /> + <meta name="description" content="{{ post.summary|e }}" /> + <link rel="canonical" href="{{ post.url }}" /> + <link rel="license" href="https://creativecommons.org/licenses/4.0/{{ post.licence }}" /> +{% endblock %}
@@ -1,15 +0,0 @@
-{% if post.is_reply %} - <span class="p-name"> - <svg class="icon" width="16" height="16"><use xlink:href="#icon-reply" /></svg> - <a href="/{{ post.slug }}/" class="u-url" title="{{ post.title }}"> - RE: - </a> - <a href="{{ post.is_reply }}" class="u-in-reply-to" title="Reply to: {{ post.is_reply }}"> - {{ post.is_reply }} - </a> - </span> -{% else %} - <a href="/{{ post.slug }}/" title="{{ post.title }}" class="{% if post.summary %}has-summary{% endif %}"> - <span class="entry-title p-name">{{ post.title }}</span> - </a> -{% endif %}
@@ -1,34 +0,0 @@
-<figure class="photo"> -{% if photo.target %}<a href="{{ photo.target }}" class="{{ photo.css }}">{% endif %} -<img src="{{ photo.src }}" title="{{ photo.title }}" alt="{{ photo.alt }}" class="adaptimg" width="{{ photo.width }}" height="{{ photo.height }}" /> -{% if photo.target %}</a>{% endif %} -<figcaption> -<span class="alt">{{ photo.alt }}</span>{% if photo.is_photo %} -<dl class="exif"> -{% if photo.exif.camera %} -<dt>Camera</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-camera" /></svg>{{ photo.exif.camera }}</dd> -{% endif %} -{% if photo.exif.aperture %} -<dt>Aperture</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-aperture" /></svg>f/{{ photo.exif.aperture }}</dd> -{% endif %} -{% if photo.exif.shutter_speed %} -<dt>Shutter speed</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-clock" /></svg>{{ photo.exif.shutter_speed }} sec</dd> -{% endif %} -{% if photo.exif.focallength %} -<dt>Focal length (as set)</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-focallength" /></svg>{{ photo.exif.focallength }}</dd> -{% endif %} -{% if photo.exif.iso %} -<dt>Sensitivity</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-sensitivity" /></svg>ISO {{ photo.exif.iso }}</dd> -{% endif %} -{% if photo.exif.lens %} -<dt>Lens</dt> -<dd><svg class="icon" width="16" height="16"><use xlink:href="#icon-lens" /></svg>{{ photo.exif.lens }}</dd> -{% endif %} -</dl>{% endif %} -</figcaption> -</figure>
@@ -0,0 +1,58 @@
+<figure class="photo"> +{% if href != src %} + <a href="{{ href }}"> +{% endif %} + <img src="{{ src }}" class="adaptimg" title="{{ title }}" alt="" width="{{ width }}" height="{{ height }}" /> +{% if href != src %} + </a> +{% endif %} + <figcaption> + <span class="alt">{{ caption }}</span> +{% if is_photo %} + <dl class="exif"> + <dt>Camera</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-camera" /> + </svg> + {{ exif.camera }} + </dd> + <dt>Aperture</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-aperture" /> + </svg> + f/{{ exif.aperture }} + </dd> + <dt>Shutter speed</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-clock" /> + </svg> + {{ exif.shutter_speed }} sec + </dd> + <dt>Focal length (as set)</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-focallength" /> + </svg> + {{ exif.focallength }} + </dd> + <dt>Sensitivity</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-sensitivity" /> + </svg> + ISO {{ exif.iso }} + </dd> + <dt>Lens</dt> + <dd> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-lens" /> + </svg> + {{ exif.lens }} + </dd> + </dl> +{% endif %} + </figcaption> +</figure>
@@ -0,0 +1,429 @@
+<!DOCTYPE html> +<html {% block lang %}lang="{{ post.lang }}"{% endblock %}> +<head> + <title>{% block title %}{{ post.title }} - petermolnar.net{% endblock %}</title> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1" /> + <link rel="icon" href="https://petermolnar.net/favicon.ico" /> + {% for key, value in meta.items() %} + <link rel="{{ key }}" href="{{ value }}" /> + {% endfor %} + {% block meta %}{% endblock %} + <style media="all"> + {% include 'style-dark.css' %} + </style> + <style id="css_alternative" media="none"> + {% include 'style-light.css' %} + </style> + <style media="print"> + {% include 'style-print.css' %} + </style> + <script> + /* color scheme switcher */ + var current = localStorage.getItem("stylesheet"); + if (current) { + document.querySelector('#css_alternative').setAttribute("media", current); + } + + function toggleStylesheet(trigger){ + var setto = 'all'; + var e = document.querySelector('#css_alternative'); + if (e.getAttribute("media") == 'all') { + setto = 'none'; + } + localStorage.setItem("stylesheet", setto); + e.setAttribute("media", setto); + } + </script> +</head> +<body> + +{% macro activemenu(name) %}{% if (post is defined and post.category == name ) or ( category is defined and category.name == name ) %}active{% endif %}{% endmacro %} + +<header class="content-header"> + <nav class="content-navigation"> + <ul> + <li> + <a title="home" href="/" class="{{ activemenu('') }}"> + <svg class="icon" width="16" height="16"><use xlink:href="#icon-home" /></svg> + home + </a> + </li> + <li> + <a title="photos" href="/category/photo/" class="{{ activemenu('photo') }}"> + <svg class="icon" width="18" height="16"><use xlink:href="#icon-photo" /></svg> + photos + </a> + </li> + <li> + <a title="journal" href="/category/journal/" class="{{ activemenu('journal') }}"> + <svg class="icon" width="16" height="16"><use xlink:href="#icon-journal" /></svg> + journal + </a> + </li> + <li> + <a title="IT" href="/category/article/" class="{{ activemenu('article') }}"> + <svg class="icon" width="16" height="16"><use xlink:href="#icon-article" /></svg> + IT + </a> + </li> + <li> + <a title="notes" href="/category/note/" class="{{ activemenu('note') }}"> + <svg class="icon" width="16" height="16"><use xlink:href="#icon-note" /></svg> + notes + </a> + </li> + </ul> + </nav> + + <!-- + <form role="search" method="get" class="search-form" action="/search"> + <label for="s">Search</label> + <input type="search" class="search-field" placeholder="search..." + value="" name="s" id="s" title="Search for:" /> + <input type="submit" class="search-submit" value="Go ➡" /> + </form> + --> + + <p class="contrast"> + <a title="toggle site colour scheme" href="#" + onclick="toggleStylesheet(this)"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-contrast" /> + </svg> + </a> + </p> +</header> + +{% block content %} +<section class="content-body"> + <article class="h-entry hentry singular" lang="{{ post.lang }}"> + <header> + <h1> + {% if post.is_reply %} + <span class="p-name"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-reply" /> + </svg> + <a href="/{{ post.slug }}/" class="u-url"> + RE: + </a> + <a href="{{ post.is_reply }}" class="u-in-reply-to"> + {{ post.is_reply }} + </a> + </span> + {% else %} + <a href="/{{ post.slug }}/" title="{{ post.title }}"> + <span class="entry-title p-name">{{ post.title }}</span> + </a> + {% endif %} + </h1> + </header> + + {% if post.review %} + <div class="h-review hreview"> + <h2>Review summary</h2> + <p> + <a href="{{ post.review.url }}" class="p-name url fn p-item h-product"> + {{ post.review.title }} + </a> + </p> + <p> + <span class="rating"> + <span class="value">{{ post.review.rating }}</span> + out of + <span class="best">5</span> + </span> + </p> + <p class="p-summary">{{ post.review.summary }}</p> + </div> + {% endif %} + + {% if post.summary %} + <div class="e-summary entry-summary"> + {{ post.html_summary }} + </div> + {% endif %} + + <div class="e-content entry-content"> + <div class="content-inner"> + {{ post.html_content }} + </div> + </div> + + <footer> + <dl> + + <dt>Published</dt> + <dd class="published"> + <time class="dt-published" datetime="{{ post.pubtime }}">{{ post.pubdate }}</time> + </dd> + + <dt>Author</dt> + <dd> + <p class="p-author h-card vcard"> + <img class="photo avatar u-photo u-avatar" + src="{{ author.avatar }}" + alt="Photo of {{ author.name }}" /> + <a class="fn p-name url u-url u-uid" href="{{ author.url }}"> + {{ author.name }} + </a> + <<a rel="me" class="u-email email" href="mailto:{{ author.email }}"> + {{ author.email }} + </a>> + </p> + </dd> + + <dt>Entry URL</dt> + <dd> + <a class="u-url u-uuid" rel="bookmark" href="{{ post.url }}"> + {{ post.url }} + </a> + </dd> + + <dt>License</dt> + <dd class="license"> + {% if post.licence == 'by' %} + <a rel="license" href="https://creativecommons.org/licenses/by/4.0/" class="u-license">CC BY 4.0</a> + <ul> + <li>you can share it</li> + <li>you can republish it</li> + <li>you can modify it, but you need to indicate the modifications</li> + <li>you can use it for commercial purposes</li> + <li>you always need to make a link back here</li> + </ul> + {% elif post.licence.text == 'by-nc' %} + <a rel="license" href="https://creativecommons.org/licenses/by-nc/4.0/" class="u-license">CC BY-NC 4.0</a> + <ul> + <li>you can share it</li> + <li>you can republish it</li> + <li>you can modify it, but you need to indicate the modifications</li> + <li>you can't use it for commercial purposes</li> + <li>you always need to make a link back here</li> + </ul> + For commercial use, please contact me. + {% else %} + <a rel="license" href="https://creativecommons.org/licenses/by-nc-nd/4.0/" class="u-license">CC BY-NC-ND 4.0</a> + <ul> + <li>you can share it</li> + <li>you can't modify it</li> + <li>you can't republish it</li> + <li>you can't use it for commercial purposes</li> + <li>you always need to make a link back here</li> + </ul> + For commercial use, please contact me. + {% endif %} + </dd> + + <dt class="noprint">Leave a tip</dt> + <dd class="donation"> + <p> + {% if post.category in labels.tiptext.keys(): %} + {{ labels.tiptext[post.category] }} + {% else %} + Did you like what you read?<br />Leave a tip! + {% endif %} + </p> + <ul> + {% for tip in tips %} + <li> + <a rel="payment" title="pay {{ author.name }} via {{ tip.label }} {{ tip.value }}" href="{{ tip.url }}"> + {{ tip.value }} + <span class="method"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-{{ tip.name }}"></use> + </svg> + with {{ tip.label }} + </span> + </a> + </li> + {% endfor %} + </ul> + </dd> + </dl> + </footer> + + + {% if post.syndicate|length %} + <section class="syndication"> + {% for url in post.syndicate %} + <a href="{{ url }}" class="u-syndication"></a> + {% endfor %} + </section> + {% endif %} + + + {% if post.replies|length %} + <section class="replies"> + <h2><a id="replies"></a>Replies</h2> + <ol> + {% for mtime, comment in post.replies.items() %} + <li class="h-entry p-comment"> + <time class="dt-published" datetime="{{ comment.pubtime }}"> + {{ comment.pubdate }} + </time> from + <span class="p-author h-card"> + {% if comment.author.url %} + <a class="url u-url" href="{{ comment.author.url }}"> + <span class="p-name fn"> + {{ comment.author.name }} + </span> + </a> + {% else %} + <span class="p-name fn"> + {{ comment.author.name }} + </span> + {% endif %} + </span><br /> + <span class="source"> + <svg class="icon" width="16" height="16"> + <use xlink:href="#icon-link"></use> + </svg> + <a class="u-url" href="{{ comment.source }}"> + {{ comment.source }} + </a> + </span> + </li> + {% endfor %} + </ol> + </section> + {% endif %} + + {% if post.reactions|length %} + <section class="reactions"> + <h2><a id="reactions"></a>Reactions</h2> + <dl> + {% for character, comments in post.reactions.items() %} + <dt>{{ character }}</dt> + <dd> + <ul> + {% for mtime, comment in comments.items() %} + <li class="h-entry p-comment"> + <span class="reaction"> + <a class="u-url" href="{{ comment.source }}"> + {{ comment.type }} + </a> + </span> + <time class="dt-published" datetime="{{ comment.pubtime }}"> + {{ comment.pubdate }} + </time> from + <span class="p-author h-card"> + {% if comment.author.url %} + <a class="url u-url" href="{{ comment.author.url }}"> + <span class="p-name fn"> + {{ comment.author.name }} + </span> + </a> + {% else %} + <span class="p-name fn"> + {{ comment.author.name }} + </span> + {% endif %} + </span> + </li> + {% endfor %} + </ul> + </dd> + {% endfor %} + </dl> + </section> + {% endif %} + + </article> +</section> + +{% endblock %} + +{% block pagination %} +{% endblock %} + + +<footer class="content-footer" id="main-footer"> + <nav class="footer-contact p-author h-card vcard limit"> + <h2>Site author</h2> + <dl> + <dt> + <img class="photo avatar u-photo u-avatar" + src="https://petermolnar.net/molnar_peter_avatar.jpg" + alt="Photo of Peter Molnar" /> + </dt> + <dd> + <a class="fn p-name url u-url u-uid" href="/about.html"> + Peter Molnar + </a> + </dd> + <dt>email</dt> + <dd> + <a rel="me" class="u-email email" href="mailto:{{ author.email }}"> + {{ author.email }} + </a> + </dd> +{% if author.sip %} + <dt>SIP</dt> + <dd> + <a rel="me" class="u-sip sip" href="sip:{{ author.sip }}"> + {{ author.sip }} + </a> + </dd> +{% endif %} +{% if author.xmpp %} + <dt>XMPP</dt> + <dd> + <a rel="me" class="u-xmpp xmpp" href="xmpp:{{ author.xmpp }}">{{ author.xmpp }}</a> + </dd> +{% endif %} +{% if author.gpg %} + <dt>GPG</dt> + <dd> + <a rel="me" class="u-gpg gpg" href="/{{ author.gpg }}">key</a> + </dd> +{% endif %} +{% if author.flickr %} + <dt>flickr</dt> + <dd> + <a rel="me" class="u-flickr" href="https://flickr.com/people/{{ author.flickr }}">{{ author.flickr }}</a> + </dd> +{% endif %} +{% if author.github %} + <dt>github</dt> + <dd> + <a rel="me" class="u-github" href="https://github.com/{{ author.github }}">{{ author.github }}</a> + </dd> +{% endif %} + </dl> + </nav> +</footer> + +{% include 'symbols.svg' %} + + <!-- + This is a self-hosted analytics code, called Matomo + I configured it to avoid placing cookies, respect Do Not Track, + remove the last segment of IP information, so it stores as little + information as possible. + --> + <script type="text/javascript"> + var _paq = _paq || []; + _paq.push(["setDoNotTrack", true]); + _paq.push(["disableCookies"]); + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + (function() { + var u="//stats.petermolnar.net/"; + _paq.push(['setTrackerUrl', u+'piwik.php']); + _paq.push(['setSiteId', '{{ site.piwik.id }}']); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; + g.async=true; + g.defer=true; + g.src=u+'piwik.js'; + s.parentNode.insertBefore(g,s); + })(); + </script> + <noscript> + <img src="https://{{ site.piwik.domain }}/piwik.php?idsite=1&rec=1" style="border:0" alt="" /> + </noscript> + + + </body> +</html>
@@ -1,5 +0,0 @@
-<p class="p-author h-card vcard"> - <img class="photo avatar u-photo u-avatar" src="{{ site.author.avatar }}" alt="Photo of {{ site.author.name }}" /> - <a class="fn p-name url u-url u-uid" href="{{ site.author.url }}">{{ site.author.name }}</a> - <<a rel="me" class="u-email email" href="mailto:{{ site.author.email }}">{{ site.author.email }}</a>> -</p>
@@ -1,88 +0,0 @@
- - <footer class="content-footer" id="main-footer"> - <nav class="footer-contact p-author h-card vcard limit"> - <h2>Site author</h2> - <dl> - <dt> - <img class="photo avatar u-photo u-avatar" src="{{ site.author.avatar }}" alt="Photo of {{ site.author.name }}" /> - </dt> - <dd> - <a class="fn p-name url u-url u-uid" href="/about.html"> - {{ site.author.name }} - </a> - </dd> - <dt>email</dt> - <dd> - <a rel="me" class="u-email email" href="mailto:{{ site.author.email }}"> - {{ site.author.email }} - </a> - </dd> -{% if site.author.sip %} - <dt>SIP</dt> - <dd> - <a rel="me" class="u-sip sip" href="sip:{{ site.author.sip }}"> - {{ site.author.sip }} - </a> - </dd> -{% endif %} -{% if site.author.xmpp %} - <dt>XMPP</dt> - <dd> - <a rel="me" class="u-xmpp xmpp" href="xmpp:{{ site.author.xmpp }}">{{ site.author.xmpp }}</a> - </dd> -{% endif %} -{% if site.author.gpg %} - <dt>GPG</dt> - <dd> - <a rel="me" class="u-gpg gpg" href="/{{ site.author.gpg }}">key</a> - </dd> -{% endif %} -{% if site.author.flickr %} - <dt>flickr</dt> - <dd> - <a rel="me" class="u-flickr" href="https://flickr.com/people/{{ site.author.flickr }}">{{ site.author.flickr }}</a> - </dd> -{% endif %} -{% if site.author.github %} - <dt>github</dt> - <dd> - <a rel="me" class="u-github" href="https://github.com/{{ site.author.github }}">{{ site.author.github }}</a> - </dd> -{% endif %} - </dl> - </nav> - </footer> - -{% include 'symbols.svg' %} - - <!-- - This is a self-hosted analytics code, called Matomo - I configured it to avoid placing cookies, respect Do Not Track, - remove the last segment of IP information, so it stores as little - information as possible. - --> - <script type="text/javascript"> - var _paq = _paq || []; - _paq.push(["setDoNotTrack", true]); - _paq.push(["disableCookies"]); - _paq.push(['trackPageView']); - _paq.push(['enableLinkTracking']); - (function() { - var u="//stats.petermolnar.net/"; - _paq.push(['setTrackerUrl', u+'piwik.php']); - _paq.push(['setSiteId', '1']); - var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; - g.type='text/javascript'; - g.async=true; - g.defer=true; - g.src=u+'piwik.js'; - s.parentNode.insertBefore(g,s); - })(); - </script> - <noscript> - <img src="https://stats.petermolnar.net/piwik.php?idsite=1&rec=1" style="border:0" alt="" /> - </noscript> - - - </body> -</html>
@@ -1,101 +0,0 @@
- <style media="all"> - {% include 'style-dark.css' %} - </style> - <style id="css_alternative" media="none"> - {% include 'style-light.css' %} - </style> - <style media="print"> - {% include 'style-print.css' %} - </style> - <script> - var current = localStorage.getItem("stylesheet"); - if (current) { - document.querySelector('#css_alternative').setAttribute("media", current); - } - - function toggleStylesheet(trigger){ - var setto = 'all'; - var e = document.querySelector('#css_alternative'); - if (e.getAttribute("media") == 'all') { - setto = 'none'; - } - localStorage.setItem("stylesheet", setto); - e.setAttribute("media", setto); - } - </script> -</head> -<body> - -<header class="content-header" id="main-header"> - <nav class="content-navigation"> - <ul> - <li> -{% set cssclass = '' %} -{% if taxonomy is defined and taxonomy.name == '' %} - {% set cssclass = 'active' %} -{% endif %} - <a title="home" href="/" class="{{ cssclass }}"> - <svg class="icon" width="16" height="16"><use xlink:href="#icon-home" /></svg> - home - </a> - </li> -{% set cssclass = '' %} -{% if (post is defined and post.category == 'photo' ) or ( taxonomy is defined and taxonomy.name == 'photo' ) %} - {% set cssclass = 'active' %} -{% endif %} - <li> - <a title="photos" href="/category/photo/" class="{{ cssclass }}"> - <svg class="icon" width="18" height="16"><use xlink:href="#icon-photo" /></svg> - photos - </a> - </li> -{% set cssclass = '' %} -{% if (post is defined and post.category == 'journal' ) or ( taxonomy is defined and taxonomy.name == 'journal' ) %} - {% set cssclass = 'active' %} -{% endif %} - <li> - <a title="journal" href="/category/journal/" class="{{ cssclass }}"> - <svg class="icon" width="16" height="16"><use xlink:href="#icon-journal" /></svg> - journal - </a> - </li> -{% set cssclass = '' %} -{% if (post is defined and post.category == 'article' ) or ( taxonomy is defined and taxonomy.name == 'article' ) %} - {% set cssclass = 'active' %} -{% endif %} - <li> - <a title="IT" href="/category/article/" class="{{ cssclass }}"> - <svg class="icon" width="16" height="16"><use xlink:href="#icon-article" /></svg> - IT - </a> - </li> -{% set cssclass = '' %} -{% if (post is defined and post.category == 'note' ) or ( taxonomy is defined and taxonomy.name == 'note' ) %} - {% set cssclass = 'active' %} -{% endif %} - <li> - <a title="notes" href="/category/note/" class="{{ cssclass }}"> - <svg class="icon" width="16" height="16"><use xlink:href="#icon-note" /></svg> - notes - </a> - </li> - </ul> - </nav> - - <form role="search" method="get" class="search-form" action="/search"> - <label for="s">Search</label> - <input type="search" class="search-field" placeholder="search..." value="" name="s" id="s" title="Search for:" /> - <input type="submit" class="search-submit" value="Go ➡" /> - </form> - - <p class="contrast"> - <a title="toggle site colour scheme" href="#" - onclick="toggleStylesheet(this)"> - <svg class="icon" width="16" height="16"> - <use xlink:href="#icon-contrast" /> - </svg> - </a> - </p> - - -</header>
@@ -1,11 +0,0 @@
-<!DOCTYPE html> -<html{% if post and post.lang %} lang="{{ post.lang }}"{% endif %}> -<head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1" /> - <link rel="icon" href="{{ site.url }}/favicon.ico" /> - <!-- https://indieweb.org/Webmention --> - <link rel="http://webmention.org/" href="{{ site.url }}/webmention" /> - <link rel="webmention" href="{{ site.url }}/webmention" /> - <!-- https://indieweb.org/Microsub --> - <link rel="hub" href="{{ site.websub.hub }}" />
@@ -175,28 +175,84 @@ code {
padding: 0.1em; } -.sourceCode .al { color: green; } -.sourceCode .at { color: green; } -.sourceCode .bn { color: green; } -.sourceCode .bu { color: green; } -.sourceCode .cf { color: green; } -.sourceCode .ch { color: green; } -.sourceCode .co { color: gray; } -.sourceCode .dt { color: green; } -.sourceCode .dv { color: green; } -.sourceCode .er { color: green; } -.sourceCode .ex { color: orange; } -.sourceCode .fl { color: green; } -.sourceCode .fu { color: orange; } -.sourceCode .im { color: green; } -.sourceCode .kw { color: cyan; } -.sourceCode .op { color: green; } -.sourceCode .ot { color: green; } -.sourceCode .pp { color: green; } -.sourceCode .sc { color: green; } -.sourceCode .ss { color: green; } -.sourceCode .st { color: magenta; } -.sourceCode .va { color: turquoise; } +.codehilite .hll { background-color: #222222 } +.codehilite { background: #000000; color: #cccccc } +.codehilite .c { color: #000080 } /* Comment */ +.codehilite .err { color: #cccccc; border: 1px solid #FF0000 } /* Error */ +.codehilite .esc { color: #cccccc } /* Escape */ +.codehilite .g { color: #cccccc } /* Generic */ +.codehilite .k { color: #cdcd00 } /* Keyword */ +.codehilite .l { color: #cccccc } /* Literal */ +.codehilite .n { color: #cccccc } /* Name */ +.codehilite .o { color: #3399cc } /* Operator */ +.codehilite .x { color: #cccccc } /* Other */ +.codehilite .p { color: #cccccc } /* Punctuation */ +.codehilite .ch { color: #000080 } /* Comment.Hashbang */ +.codehilite .cm { color: #000080 } /* Comment.Multiline */ +.codehilite .cp { color: #000080 } /* Comment.Preproc */ +.codehilite .cpf { color: #000080 } /* Comment.PreprocFile */ +.codehilite .c1 { color: #000080 } /* Comment.Single */ +.codehilite .cs { color: #cd0000; font-weight: bold } /* Comment.Special */ +.codehilite .gd { color: #cd0000 } /* Generic.Deleted */ +.codehilite .ge { color: #cccccc; font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00cd00 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { color: #cccccc; font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #cdcd00 } /* Keyword.Constant */ +.codehilite .kd { color: #00cd00 } /* Keyword.Declaration */ +.codehilite .kn { color: #cd00cd } /* Keyword.Namespace */ +.codehilite .kp { color: #cdcd00 } /* Keyword.Pseudo */ +.codehilite .kr { color: #cdcd00 } /* Keyword.Reserved */ +.codehilite .kt { color: #00cd00 } /* Keyword.Type */ +.codehilite .ld { color: #cccccc } /* Literal.Date */ +.codehilite .m { color: #cd00cd } /* Literal.Number */ +.codehilite .s { color: #cd0000 } /* Literal.String */ +.codehilite .na { color: #cccccc } /* Name.Attribute */ +.codehilite .nb { color: #cd00cd } /* Name.Builtin */ +.codehilite .nc { color: #00cdcd } /* Name.Class */ +.codehilite .no { color: #cccccc } /* Name.Constant */ +.codehilite .nd { color: #cccccc } /* Name.Decorator */ +.codehilite .ni { color: #cccccc } /* Name.Entity */ +.codehilite .ne { color: #666699; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #cccccc } /* Name.Function */ +.codehilite .nl { color: #cccccc } /* Name.Label */ +.codehilite .nn { color: #cccccc } /* Name.Namespace */ +.codehilite .nx { color: #cccccc } /* Name.Other */ +.codehilite .py { color: #cccccc } /* Name.Property */ +.codehilite .nt { color: #cccccc } /* Name.Tag */ +.codehilite .nv { color: #00cdcd } /* Name.Variable */ +.codehilite .ow { color: #cdcd00 } /* Operator.Word */ +.codehilite .w { color: #cccccc } /* Text.Whitespace */ +.codehilite .mb { color: #cd00cd } /* Literal.Number.Bin */ +.codehilite .mf { color: #cd00cd } /* Literal.Number.Float */ +.codehilite .mh { color: #cd00cd } /* Literal.Number.Hex */ +.codehilite .mi { color: #cd00cd } /* Literal.Number.Integer */ +.codehilite .mo { color: #cd00cd } /* Literal.Number.Oct */ +.codehilite .sa { color: #cd0000 } /* Literal.String.Affix */ +.codehilite .sb { color: #cd0000 } /* Literal.String.Backtick */ +.codehilite .sc { color: #cd0000 } /* Literal.String.Char */ +.codehilite .dl { color: #cd0000 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #cd0000 } /* Literal.String.Doc */ +.codehilite .s2 { color: #cd0000 } /* Literal.String.Double */ +.codehilite .se { color: #cd0000 } /* Literal.String.Escape */ +.codehilite .sh { color: #cd0000 } /* Literal.String.Heredoc */ +.codehilite .si { color: #cd0000 } /* Literal.String.Interpol */ +.codehilite .sx { color: #cd0000 } /* Literal.String.Other */ +.codehilite .sr { color: #cd0000 } /* Literal.String.Regex */ +.codehilite .s1 { color: #cd0000 } /* Literal.String.Single */ +.codehilite .ss { color: #cd0000 } /* Literal.String.Symbol */ +.codehilite .bp { color: #cd00cd } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #cccccc } /* Name.Function.Magic */ +.codehilite .vc { color: #00cdcd } /* Name.Variable.Class */ +.codehilite .vg { color: #00cdcd } /* Name.Variable.Global */ +.codehilite .vi { color: #00cdcd } /* Name.Variable.Instance */ +.codehilite .vm { color: #00cdcd } /* Name.Variable.Magic */ +.codehilite .il { color: #cd00cd } /* Literal.Number.Integer.Long */ .limit, .content-body {
@@ -18,12 +18,75 @@ color: darkgreen;
background-color: #eee; } -.sourceCode .co { color: gray; } -.sourceCode .ex { color: darkorange; } -.sourceCode .fu { color: darkorange; } -.sourceCode .kw { color: darkcyan; } -.sourceCode .st { color: darkmagenta; } -.sourceCode .va { color: darkturquoise; } +.codehilite .hll { background-color: #ffffcc } +.codehilite { background: #f8f8f8; } +.codehilite .c { color: #408080; font-style: italic } /* Comment */ +.codehilite .err { border: 1px solid #FF0000 } /* Error */ +.codehilite .k { color: #008000; font-weight: bold } /* Keyword */ +.codehilite .o { color: #666666 } /* Operator */ +.codehilite .ch { color: #408080; font-style: italic } /* Comment.Hashbang */ +.codehilite .cm { color: #408080; font-style: italic } /* Comment.Multiline */ +.codehilite .cp { color: #BC7A00 } /* Comment.Preproc */ +.codehilite .cpf { color: #408080; font-style: italic } /* Comment.PreprocFile */ +.codehilite .c1 { color: #408080; font-style: italic } /* Comment.Single */ +.codehilite .cs { color: #408080; font-style: italic } /* Comment.Special */ +.codehilite .gd { color: #A00000 } /* Generic.Deleted */ +.codehilite .ge { font-style: italic } /* Generic.Emph */ +.codehilite .gr { color: #FF0000 } /* Generic.Error */ +.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.codehilite .gi { color: #00A000 } /* Generic.Inserted */ +.codehilite .go { color: #888888 } /* Generic.Output */ +.codehilite .gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.codehilite .gs { font-weight: bold } /* Generic.Strong */ +.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.codehilite .gt { color: #0044DD } /* Generic.Traceback */ +.codehilite .kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.codehilite .kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.codehilite .kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.codehilite .kp { color: #008000 } /* Keyword.Pseudo */ +.codehilite .kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.codehilite .kt { color: #B00040 } /* Keyword.Type */ +.codehilite .m { color: #666666 } /* Literal.Number */ +.codehilite .s { color: #BA2121 } /* Literal.String */ +.codehilite .na { color: #7D9029 } /* Name.Attribute */ +.codehilite .nb { color: #008000 } /* Name.Builtin */ +.codehilite .nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.codehilite .no { color: #880000 } /* Name.Constant */ +.codehilite .nd { color: #AA22FF } /* Name.Decorator */ +.codehilite .ni { color: #999999; font-weight: bold } /* Name.Entity */ +.codehilite .ne { color: #D2413A; font-weight: bold } /* Name.Exception */ +.codehilite .nf { color: #0000FF } /* Name.Function */ +.codehilite .nl { color: #A0A000 } /* Name.Label */ +.codehilite .nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.codehilite .nt { color: #008000; font-weight: bold } /* Name.Tag */ +.codehilite .nv { color: #19177C } /* Name.Variable */ +.codehilite .ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.codehilite .w { color: #bbbbbb } /* Text.Whitespace */ +.codehilite .mb { color: #666666 } /* Literal.Number.Bin */ +.codehilite .mf { color: #666666 } /* Literal.Number.Float */ +.codehilite .mh { color: #666666 } /* Literal.Number.Hex */ +.codehilite .mi { color: #666666 } /* Literal.Number.Integer */ +.codehilite .mo { color: #666666 } /* Literal.Number.Oct */ +.codehilite .sa { color: #BA2121 } /* Literal.String.Affix */ +.codehilite .sb { color: #BA2121 } /* Literal.String.Backtick */ +.codehilite .sc { color: #BA2121 } /* Literal.String.Char */ +.codehilite .dl { color: #BA2121 } /* Literal.String.Delimiter */ +.codehilite .sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.codehilite .s2 { color: #BA2121 } /* Literal.String.Double */ +.codehilite .se { color: #BB6622; font-weight: bold } /* Literal.String.Escape */ +.codehilite .sh { color: #BA2121 } /* Literal.String.Heredoc */ +.codehilite .si { color: #BB6688; font-weight: bold } /* Literal.String.Interpol */ +.codehilite .sx { color: #008000 } /* Literal.String.Other */ +.codehilite .sr { color: #BB6688 } /* Literal.String.Regex */ +.codehilite .s1 { color: #BA2121 } /* Literal.String.Single */ +.codehilite .ss { color: #19177C } /* Literal.String.Symbol */ +.codehilite .bp { color: #008000 } /* Name.Builtin.Pseudo */ +.codehilite .fm { color: #0000FF } /* Name.Function.Magic */ +.codehilite .vc { color: #19177C } /* Name.Variable.Class */ +.codehilite .vg { color: #19177C } /* Name.Variable.Global */ +.codehilite .vi { color: #19177C } /* Name.Variable.Instance */ +.codehilite .vm { color: #19177C } /* Name.Variable.Magic */ +.codehilite .il { color: #666666 } /* Literal.Number.Integer.Long */ .donation li a { color: #333;