Merge pull request #3 from Mindiell/refonte

Refonte
This commit is contained in:
Julien Palard 2021-05-01 17:09:19 +02:00 committed by GitHub
commit 577131fc3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 200 additions and 576 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.venv/
__pycache__/
config-*.py

View File

@ -1,3 +1,14 @@
# Running
# AfpyLogs
Web view of IRC logs from #afpy channel on Freenode.
## Installing
pip install -r requirements.txt
## Running
gunicorn --workers 2 --bind 0.0.0.0:8000 app
Run with: `gunicorn -w 2 --paste deploy.ini`.

View File

View File

@ -1,142 +0,0 @@
import datetime
import os
from operator import itemgetter
from collections import defaultdict
import codecs
from webob import Request, Response
from webob.exc import HTTPFound
from paste.fileapp import FileApp
from paste.deploy.config import ConfigMiddleware
from paste.deploy.config import CONFIG
from afpylogs.parser import parse
from afpylogs.utils import get_stylesheets, get_header
dirname = os.path.dirname(os.path.abspath(__file__))
def path_from_here(*args):
return os.path.join(dirname, *args)
MONTH_TPL = u"""
<dt class="portletHeader {active}"><a href="#">{date:%Y %m}</a></dt>
<dd class="portletItem">{rendered_days}</dd>
"""
DAY_TPL = u'<a class="{active}" href="/archives/{date:%Y/%m/%d}/">{date:%d}</a> '
def render_archives(archives, active):
"""Given an archive dict, return the corresponding HTML."""
rendered = ['<dl class="portlet">']
for year, months in sorted(archives.items(), key=itemgetter(0),
reverse=True):
for month, days in sorted(months.items(), key=itemgetter(0),
reverse=True):
is_active_month = (active.year, active.month) == (year, month)
rendered.append(MONTH_TPL.format(
active='active' if is_active_month else '',
date=datetime.date(year, month, 1), # first day of the current month
rendered_days=render_days(year, month, days, active)
))
rendered.append('</dl>')
return u'\n'.join(rendered)
def render_days(year, month, days, active):
"""Return the HTML corresponding to all the available days of a given month."""
rendered = []
for day in sorted(days):
current_date = datetime.date(year, month, day)
is_active_day = active == current_date
rendered.append(DAY_TPL.format(
date=current_date,
active='active' if is_active_day else '',
))
return u'\n'.join(rendered)
def get_archives(path, active):
"""Return HTML with links corresponding to all available days.
The list is reversed chronologically, with most recent days appearing at
the top."""
archives = defaultdict(lambda: defaultdict(list))
for filename in os.listdir(path):
if not filename.endswith('.txt'):
continue
_, year, month, day = filename.replace('.txt','').split('-')
archives[int(year)][int(month)].append(int(day))
return render_archives(archives, active)
def serve_static(filepath):
return FileApp(filepath)
def r404(environ, start_response, text=u"File not found"):
resp = Response(status=404, text=text)
return resp(environ, start_response)
def redirect(environ, start_response, location):
resp = HTTPFound(location=location)
return resp(environ, start_response)
def application(environ, start_response):
logfile_encoding = CONFIG.get('charset', 'utf-8') # XXX: actually depends on the date (see below)
response_encoding = 'utf-8'
template_encoding = 'utf-8'
STATIC = ['jquery.js', 'transcript.js']
template = CONFIG.get('template', path_from_here('template.html'))
channel = CONFIG.get('channel', '')
path = CONFIG.get('path', '')
path_info = Request(environ).path_info[1:] # remove leading /
if path_info in STATIC:
app = serve_static(path_from_here(path_info))
return app(environ, start_response)
if not path_info:
date = datetime.date.today()
location = 'archives/{:%Y/%m/%d/}'.format(date)
return redirect(environ, start_response, location)
else:
try:
date = datetime.datetime.strptime(path_info, 'archives/%Y/%m/%d/').date()
except ValueError:
return r404(environ, start_response)
# XXX: logfiles changed encoding at some point
if date <= datetime.date(2011, 6, 5):
logfile_encoding = 'latin1'
filename = 'log-{:%Y-%m-%d}.txt'.format(date)
filepath = os.path.join(path, filename)
if not os.path.isfile(filepath):
return r404(environ, start_response)
output = parse('#%s' % channel, filepath, logfile_encoding)
archives = get_archives(path, active=date)
with codecs.open(template, encoding=template_encoding) as fd:
tpl = fd.read()
body = tpl % {
'channel': channel,
'body': output,
'archives': archives,
'stylesheets': get_stylesheets(),
'header': get_header(),
'date': date.strftime('%Y/%m/%d'),
}
resp = Response(
content_type="text/html",
charset=response_encoding,
)
resp.text = body
return resp(environ, start_response)
def factory(global_config, **local_config):
"""Aplication factory to expand configs"""
conf = global_config.copy()
conf.update(**local_config)
return ConfigMiddleware(application, conf)

32
afpylogs/jquery.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,96 +0,0 @@
from __future__ import unicode_literals
import re
import codecs
from collections import namedtuple
from datetime import time
from html import escape
IRCLine = namedtuple('IRCLine', ['timestamp', 'nick', 'message'])
# Auto-linkification
_re_link = re.compile(r'.*(https?://\S+).*') # detect URLs
_tpl_link = '<a href="{link}" rel="nofollow">{link}</a>' # Template used to render them
# IRSSI log format
_re_line = re.compile(r'''^
(?P<hour>\d{2}):(?P<minute>\d{2})\s+
((<(?P<nick>[^>]+)>)|(\[\#\]))\s+
(?P<message>.*)
$
''', re.VERBOSE)
_tpl_action_message = '<span class="action">[#] {message}</span>'
_tpl_normal_message = (
'<span class="barket">&lt;</span>'
'<span class="nick">{nick}</span>'
'<span class="barket">&gt;</span>'
' {message}'
)
_tpl_line = (
'<div>'
'<a id="{line_number}" href="#{line_number}">{timestamp:%H}:{timestamp:%M}</a>'
' {message}'
'</div>'
)
def get_lines(path, encoding='utf-8'):
"""Yield IRCLine tuples from the given path to an irssi log file.
Lines beginning with '---' will be omitted.
All lines are stripped from whitespace before being returned."""
with codecs.open(path, encoding=encoding) as f:
for line in f:
if line.startswith('---'):
continue
try:
yield parse_line(line.strip())
except ValueError:
continue # TODO: log failure?
def linkify(message):
"""Make <a> tags for URLs in the given message. Return the HTML."""
for link in set(_re_link.findall(message)):
message = message.replace(link, _tpl_link.format(link=link))
return message
def render_line(**context):
"""Render a line to HTML.
Depending on whether the message is a normal or an "action" one, use
a different template."""
message_tpl = _tpl_normal_message if context['nick'] else _tpl_action_message
context['message'] = message_tpl.format(**context)
return _tpl_line.format(**context)
def parse_line(line):
"""Parse a line from the log and return an IRCLine tuple."""
match = _re_line.search(line)
if not match:
raise ValueError('Unhandled line %r' % line)
data = match.groupdict()
data['timestamp'] = time(hour=int(data.pop('hour')), minute=int(data.pop('minute')))
return IRCLine(**data)
def parse(channel, path, encoding='utf-8'):
"""Turn an irssi log line into HTML, URLs are converted to <a>.
Normal lines look like this:
09:42 < bmispelon> this is the message
Some lines are "action" ones (usually, the result of a "/me ... command")
and look like this:
09:42 [#] this is the message
"""
stdout = []
for i, line_tuple in enumerate(get_lines(path, encoding), start=1):
message = linkify(escape(line_tuple.message))
line_tuple = line_tuple._replace(message=message)
stdout.append(render_line(
line_number=i,
**line_tuple._asdict()
))
return '\n'.join(stdout)
if __name__ == '__main__':
import doctest
doctest.testmod()

View File

@ -1,39 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Logs du chan #afpy pour le %(date)s</title>
<style type="text/css">
.barket {color: #ccc;}
.nick {color: #8A6642;}
.action {color: #a4c;}
#logs_navigation dt.active a {
color: rgb(117, 173, 10);
}
#logs_navigation dd a.active {
font-weight: bold;
color: rgb(117, 173, 10);
}
</style>
%(stylesheets)s
</head>
<body>
<div id="visual-portal-wrapper">
%(header)s
<div id="portal-columns" class="row">
<div id="portal-column-content" class="cell width-3:4 position-1:4">
<div id="letters"></div>
<div id="contents">
%(body)s
</div>
</div>
<div id="portal-column-one" class="cell width-1:4 position-0">
<div id="logs_navigation">%(archives)s</div>
</div>
</div>
</div>
<script src="/jquery.js" type="text/javascript"></script>
<script src="/transcript.js" type="text/javascript"></script>
</body>
</html>

View File

@ -1,14 +0,0 @@
jQuery(document).ready(
function(e) {
$('#logs_navigation dt').each(function () {
var dt=$(this);
var dd = dt.next('dd');
if (!dt.hasClass('active')) {
dd.hide();
}
dt.click(function (e) {
dd.slideToggle();
e.preventDefault();
})
});
});

View File

@ -1,32 +0,0 @@
"""
We do it this way so that we can re-use the afpy stylesheets and navigation menu
that plone generates.
We use a global variable as the document as a poor man's cache.
"""
from pyquery import PyQuery
document = PyQuery(url='https://www.afpy.org')
# Remove some stuff we don't want
document('#portal-searchbox, #portal-personaltools-wrapper').remove()
def as_absolute(html):
for attr in 'href', 'src':
html = html.replace(f'{attr}="/', f'{attr}="https://afpy.org/')
return html
def get_stylesheets():
"""
All the stylesheets used in the <head>, as a string.
"""
global document
STYLESHEET_SELECTOR = 'head style, head link[@rel=stylesheet]'
return as_absolute('\n'.join(str(elt) for elt in document(STYLESHEET_SELECTOR).items()))
def get_header():
"""
The navigation menu as a string.
"""
global document
HEADER_SELECTOR = 'nav.menu'
return as_absolute(str(document(HEADER_SELECTOR)))

89
app.py Normal file
View File

@ -0,0 +1,89 @@
# encoding: utf-8
import os
import re
from bleach import Cleaner
from bleach.linkifier import LinkifyFilter
from flask import Flask, g, redirect, render_template, url_for
application = Flask(__name__, template_folder=".")
application.config.from_object("config")
try:
application.config.from_object(f"config-{application.config['ENV']}")
except Exception as e:
print(
"Starting without specific configuration"
f"file config-{application.config['ENV']}.py"
)
application.jinja_env.trim_blocks = application.config["JINJA_ENV"]["TRIM_BLOCKS"]
application.jinja_env.lstrip_blocks = application.config["JINJA_ENV"]["LSTRIP_BLOCKS"]
LOG_PATTERN = re.compile(application.config["LOG_PATTERN"])
BOLD_PATTERN = re.compile(application.config["BOLD_PATTERN"])
def get_archives():
archives = []
dates = {"years": [], "months": {}, "days": {}}
for filename in sorted(os.listdir(application.config["LOG_PATH"])):
date = filename[:-4].split("-")[1:]
archives.append(date)
if date[0] not in dates["years"]:
dates["years"].append(date[0])
dates["months"][date[0]] = []
if date[1] not in dates["months"][date[0]]:
dates["months"][date[0]].append(date[1])
dates["days"]["%s%s" % tuple(date[:2])] = []
if date[2] not in dates["days"]["%s%s" % tuple(date[:2])]:
dates["days"]["%s%s" % tuple(date[:2])].append(date[2])
return archives, dates
@application.route("/")
@application.route("/archives/<year>")
@application.route("/archives/<year>/<month>")
@application.route("/archives/<year>/<month>/<day>")
def archives(year=None, month=None, day=None):
# Récupération des fichiers disponibles
archives, g.dates = get_archives()
# Récupération de la date souhaitée
if (
year is None
or month is None
or day is None
or [year, month, day] not in archives
):
# Si date mal ou non fournie ou inexistante, on prend la dernière
year = archives[-1][0]
month = archives[-1][1]
day = archives[-1][2]
# Et on redirige proprement
return redirect(url_for("archives", year=year, month=month, day=day))
# Ok, on charge et on affiche le contenu du fichier
filename = "log-%s-%s-%s.txt" % (year, month, day)
filepath = os.path.join(application.config["LOG_PATH"], filename)
with open(filepath, encoding="utf-8") as f:
lines = f.read().splitlines()
g.lines = []
g.year, g.month, g.day = year, month, day
cleaner = Cleaner(tags=["b"], filters=[LinkifyFilter])
for line in lines:
result = LOG_PATTERN.match(line)
if result is not None:
message = result.group("message")
for text in BOLD_PATTERN.findall(message):
message = message.replace(
text, application.config["BOLD_HTML"].format(text=text)
)
message = cleaner.clean(message)
g.lines.append(
{
"time": result.group("time"),
"nick": result.group("nick"),
"message": message,
}
)
return render_template("template.html")

View File

@ -1,179 +0,0 @@
##############################################################################
#
# Copyright (c) 2006 Zope Foundation and Contributors.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""Bootstrap a buildout-based project
Simply run this script in a directory containing a buildout.cfg.
The script accepts buildout command-line options, so you can
use the -c option to specify an alternate configuration file.
"""
import os
import shutil
import sys
import tempfile
from optparse import OptionParser
tmpeggs = tempfile.mkdtemp()
usage = '''\
[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options]
Bootstraps a buildout-based project.
Simply run this script in a directory containing a buildout.cfg, using the
Python that you want bin/buildout to use.
Note that by using --find-links to point to local resources, you can keep
this script from going over the network.
'''
parser = OptionParser(usage=usage)
parser.add_option("-v", "--version", help="use a specific zc.buildout version")
parser.add_option("-t", "--accept-buildout-test-releases",
dest='accept_buildout_test_releases',
action="store_true", default=False,
help=("Normally, if you do not specify a --version, the "
"bootstrap script and buildout gets the newest "
"*final* versions of zc.buildout and its recipes and "
"extensions for you. If you use this flag, "
"bootstrap and buildout will get the newest releases "
"even if they are alphas or betas."))
parser.add_option("-c", "--config-file",
help=("Specify the path to the buildout configuration "
"file to be used."))
parser.add_option("-f", "--find-links",
help=("Specify a URL to search for buildout releases"))
parser.add_option("--allow-site-packages",
action="store_true", default=False,
help=("Let bootstrap.py use existing site packages"))
options, args = parser.parse_args()
######################################################################
# load/install setuptools
try:
if options.allow_site_packages:
import setuptools
import pkg_resources
from urllib.request import urlopen
except ImportError:
from urllib2 import urlopen
ez = {}
exec(urlopen('https://bitbucket.org/pypa/setuptools/downloads/ez_setup.py'
).read(), ez)
if not options.allow_site_packages:
# ez_setup imports site, which adds site packages
# this will remove them from the path to ensure that incompatible versions
# of setuptools are not in the path
import site
# inside a virtualenv, there is no 'getsitepackages'.
# We can't remove these reliably
if hasattr(site, 'getsitepackages'):
for sitepackage_path in site.getsitepackages():
sys.path[:] = [x for x in sys.path if sitepackage_path not in x]
setup_args = dict(to_dir=tmpeggs, download_delay=0)
ez['use_setuptools'](**setup_args)
import setuptools
import pkg_resources
# This does not (always?) update the default working set. We will
# do it.
for path in sys.path:
if path not in pkg_resources.working_set.entries:
pkg_resources.working_set.add_entry(path)
######################################################################
# Install buildout
ws = pkg_resources.working_set
cmd = [sys.executable, '-c',
'from setuptools.command.easy_install import main; main()',
'-mZqNxd', tmpeggs]
find_links = os.environ.get(
'bootstrap-testing-find-links',
options.find_links or
('http://downloads.buildout.org/'
if options.accept_buildout_test_releases else None)
)
if find_links:
cmd.extend(['-f', find_links])
setuptools_path = ws.find(
pkg_resources.Requirement.parse('setuptools')).location
requirement = 'zc.buildout'
version = options.version
if version is None and not options.accept_buildout_test_releases:
# Figure out the most recent final version of zc.buildout.
import setuptools.package_index
_final_parts = '*final-', '*final'
def _final_version(parsed_version):
for part in parsed_version:
if (part[:1] == '*') and (part not in _final_parts):
return False
return True
index = setuptools.package_index.PackageIndex(
search_path=[setuptools_path])
if find_links:
index.add_find_links((find_links,))
req = pkg_resources.Requirement.parse(requirement)
if index.obtain(req) is not None:
best = []
bestv = None
for dist in index[req.project_name]:
distv = dist.parsed_version
if _final_version(distv):
if bestv is None or distv > bestv:
best = [dist]
bestv = distv
elif distv == bestv:
best.append(dist)
if best:
best.sort()
version = best[-1].version
if version:
requirement = '=='.join((requirement, version))
cmd.append(requirement)
import subprocess
if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0:
raise Exception(
"Failed to execute command:\n%s",
repr(cmd)[1:-1])
######################################################################
# Import and run buildout
ws.add_entry(tmpeggs)
ws.require(requirement)
import zc.buildout.buildout
if not [a for a in args if '=' not in a]:
args.append('bootstrap')
# if -c was provided, we push it back into args for buildout' main function
if options.config_file is not None:
args[0:0] = ['-c', options.config_file]
zc.buildout.buildout.main(args)
shutil.rmtree(tmpeggs)

17
config.py Normal file
View File

@ -0,0 +1,17 @@
# encoding: utf-8
SECRET_KEY = "Choose a secret key"
JINJA_ENV = {
"TRIM_BLOCKS": True,
"LSTRIP_BLOCKS": True,
}
LOG_PATH = "/var/www/logs.afpy.org"
# IRSSI log pattern
DATE_FORMAT = "(\d+-\d+-\d+ )?(?P<time>\d\d:\d\d)"
LOG_PATTERN = r"^%s\s+[<*]\s*(?P<nick>[^> ]+)[> ]\s+(?P<message>.*)$" % DATE_FORMAT
BOLD_PATTERN = r"\*[^*\s]+\*"
BOLD_HTML = "<b>{text}</b>"

View File

@ -1,10 +0,0 @@
[app:main]
use = egg:AfpyLogs
path = /var/www/logs.afpy.org
channel = #afpy
[server:main]
use = egg:Paste#http
host = 0.0.0.0
port = 6085

2
requirements.txt Normal file
View File

@ -0,0 +1,2 @@
flask
gunicorn

View File

@ -1,7 +0,0 @@
[egg_info]
tag_build = dev
tag_svn_revision = true
[aliases]
iw_upload = register sdist bdist_egg upload -r http://products.ingeniweb.com/catalog

View File

@ -1,23 +0,0 @@
from setuptools import setup, find_packages
version = "0.1"
setup(
name="AfpyLogs",
version=version,
keywords="",
author="Gael Pasgrimaud",
author_email="gael@gawel.org",
url="",
license="GPL",
packages=find_packages(exclude=["ez_setup"]),
include_package_data=True,
zip_safe=False,
# uncomment this to be able to run tests with setup.py
# test_suite = "gp.transcript.tests.test_transcriptdocs.test_suite",
install_requires=["paste", "pyquery", "webob", "pastedeploy", "gunicorn"],
entry_points="""
[paste.app_factory]
main = afpylogs.app:factory
""",
)

76
template.html Normal file
View File

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Logs du chan #afpy pour le </title>
<style type="text/css">
body {background-color: #000; color: #fff; font-family: "Verdana"}
.time {color: #ffd738; font-size: .9em; font-weight: bold}
.bracket {color: #ccc; font-size: .8em}
.nick {color: #dAa642}
.message {color: #eaeaea}
.action {color: #a4c}
.click {cursor: pointer}
.off {display: none}
.on {display: block}
#content a {color: #ffd738; font-size: .9em; font-weight: bold; text-decoration: none}
#content a:hover {text-decoration: underline}
.calendar {color: #ffd738}
.day {font-weight: bold}
#calendar {padding-bottom: .6em; margin-bottom: .6em; border-bottom: 1px dashed #ffd738}
#calendar a {color: #ffd738; text-decoration: none}
#calendar a:hover {text-decoration: underline}
</style>
<script type="text/javascript">
function hide(id) {
var els = document.getElementsByClassName("on");
[].forEach.call(els, function (el){
console.log(el.id);
console.log(id);
console.log(id.substr(0, el.id.length));
console.log(id.substr(0, el.id.length) != el.id);
if (el.id != id.substr(0, el.id.length)) {
el.className = "off";
}
});
}
function display(id) {
hide(id.toString());
var el = document.getElementById(id);
if (el!=null) {
el.className = "on";
}
}
</script>
</head>
<body>
<div id="calendar">
<nav>
{% for year in g.dates.years %}
<span onclick="display('{{ year }}')" class="calendar click {%if year==g.year%}day{%endif%}">{{ year }}</span>
{% endfor %}
</nav>
{% for year in g.dates.years %}
<nav id="{{year}}" class="{%if year==g.year%}on{%else%}off{%endif%}">
{% for month in g.dates.months[year] %}
<span onclick="display('{{ year }}{{ month }}')" class="calendar click {%if year==g.year and month==g.month%}day{%endif%}">{{ month }}</span>
{% endfor %}
{% for month in g.dates.months[year] %}
<nav id="{{year}}{{month}}" class="{%if year==g.year and month==g.month%}on{%else%}off{%endif%}">
{% for day in g.dates.days[year+month] %}
<a href="{{url_for('archives', year=year, month=month, day=day)}}" class="calendar {%if year==g.year and month==g.month and day==g.day%}day{%endif%}">{{ day }}</a>
{% endfor %}
</nav>
{% endfor %}
</nav>
{% endfor %}
</div>
<div id="content">
{% for line in g.lines %}
<span class="time">{{ line.time }}</span> <span class="bracket">&lt;</span><span class="nick">{{ line.nick }}</span><span class="bracket">&gt;</span> <span class="message">{{ line.message|safe }}</span><br />
{% endfor %}
</div>
</body>
</html>