diff --git a/calendarpush.py b/calendarpush.py
new file mode 100644
index 0000000000000000000000000000000000000000..260aece3ccf9b3ceb39004122df8f023122278d5
--- /dev/null
+++ b/calendarpush.py
@@ -0,0 +1,135 @@
+from datetime import datetime, timedelta
+import random
+import quopri
+
+from caldav import DAVClient, Principal, Calendar, Event
+from vobject.base import ContentLine
+
+import config
+
+class CalendarException(Exception):
+    pass
+
+class Client:
+    def __init__(self, calendar=None, url=None):
+        self.url = url if url is not None else config.CALENDAR_URL
+        self.client = DAVClient(self.url)
+        self.principal = self.client.principal()
+        if calendar is not None:
+            self.calendar = self.get_calendar(calendar)
+        else:
+            self.calendar = calendar
+
+    def get_calendars(self):
+        return [
+            calendar.name
+            for calendar in self.principal.calendars()
+        ]
+
+    def get_calendar(self, calendar_name):
+        candidates = self.principal.calendars()
+        for calendar in candidates:
+            if calendar.name == calendar_name:
+                return calendar
+        raise CalendarException("No calendar named {}.".format(calendar_name))
+
+    def set_event_at(self, begin, name, description):
+        candidates = [
+            Event.from_raw_event(raw_event)
+            for raw_event in self.calendar.date_search(begin)
+        ]
+        candidates = [event for event in candidates if event.name == name]
+        event = None
+        if len(candidates) == 0:
+            event = Event(None, name, description, begin,
+                begin + timedelta(hours=config.CALENDAR_DEFAULT_DURATION))
+            vevent = self.calendar.add_event(event.to_vcal())
+            event.vevent = vevent
+        else:
+            event = candidates[0]
+        event.set_description(description)
+        event.vevent.save()
+
+
+NAME_KEY = "summary"
+DESCRIPTION_KEY = "description"
+BEGIN_KEY = "dtstart"
+END_KEY = "dtend"
+def _get_item(content, key):
+    if key in content:
+        return content[key][0].value
+    return None
+        
+class Event:
+    def __init__(self, vevent, name, description, begin, end):
+        self.vevent = vevent
+        self.name = name
+        self.description = description
+        self.begin = begin
+        self.end = end
+
+    @staticmethod
+    def from_raw_event(vevent):
+        raw_event = vevent.instance.contents["vevent"][0]
+        content = raw_event.contents
+        name = _get_item(content, NAME_KEY)
+        description = _get_item(content, DESCRIPTION_KEY)
+        begin = _get_item(content, BEGIN_KEY)
+        end = _get_item(content, END_KEY)
+        return Event(vevent=vevent, name=name, description=description,
+            begin=begin, end=end)
+
+    def set_description(self, description):
+        raw_event = self.vevent.instance.contents["vevent"][0]
+        self.description = description
+        encoded = encode_quopri(description)
+        if DESCRIPTION_KEY not in raw_event.contents:
+            raw_event.contents[DESCRIPTION_KEY] = [ContentLine(DESCRIPTION_KEY, {"ENCODING": ["QUOTED-PRINTABLE"]}, encoded)]
+        else:
+            content_line = raw_event.contents[DESCRIPTION_KEY][0]
+            content_line.value = encoded
+            content_line.params["ENCODING"] = ["QUOTED-PRINTABLE"]
+
+    def __repr__(self):
+        return "<Event(name='{}', description='{}', begin={}, end={})>".format(
+            self.name, self.description, self.begin, self.end)
+
+    def to_vcal(self):
+        offset = get_timezone_offset()
+        return """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//FSMPI Protokollsystem//CalDAV Client//EN
+BEGIN:VEVENT
+UID:{uid}
+DTSTAMP:{now}
+DTSTART:{begin}
+DTEND:{end}
+SUMMARY:{summary}
+DESCRIPTION;ENCODING=QUOTED-PRINTABLE:{description}
+END:VEVENT
+END:VCALENDAR""".format(
+            uid=create_uid(), now=date_format(datetime.now()-offset),
+            begin=date_format(self.begin-offset), end=date_format(self.end-offset),
+            summary=self.name,
+            description=encode_quopri(self.description))
+
+def create_uid():
+    return str(random.randint(0, 1e10)).rjust(10, "0")
+
+def date_format(dt):
+    return dt.strftime("%Y%m%dT%H%M%SZ")
+
+def get_timezone_offset():
+    difference = datetime.now() - datetime.utcnow()
+    return timedelta(hours=round(difference.seconds / 3600 + difference.days * 24))
+
+def encode_quopri(text):
+    return quopri.encodestring(text.encode("utf-8")).replace(b"\n", b"=0D=0A").decode("utf-8")
+
+def main():
+    client = Client("Protokolltest")
+    client.set_event_at(datetime(2017, 2, 27, 19, 0), "FSS", "Tagesordnung\nTOP 1")
+
+if __name__ == "__main__":
+    if config.CALENDAR_ACTIVE:
+        main()
diff --git a/config.py.example b/config.py.example
index 5c443f4da4d37864609fbb8607c3b80271f9ef63..0f0399a6032489a2236ccaa4893868925c889490 100644
--- a/config.py.example
+++ b/config.py.example
@@ -1,30 +1,35 @@
-SQLALCHEMY_DATABASE_URI = "postgresql://proto3:@/proto3" # change
+# (local) database
+SQLALCHEMY_DATABASE_URI = "postgresql://user:password@host/database" # change this
 SQLALCHEMY_TRACK_MODIFICATIONS = False # do not change
 
-SECRET_KEY = "something random"
+SECRET_KEY = "something random" # change this
 
 DEBUG = False
 
+# mailserver (optional)
 MAIL_ACTIVE = True
 MAIL_FROM = "protokolle@example.com"
 MAIL_HOST = "mail.example.com:465"
 MAIL_USER = "user"
 MAIL_PASSWORD = "password"
-MAIL_PREFIX = "protokolle"
 
+# (local) message queue (necessary)
 CELERY_BROKER_URL = "redis://localhost:6379/0"
 CELERY_TASK_SERIALIZER = "pickle" # do not change
 CELERY_ACCEPT_CONTENT = ["pickle"] # do not change
 
+# this websites address
 URL_ROOT = "protokolle.example.com"
 URL_PROTO = "https"
 URL_PATH = "/"
 URL_PARAMS = ""
 
+# ldap server (necessary)
 LDAP_PROVIDER_URL = "ldaps://auth.example.com:389"
 LDAP_BASE = "dc=example,dc=example,dc=com"
 LDAP_PROTOCOL_VERSION = 3 # do not change
 
+# CUPS printserver (optional)
 PRINTING_ACTIVE = True
 PRINTING_SERVER = "printsrv.example.com:631"
 PRINTING_USER = "protocols"
@@ -33,7 +38,9 @@ PRINTING_PRINTERS = [
     "other_printer": ["list", "of", "options"]
 ]
 
-ETHERPAD_URL = "https://fachschaften.rwth-aachen.de/etherpad"
+# etherpad (optional)
+ETHERPAD_ACTIVE = True
+ETHERPAD_URL = "https://example.com/etherpad"
 EMPTY_ETHERPAD = """Welcome to Etherpad!
 
 This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!
@@ -42,6 +49,7 @@ Get involved with Etherpad at http://etherpad.org
 
 """ # do not change
 
+# wiki (optional)
 WIKI_ACTIVE = True
 WIKI_API_URL = "https://wiki.example.com/wiki/api.php"
 WIKI_ANONYMOUS = False
@@ -49,17 +57,38 @@ WIKI_USER = "user"
 WIKI_PASSWORD = "password"
 WIKI_DOMAIN = "domain" # set to None if not necessary
 
-SESSION_PROTECTION = "strong"
+# CalDAV calendar (optional)
+CALENDAR_ACTIVE = True
+CALENDAR_URL = "https://user:password@calendar.example.com/dav/"
+CALENDAR_DEFAULT_DURATION = 3 # default meeting length in hours
 
-SECURITY_KEY = "some other random string"
+SESSION_PROTECTION = "strong" # do not change
 
+SECURITY_KEY = "some other random string" # change this
+
+# lines of error description
 ERROR_CONTEXT_LINES = 3
 
+# pagination
 PAGE_LENGTH = 20
 PAGE_DIFF = 3
 
+# upcoming meetings within this number of days from today are shown on the index page
+MAX_INDEX_DAYS = 14
+
+# mail to contact in case of complex errors
+ADMIN_MAIL = "admin@example.com"
+
+# accept protocols even with some errors
+# useful for importing old protocols
+# not recommended for regular operation
+PARSER_LAZY = False
+
+# minimum similarity (0-100) todos need to have to be considered equal
+FUZZY_MIN_SCORE = 50
 
 # choose something nice from fc-list
+# Nimbus Sans looks very much like Computer Modern
 FONTS = {
     "main": {
         "regular": "Nimbus Sans",
@@ -87,7 +116,9 @@ FONTS = {
     }
 }
 
+# local filesystem path to save documents
 DOCUMENTS_PATH = "documents"
 
+# keywords indicating private protocol parts
 PRIVATE_KEYWORDS = ["private", "internal", "privat", "intern"]
 
diff --git a/migrations/versions/0555db125011_.py b/migrations/versions/0555db125011_.py
new file mode 100644
index 0000000000000000000000000000000000000000..f3c0bfdffdafe8cfcf0df70a2f2cdb2625999d32
--- /dev/null
+++ b/migrations/versions/0555db125011_.py
@@ -0,0 +1,28 @@
+"""empty message
+
+Revision ID: 0555db125011
+Revises: d543c6a2ea6e
+Create Date: 2017-02-28 00:46:16.624939
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0555db125011'
+down_revision = 'd543c6a2ea6e'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('protocoltypes', sa.Column('calendar', sa.String(), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('protocoltypes', 'calendar')
+    # ### end Alembic commands ###
diff --git a/models/database.py b/models/database.py
index 55a0f5da20a199e10d1a7f9be3fa046ad351abff..07c4e4fccd5eef0c46b780e87753ac8e7996f1a9 100644
--- a/models/database.py
+++ b/models/database.py
@@ -34,6 +34,7 @@ class ProtocolType(db.Model):
     wiki_category = db.Column(db.String)
     wiki_only_public = db.Column(db.Boolean)
     printer = db.Column(db.String)
+    calendar = db.Column(db.String)
 
     protocols = relationship("Protocol", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="Protocol.id")
     default_tops = relationship("DefaultTOP", backref=backref("protocoltype"), cascade="all, delete-orphan", order_by="DefaultTOP.number")
@@ -56,16 +57,19 @@ class ProtocolType(db.Model):
         self.wiki_category = wiki_category
         self.wiki_only_public = wiki_only_public
         self.printer = printer
+        self.calendar = calendar
 
     def __repr__(self):
         return ("<ProtocolType(id={}, short_name={}, name={}, "
                 "organization={}, is_public={}, private_group={}, "
                 "public_group={}, use_wiki={}, wiki_category='{}', "
-                "wiki_only_public={}, printer={}, usual_time={})>".format(
+                "wiki_only_public={}, printer={}, usual_time={}, "
+                "calendar='{}')>".format(
             self.id, self.short_name, self.name,
             self.organization, self.is_public, self.private_group,
             self.public_group, self.use_wiki, self.wiki_category,
-            self.wiki_only_public, self.printer, self.usual_time))
+            self.wiki_only_public, self.printer, self.usual_time,
+            self.calendar))
 
     def get_latest_protocol(self):
         candidates = sorted([protocol for protocol in self.protocols if protocol.is_done()], key=lambda p: p.date, reverse=True)
@@ -205,6 +209,9 @@ class Protocol(db.Model):
             return ""
         return get_etherpad_url(self.get_identifier())
 
+    def get_datetime(self):
+        return datetime(self.date.year, self.date.month, self.date.day, self.protocoltype.usual_time.hour, self.protocoltype.usual_time.minute)
+
     def has_nonplanned_tops(self):
         return len([top for top in self.tops if not top.planned]) > 0
 
diff --git a/requirements.txt b/requirements.txt
index 218d809d2873d46c835358172d46e6ca057a48f7..499eb51498e3c0c923bd48fcc5ab4269c23f7c4e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -7,8 +7,10 @@ billiard==3.5.0.2
 blessings==1.6
 blinker==1.4
 bpython==0.16
+caldav==0.5.0
 celery==4.0.2
 click==6.7
+coverage==4.3.4
 curtsies==0.2.11
 Flask==0.12
 Flask-Migrate==2.0.3
@@ -21,14 +23,17 @@ greenlet==0.4.12
 itsdangerous==0.24
 Jinja2==2.9.5
 kombu==4.0.2
+lxml==3.7.3
 Mako==1.0.6
 MarkupSafe==0.23
+nose==1.3.7
 packaging==16.8
 pathtools==0.1.2
 psycopg2==2.6.2
 Pygments==2.2.0
 pyldap==2.4.28
 pyparsing==2.1.10
+python-dateutil==2.6.0
 python-editor==1.0.3
 python-engineio==1.2.2
 python-Levenshtein==0.12.0
@@ -42,6 +47,7 @@ six==1.10.0
 SQLAlchemy==1.1.5
 tzlocal==1.3
 vine==1.1.3
+vobject==0.9.4.1
 watchdog==0.8.3
 wcwidth==0.1.7
 Werkzeug==0.11.15
diff --git a/server.py b/server.py
index e1a8ac59fdc2c01325e694f5bc48590819cfa706..8036f3b958449caf2866ed45ca4f2d7092234194 100755
--- a/server.py
+++ b/server.py
@@ -451,6 +451,7 @@ def new_protocol():
         protocol = Protocol(protocoltype.id, form.date.data)
         db.session.add(protocol)
         db.session.commit()
+        tasks.push_tops_to_calendar(protocol)
         return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
     type_id = request.args.get("type_id")
     if type_id is not None:
@@ -645,6 +646,7 @@ def update_protocol(protocol_id):
     if edit_form.validate_on_submit():
         edit_form.populate_obj(protocol)
         db.session.commit()
+        tasks.push_tops_to_calendar(protocol)
         return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
     return render_template("protocol-update.html", upload_form=upload_form, edit_form=edit_form, protocol=protocol)
 
@@ -677,6 +679,7 @@ def new_top(protocol_id):
         top = TOP(protocol_id=protocol.id, name=form.name.data, number=form.number.data, planned=True)
         db.session.add(top)
         db.session.commit()
+        tasks.push_tops_to_calendar(top.protocol)
         return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
     else:
         current_numbers = list(map(lambda t: t.number, protocol.tops))
@@ -696,6 +699,7 @@ def edit_top(top_id):
     if form.validate_on_submit():
         form.populate_obj(top)
         db.session.commit()
+        tasks.push_tops_to_calendar(top.protocol)
         return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=top.protocol.id))
     return render_template("top-edit.html", form=form, top=top)
 
@@ -708,11 +712,12 @@ def delete_top(top_id):
         flash("Invalider TOP oder keine Berechtigung.", "alert-error")
         return redirect(request.args.get("next") or url_for("index"))
     name = top.name
-    protocol_id = top.protocol.id
+    protocol = top.protocol
     db.session.delete(top)
     db.session.commit()
+    tasks.push_tops_to_calendar(protocol)
     flash("Der TOP {} wurde gelöscht.".format(name), "alert-success")
-    return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol_id))
+    return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=protocol.id))
 
 @app.route("/protocol/top/move/<int:top_id>/<diff>")
 @login_required
@@ -725,6 +730,7 @@ def move_top(top_id, diff):
     try:
         top.number += int(diff)
         db.session.commit()
+        tasks.push_tops_to_calendar(top.protocol)
     except ValueError:
         flash("Die angegebene Differenz ist keine Zahl.", "alert-error")
     return redirect(request.args.get("next") or url_for("show_protocol", protocol_id=top.protocol.id))
diff --git a/tasks.py b/tasks.py
index 658f81dc2cd7d24156f39b1751d22c5136440189..bea4291cf8b07d29b1101ad9b36116c663a28b48 100644
--- a/tasks.py
+++ b/tasks.py
@@ -13,6 +13,7 @@ from shared import db, escape_tex, unhyphen, date_filter, datetime_filter, date_
 from utils import mail_manager, url_manager, encode_kwargs, decode_kwargs
 from parser import parse, ParserException, Element, Content, Text, Tag, Remark, Fork, RenderType
 from wiki import WikiClient, WikiException
+from calendarpush import Client as CalendarClient, CalendarException
 from legacy import lookup_todo_id, import_old_todos
 
 import config
@@ -439,3 +440,24 @@ def send_mail_async(protocol_id, to_addr, subject, content, appendix):
             db.session.add(error)
             db.session.commit()
 
+def push_tops_to_calendar(protocol):
+    push_tops_to_calendar_async.delay(protocol.id)
+
+@celery.task
+def push_tops_to_calendar_async(protocol_id):
+    if not config.CALENDAR_ACTIVE:
+        return
+    with app.app_context():
+        protocol = Protocol.query.filter_by(id=protocol_id).first()
+        if protocol.protocoltype.calendar == "":
+            return
+        description = render_template("calendar-tops.txt", protocol=protocol)
+        try:
+            client = CalendarClient(protocol.protocoltype.calendar)
+            client.set_event_at(begin=protocol.get_datetime(),
+                name=protocol.protocoltype.short_name, description=description)
+        except CalendarException as exc:
+            error = Protocol.create_error("Calendar",
+                "Pushing TOPs to Calendar failed", str(exc))
+            db.session.add(error)
+            db.session.commit()
diff --git a/templates/calendar-tops.txt b/templates/calendar-tops.txt
new file mode 100644
index 0000000000000000000000000000000000000000..a9d8da6e991061d1021a5c9a37cfe311ce1842c4
--- /dev/null
+++ b/templates/calendar-tops.txt
@@ -0,0 +1,17 @@
+{% if not protocol.has_nonplanned_tops() %}
+    {% for default_top in protocol.protocoltype.default_tops %}
+        {% if not default_top.is_at_end() %}
+{{default_top.name}}
+        {% endif %}
+    {% endfor %}
+{% endif %}
+{% for top in protocol.tops %}
+{{top.name}}
+{% endfor %}
+{% if not protocol.has_nonplanned_tops() %}
+    {% for default_top in protocol.protocoltype.default_tops %}
+        {% if default_top.is_at_end() %}
+{{default_top.name}}
+        {% endif %}
+    {% endfor %}
+{% endif %}
diff --git a/views/forms.py b/views/forms.py
index d910205efdebd65cdc52f786d902ed1dd48a45df..e917996fba8b82626c5b775a933ab2504afdc169 100644
--- a/views/forms.py
+++ b/views/forms.py
@@ -4,6 +4,7 @@ from wtforms.validators import InputRequired, Optional
 
 from models.database import TodoState
 from validators import CheckTodoDateByState
+from calendarpush import Client as CalendarClient
 
 import config
 
@@ -19,6 +20,12 @@ def get_todostate_choices():
         for state in TodoState
     ]
 
+def get_calendar_choices():
+    return [
+        (calendar, calendar)
+        for calendar in CalendarClient().get_calendars()
+    ]
+
 class LoginForm(FlaskForm):
     username = StringField("Benutzer", validators=[InputRequired("Bitte gib deinen Benutzernamen ein.")])
     password = PasswordField("Passwort", validators=[InputRequired("Bitte gib dein Passwort ein.")])
@@ -37,6 +44,7 @@ class ProtocolTypeForm(FlaskForm):
     use_wiki = BooleanField("Wiki benutzen")
     wiki_only_public = BooleanField("Wiki ist öffentlich")
     printer = SelectField("Drucker", choices=list(zip(config.PRINTING_PRINTERS, config.PRINTING_PRINTERS)))
+    calendar = SelectField("Kalender", choices=get_calendar_choices())
 
 class DefaultTopForm(FlaskForm):
     name = StringField("Name", validators=[InputRequired("Du musst einen Namen angeben.")])
diff --git a/views/tables.py b/views/tables.py
index 643fb67c5566f984c0d1281a4aea7ddcdc5a91ef..89d2c0323300c85f02af1c94275a4e0ee1a87739 100644
--- a/views/tables.py
+++ b/views/tables.py
@@ -118,7 +118,11 @@ class ProtocolTypeTable(SingleValueTable):
             wiki_headers.append("Wiki-Kategorie")
         if not config.WIKI_ACTIVE:
             wiki_headers = []
-        return general_headers + mail_headers + printing_headers + wiki_headers
+        calendar_headers = ["Kalender"]
+        if not config.CALENDAR_ACTIVE:
+            calendar_headers = []
+        return (general_headers + mail_headers + printing_headers
+           + wiki_headers + calendar_headers)
 
     def row(self):
         general_part = [
@@ -146,7 +150,10 @@ class ProtocolTypeTable(SingleValueTable):
             wiki_part.append(self.value.wiki_category)
         if not config.WIKI_ACTIVE:
             wiki_part = []
-        return general_part + mail_part + printing_part + wiki_part
+        calendar_part = [self.value.calendar if self.value.calendar is not None else ""]
+        if not config.CALENDAR_ACTIVE:
+            calendar_part = []
+        return general_part + mail_part + printing_part + wiki_part + calendar_part
 
 class DefaultTOPsTable(Table):
     def __init__(self, tops, protocoltype=None):