diff --git a/.gitignore b/.gitignore index 5250e072..f250e7a5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ *.pyo env dist +website/_mailinglist/* *.egg-info diff --git a/website/_mailinglist/.ignore b/website/_mailinglist/.ignore new file mode 100644 index 00000000..e69de29b diff --git a/website/flask_website.py b/website/flask_website.py new file mode 100644 index 00000000..5db93155 --- /dev/null +++ b/website/flask_website.py @@ -0,0 +1,110 @@ +from __future__ import with_statement +from hashlib import md5 +from werkzeug import parse_date +from flask import Flask, render_template, json, url_for, abort, Markup +from jinja2.utils import urlize +app = Flask(__name__) + + +THREADS_PER_PAGE = 15 + + +class Mail(object): + + def __init__(self, d): + self.msgid = d['msgid'] + self.author_name, self.author_addr = d['author'] + self.date = parse_date(d['date']) + self.subject = d['subject'] + self.children = [Mail(x) for x in d['children']] + self.text = d['text'] + + @property + def rendered_text(self): + result = [] + in_sig = False + for line in self.text.splitlines(): + if line == u'-- ': + in_sig = True + if in_sig: + line = Markup(u'%s' % line) + elif line.startswith('>'): + line = Markup(u'%s' % line) + result.append(urlize(line)) + return Markup(u'\n'.join(result)) + + @property + def id(self): + return md5(self.msgid.encode('utf-8')).hexdigest() + + +class Thread(object): + + def __init__(self, d): + self.slug = d['slug'].rsplit('/', 1)[-1] + self.title = d['title'] + self.reply_count = d['reply_count'] + self.author_name, self.author_email = d['author'] + self.date = parse_date(d['date']) + if 'root' in d: + self.root = Mail(d['root']) + + @staticmethod + def get(year, month, day, slug): + try: + with app.open_resource('_mailinglist/threads/%s-%02d-%02d/%s' % + (year, month, day, slug)) as f: + return Thread(json.load(f)) + except IOError: + pass + + @staticmethod + def get_list(): + with app.open_resource('_mailinglist/threads/threadlist') as f: + return [Thread(x) for x in json.load(f)] + + @property + def url(self): + return url_for('mailinglist_show_thread', year=self.date.year, + month=self.date.month, day=self.date.day, + slug=self.slug) + + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/mailinglist/') +def mailinglist_index(): + return render_template('mailinglist/index.html') + + +@app.route('/mailinglist/archive/', defaults={'page': 1}) +@app.route('/mailinglist/archive/page/') +def mailinglist_archive(page): + all_threads = Thread.get_list() + offset = (page - 1) * THREADS_PER_PAGE + threads = all_threads[offset:offset + THREADS_PER_PAGE] + if page != 1 and not threads: + abort(404) + return render_template('mailinglist/archive.html', + page_count=len(threads) // THREADS_PER_PAGE + 1, + page=page, threads=threads) + + +@app.route('/mailinglist/archives////') +def mailinglist_show_thread(year, month, day, slug): + thread = Thread.get(year, month, day, slug) + if thread is None: + abort(404) + return render_template('mailinglist/show_thread.html', thread=thread) + + +@app.errorhandler(404) +def not_found(error): + return render_template('404.html'), 404 + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/website/logo.png b/website/static/logo.png similarity index 100% rename from website/logo.png rename to website/static/logo.png diff --git a/website/static/mailinglist.js b/website/static/mailinglist.js new file mode 100644 index 00000000..abd3b30f --- /dev/null +++ b/website/static/mailinglist.js @@ -0,0 +1,33 @@ +$(function() { + var first_mail = $('div.mail:first')[0].id; + + function display(id) { + var pos = { + x: window.pageXOffset || document.body.scrollLeft, + y: window.pageYOffset || document.body.scrollTop + }; + $('ul.mailtree div.link').removeClass('selected'); + $('#link-' + id).addClass('selected').focus(); + $('div.mail').hide(); + $('#' + id).show(); + if (!(document.location.hash == '' && id == first_mail)) + document.location.href = '#' + id; + window.scrollTo(pos.x, pos.y); + } + + $('div.mail') + .addClass('dynamic-mail') + .appendTo($('
').insertBefore('div.mail:first')) + .hide(); + + $('div.link').each(function() { + var id = $('a', $(this).parent()).attr('href').substr(1); + $(this).click(function() { + display(id); + return false; + }); + }).css({cursor: 'pointer'}); + + var href = document.location.href.split(/#/, 2)[1]; + display(href != null ? href : first_mail); +}); diff --git a/website/static/mailinglist.png b/website/static/mailinglist.png new file mode 100755 index 00000000..96aeb583 Binary files /dev/null and b/website/static/mailinglist.png differ diff --git a/website/mask.png b/website/static/mask.png similarity index 100% rename from website/mask.png rename to website/static/mask.png diff --git a/website/ship.png b/website/static/ship.png similarity index 100% rename from website/ship.png rename to website/static/ship.png diff --git a/website/static/style.css b/website/static/style.css new file mode 100644 index 00000000..d3ca4793 --- /dev/null +++ b/website/static/style.css @@ -0,0 +1,41 @@ +body { font-family: 'Georgia', serif; font-size: 17px; color: #000; } +a { color: #004B6B; } +a:hover { color: #6D4100; } +.box { width: 540px; margin: 40px auto; } +h1, h2, h3 { font-family: 'Garamond', 'Georgia', serif; font-weight: normal; } +h2 { font-size: 28px; margin: 15px 0 5px 0; } +h3 { font-size: 22px; margin: 15px 0 5px 0; } +code, +pre { font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', + monospace; font-size: 15px; background: #eee; } +pre { padding: 7px 30px; margin: 15px -30px; line-height: 1.3; } +.ig { color: #888; } +p { line-height: 1.4; } +ul { margin: 15px 0 15px 0; padding: 0; list-style: none; } +ul li:before { content: "\00BB \0020"; color: #888; position: absolute; margin-left: -19px; } +blockquote { margin: 0; font-style: italic; color: #444; } +.footer { font-size: 13px; color: #888; text-align: right; margin-top: 25px; } +.backnav { text-align: center; color: #444; font-style: italic; } + +/* mailinglist */ +.pagination { text-align: center; font-size: 15px; margin: 20px 0 0 0; } +.disabled { color: #888; } +.archive .meta { font-size: 0.9em; display: block; margin: 0 0 0.5em 1em; } +.mailtree { border-top: 1px solid black; padding: 5px 10px 0 10px; + border-bottom: 1px solid black; font-size: 14px; } +.mailtree ul { margin: 5px 0 5px 15px; } +.mailtree li:before { display: none; } +.mailtree .selected:before { content: "\00BB \0020"; color: #888; + position: absolute; margin-left: -10px; } +.mail { margin: 15px 0; } +.children .mail { margin: 15px 0 15px 20px; } +.dynamic-mail { margin-left: 0!important; } +.mail dl { margin: 0; padding-bottom: 10px; + border-bottom: 1px solid black; } +.mail dl dt { color: #888; width: 70px; float: left; height: 20px; } +.mail dl dd { height: 20px; width: 500px; } +.mail dl dd.from { text-decoration: underline; } +.mail pre { background: transparent; font-size: 13px; + line-height: 1.15; } +.mail .quote { color: #004B6B; } +.mail .sig { color: #888; } diff --git a/website/sync-librelist.py b/website/sync-librelist.py new file mode 100644 index 00000000..04dbe33d --- /dev/null +++ b/website/sync-librelist.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + sync librelist + ~~~~~~~~~~~~~~ + + Pulls in the latest version of the mails from the Flask librelist + mailinglist and sorts them by thread into the processed folder as + json dumps with the most relevant information. + + This will also trigger the rsync. + + :copyright: Copyright 2010 by Armin Ronacher. + :license: BSD, see LICENSE for more details. +""" +from __future__ import with_statement + +import os +import re +import unicodedata +from glob import glob +from subprocess import Popen + +from flask import json +from werkzeug import Headers, parse_date + + +INCOMING_MAIL_FOLDER = '_mailinglist/incoming' +THREAD_FOLDER = '_mailinglist/threads' +LIST_NAME = 'zine' +RSYNC_PATH = 'librelist.com::json/%s' +SUBJECT_PREFIX = '[zine]' + + +_punctuation_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.:]+') +_mail_split_re = re.compile(r'"?(.*?)"?(?:\s+<([^>]+)>)?$') +_string_inc_re = re.compile(r'(\d+)$') +_msgid_re = re.compile(r'<([^>]+)>') + + +def unquote_msgid(msgid): + msgid = (msgid or '').strip().strip('<>') + if msgid: + if '@' in msgid: + a, b = msgid.split('@', 1) + return a.strip('"') + '@' + b + return msgid.decode('iso-8859-15', 'replace') + + +def split_email(s): + p1, p2 = _mail_split_re.match(s.strip()).groups() + if p2: + words = p1.split() + for idx, word in enumerate(words): + if word.isupper(): + words[idx] = word.capitalize() + return u' '.join(words), p2 + elif '@' in p1: + return None, p1 + return p1, None + + +def increment_string(string): + match = _string_inc_re.search(string) + if match is None: + return string + u'2' + return string[:match.start()] + unicode(int(match.group(1)) + 1) + + +def strip_subject_prefix(string): + """Unstrips a title""" + if string.startswith(SUBJECT_PREFIX): + return string[len(SUBJECT_PREFIX):].lstrip() + if string[:3].lower() in (u'aw:', u're:'): + return u'Re: ' + strip_subject_prefix(string[3:].lstrip()) + if string[:3].lower() in (u'fw:', u'wg:'): + return u'Fw: ' + strip_subject_prefix(string[3:].lstrip()) + return string + + +def rsync(): + """Invokes rsync""" + Popen(['rsync', '-qazv', RSYNC_PATH % LIST_NAME, + INCOMING_MAIL_FOLDER]).wait() + + +class Tree(object): + + def __init__(self, threads): + self.threads = threads + self.processed_mail = set() + self._new_mail = [] + self._known_ids = {} + + def _walk_mails(mails): + for mail in mails: + self.processed_mail.add(mail['fsid']) + self._known_ids[mail['msgid']] = mail + _walk_mails(mail['children']) + _walk_mails(x['root'] for x in threads) + + def slug_used(self, slug): + for thread in self.threads: + if thread['slug'] == slug: + return True + return False + + def generate_slug(self, mail): + date = parse_date(mail['date']) + if date is None: + date = 'missing-date' + else: + date = date.strftime('%Y-%m-%d') + rv = u'%s/%s' % (date, + '-'.join(x for x in _punctuation_re.split( + unicodedata.normalize('NFKC', unicode(mail['subject'])) + .encode('ascii', 'ignore')) if x).lower()) + while self.slug_used(rv): + rv = increment_string(rv) + return rv + + def walk(self): + return self._known_ids.itervalues() + + def add_new_mail(self, f, fsid): + mail = parse_mail(f, fsid) + self._new_mail.append(mail) + self._known_ids[mail['msgid']] = mail + + def add_thread_for(self, mail): + self.threads.append({ + 'title': mail['subject'], + 'slug': self.generate_slug(mail), + 'date': mail['date'], + 'author': mail['author'], + 'root': mail, + 'reply_count': 0 + }) + + def has_mail(self, msgid): + return msgid in self._known_ids + + def get_mail(self, msgid): + return self._known_ids.get(msgid) + + def find_parent(self, mail): + # first check the reply to, some clients actually set that to + # something useful :) + if mail['in-reply-to']: + referenced_mail = self.get_mail(mail['in-reply-to']) + if referenced_mail is not None and referenced_mail is not mail: + return referenced_mail + + # next check the references, pick the most recent one. + last = last_date = None + for msgid in mail['references']: + referenced_mail = self.get_mail(msgid) + if referenced_mail is None: + continue + other_date = parse_date(referenced_mail['date']) + if last is None or last_date < other_date: + last_date = other_date + last = referenced_mail + if last is not None and last is not mail: + return last + + # oh boy, nothing matched, find the oldest matching subject + # then. That could take a while, we really check all mails... + def _strip_subject(subject): + if subject[:3].lower() in (u'aw:', u're:'): + subject = subject[3:] + return subject.strip().lower() + subject = _strip_subject(mail['subject']) + + last = mail + last_date = parse_date(mail['date']) + for other_mail in self.walk(): + if _strip_subject(other_mail['subject']) == subject: + other_date = parse_date(other_mail['date']) + if last is None or other_date < last_date: + last = other_mail + last_date = other_date + + if last is not mail: + return last + + def integrate_new_mail(self): + while self._new_mail: + mail = self._new_mail.pop() + print "A", mail['msgid'] + parent = self.find_parent(mail) + if parent is not None: + parent['children'].append(mail) + else: + self.add_thread_for(mail) + self.processed_mail.add(mail['fsid']) + + def _count_mails(children): + rv = len(children) + for child in children: + rv += _count_mails(child['children']) + return rv + for thread in self.threads: + thread['reply_count'] = _count_mails(thread['root']['children']) + + def save(self): + for thread in self.threads: + filename = os.path.join(THREAD_FOLDER, thread['slug']) + try: + os.makedirs(os.path.dirname(filename)) + except OSError: + pass + with open(filename, 'w') as f: + json.dump(thread, f, indent=2) + + with open(os.path.join(THREAD_FOLDER, 'threadlist'), 'w') as f: + threads = sorted(self.threads, reverse=True, + key=lambda x: parse_date(x['date'])) + for idx, thread in enumerate(threads): + thread = dict(thread) + del thread['root'] + threads[idx] = thread + json.dump(threads, f, indent=2) + + +def get_processed_tree(): + """Returns the tree of already processed mails (from + the THREAD_FOLDER). + """ + threads = [] + for thread in glob(THREAD_FOLDER + '/*/*/*/*'): + if os.path.isfile(thread): + with open(thread) as f: + threads.append(json.load(f)) + + return Tree(threads) + + +def parse_mail(f, fsid): + """Parses an email and returns the information we care about""" + msg = json.load(f) + headers = Headers(msg['headers']) + + irt = None + match = _msgid_re.search(headers.get('in-reply-to', '')) + if match is not None: + irt = unquote_msgid(match.group(1)) + references = [unquote_msgid(msgid) for msgid + in headers.get('references', '').split() if msgid] + + body = msg['body'] + if body is None: + for part in msg['parts']: + if part['encoding']['type'] == 'text/plain': + body = part['body'] + break + else: + body = 'could not decode message' + + return { + 'fsid': fsid, + 'msgid': unquote_msgid(headers.get('message-id') or 'fakdeid-' + fsid), + 'in-reply-to': irt, + 'references': references, + 'author': split_email(headers['from']), + 'date': headers['Date'], + 'subject': strip_subject_prefix(headers['subject']), + 'text': body, + 'children': [] + } + + +def process_mails(tree): + to_process = [] + + # find the unprocessed mails + for folder in glob('%s/%s/*/*/*/json' % (INCOMING_MAIL_FOLDER, LIST_NAME)): + for fsid in os.listdir(folder): + if fsid not in tree.processed_mail: + filename = os.path.join(folder, fsid) + if os.path.isfile(filename): + to_process.append((filename, fsid)) + + # now parse all mails and append them to the tree as new mails + for filename, fsid in to_process: + with open(filename) as f: + tree.add_new_mail(f, fsid) + + tree.integrate_new_mail() + + # and write the information to the file system + tree.save() + + +def main(): + tree = get_processed_tree() + rsync() + process_mails(tree) + + +if __name__ == '__main__': + main() diff --git a/website/404.html b/website/templates/404.html similarity index 87% rename from website/404.html rename to website/templates/404.html index c4de489d..82b6e5be 100644 --- a/website/404.html +++ b/website/templates/404.html @@ -8,7 +8,7 @@ body, html { } body { - background: url(/ship.png) no-repeat center right; + background: url(/static/ship.png) no-repeat center right; } body:after { @@ -18,7 +18,7 @@ body:after { top: 0; bottom: 0; width: 30px; - background: url(/mask.png) repeat-y left; + background: url(/static/mask.png) repeat-y left; } a { color: #004B6B; } diff --git a/website/index.html b/website/templates/index.html similarity index 65% rename from website/index.html rename to website/templates/index.html index 5db6b850..322931d1 100644 --- a/website/index.html +++ b/website/templates/index.html @@ -1,27 +1,12 @@ - -Flask (A Python Microframework) - - -
+{% extends "layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block body %}

Flask

because sometimes a pocket knife is not enough

@@ -45,6 +30,7 @@ def hello():

Interested?

@@ -74,8 +60,6 @@ def hello(): create a new ticket or fork. If you just want to chat with fellow developers, go to #pocoo on irc.freenode.net. -
- Fork me on GitHub +{% endblock %} diff --git a/website/templates/layout.html b/website/templates/layout.html new file mode 100644 index 00000000..a48fa502 --- /dev/null +++ b/website/templates/layout.html @@ -0,0 +1,12 @@ + +{% block head %} +{% block title %}Welcome{% endblock %} | Flask (A Python Microframework) + + + +{% endblock %} +
+ {% block body %}{% endblock %} +
diff --git a/website/templates/mailinglist/archive.html b/website/templates/mailinglist/archive.html new file mode 100644 index 00000000..53c1d73d --- /dev/null +++ b/website/templates/mailinglist/archive.html @@ -0,0 +1,27 @@ +{% extends "mailinglist/layout.html" %} +{% block title %}Mailinglist Archive{% endblock %} +{% block mailbody %} +

Mailinglist Archive

+
    + {% for thread in threads %} +
  • {{ thread.title }} + + by {{ thread.author_name or thread.author_email }} + on {{ thread.date.strftime('%Y-%m-%d @ %H:%M') }} + ({{ thread.reply_count }} repl{{ thread.reply_count == 1 and 'y' or 'ies' }}) + {% endfor %} +
+ +{% endblock %} diff --git a/website/templates/mailinglist/index.html b/website/templates/mailinglist/index.html new file mode 100644 index 00000000..2cb236f4 --- /dev/null +++ b/website/templates/mailinglist/index.html @@ -0,0 +1,20 @@ +{% extends "mailinglist/layout.html" %} +{% block title %}Mailinglist{% endblock %} +{% block mailbody %} +

+ There is a mailinglist for Flask hosted on librelist you can use for both user requests + and development discussions. + +

+ To subscribe, send a mail to flask@librelist.com and reply + to the confirmation mail. Make sure to check your Spam folder, just in + case. To unsubscribe again, send a mail to + flask-unsubscribe@librelist.com and reply to the + confirmation mail. + +

+ The mailinglist archive + is synched every hour. Go there to read up old discussions grouped by + thread. +{% endblock %} diff --git a/website/templates/mailinglist/layout.html b/website/templates/mailinglist/layout.html new file mode 100644 index 00000000..8dfe4620 --- /dev/null +++ b/website/templates/mailinglist/layout.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block body %} +

Flask Mailinglist

+
+ back to website + {% if request.endpoint != 'mailinglist_index' %}// + list information + {% endif %} + {% if request.endpoint != 'mailinglist_archive' %}// + go to archive + {% endif %} +
+ {% block mailbody %}{% endblock %} +{% endblock %} diff --git a/website/templates/mailinglist/show_thread.html b/website/templates/mailinglist/show_thread.html new file mode 100644 index 00000000..7be8adae --- /dev/null +++ b/website/templates/mailinglist/show_thread.html @@ -0,0 +1,34 @@ +{% extends "mailinglist/layout.html" %} +{% block title %}{{ thread.title }}{% endblock %} +{% block head %} + {{ super() }} + +{% endblock %} +{% block mailbody %} +

{{ thread.title }}

+ +
    + {% for mail in [thread.root] recursive %} +
  • + {% if mail.children %}
      {{ loop(mail.children) }}
    {% endif %} + {% endfor %} +
+ + {% for mail in [thread.root] recursive %} +
+

{{ mail.subject }}

+
+
From: +
{{ mail.author_name or mail.author_email }} +
Date: +
{{ mail.date.strftime('%Y-%m-%d @ %H:%M') }} +
+
{{ mail.rendered_text }}
+ {% if mail.children %} +
{{ loop(mail.children) }}
+ {% endif %} +
+ {% endfor %} +{% endblock %}