From 19b122b9186942899d0ecf208f5d5c04c97e6fdb Mon Sep 17 00:00:00 2001
From: veni-vidi-code <tom.mucke@web.de>
Date: Fri, 18 Apr 2025 03:04:42 +0200
Subject: [PATCH 1/4] Improves etherpad opening by removing unnecessary
 template call

Moves the call of protocol.get_template after the check if the etherpad is default and needs this. This removes unnecessary overhead each time someone clicks the etherpad button when the pad is already set up.
---
 tasks.py | 2 +-
 utils.py | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/tasks.py b/tasks.py
index d894769..909d51a 100644
--- a/tasks.py
+++ b/tasks.py
@@ -1012,4 +1012,4 @@ def set_etherpad_content_async(protocol_id):
     with app.app_context():
         protocol = Protocol.query.filter_by(id=protocol_id).first()
         identifier = protocol.get_identifier()
-        return set_etherpad_text(identifier, protocol.get_template())
+        return set_etherpad_text(identifier, protocol.get_template)
diff --git a/utils.py b/utils.py
index e3d68e4..3dd7fcb 100644
--- a/utils.py
+++ b/utils.py
@@ -163,6 +163,8 @@ def set_etherpad_text(pad, text, only_if_default=True):
                 and len(current_text) > 0):
             return False
     client = get_etherpad_api_client()
+    if callable(text):
+        text = text()    # This ensures that the text is only generated if needed and not always
     client.setText(padID=pad, text=text)
     return True
 
-- 
GitLab


From 568b29cae09bf6d497c5fa9200ff7f1f57cb6468 Mon Sep 17 00:00:00 2001
From: veni-vidi-code <tom.mucke@web.de>
Date: Fri, 18 Apr 2025 03:12:25 +0200
Subject: [PATCH 2/4] Fixes #224 OpenXchange caldav

Implements the first of the two in #224 proposed fixes to enable caldav with our openxchange server
---
 calendarpush.py | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/calendarpush.py b/calendarpush.py
index f69e7d0..24930a5 100644
--- a/calendarpush.py
+++ b/calendarpush.py
@@ -2,7 +2,7 @@ from datetime import datetime, timedelta
 import random
 import quopri
 
-from caldav import DAVClient
+import caldav
 from vobject.base import ContentLine
 
 from shared import config
@@ -17,11 +17,15 @@ class Client:
         if not config.CALENDAR_ACTIVE:
             return
         self.url = url if url is not None else config.CALENDAR_URL
-        self.client = DAVClient(self.url)
+        self.client = caldav.DAVClient(self.url)
         self.principal = None
         for _ in range(config.CALENDAR_MAX_REQUESTS):
             try:
                 self.principal = self.client.principal()
+                try:
+                    self.principal.calendars()
+                except Exception:
+                    self.principal = caldav.Principal(self.client, self.url) # workaround for openxchange
                 break
             except Exception as exc:
                 print("Got exception {} from caldav, retrying".format(
-- 
GitLab


From c3a35242b9b319da660f7c3a64749ebc0c7bc4cc Mon Sep 17 00:00:00 2001
From: veni-vidi-code <tom.mucke@web.de>
Date: Fri, 18 Apr 2025 03:17:48 +0200
Subject: [PATCH 3/4] Adds Termin Tag

Adds extended calendar support by
a) smaller changes to the sitzungstag (e.g. LaTeX)
b) Adding a new Tag "Termin" which automatically gets pushed to the caldav server
c) Fixing #222

The new Termin Tag also supports Kategories to allow multiple protocol types to share one calender without interference.
---
 calendarpush.py                               | 109 +++++++++++++++---
 ...c0610e845e_adds_lookahead_calendar_days.py |  30 +++++
 models/database.py                            |  15 +++
 protoparser.py                                |  37 +++++-
 requirements.txt                              |   2 +-
 tasks.py                                      |  76 +++++++++++-
 templates/documentation-syntax-tags.html      |  14 ++-
 templates/protocol-template.txt               |   7 ++
 templates/protokoll2.cls                      |  10 ++
 templates/top.tex                             |  10 ++
 views/forms.py                                |   2 +
 views/tables.py                               |   4 +-
 12 files changed, 291 insertions(+), 25 deletions(-)
 create mode 100644 migrations/versions/94c0610e845e_adds_lookahead_calendar_days.py

diff --git a/calendarpush.py b/calendarpush.py
index 24930a5..d9b7f6b 100644
--- a/calendarpush.py
+++ b/calendarpush.py
@@ -1,11 +1,11 @@
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, time
 import random
 import quopri
 
 import caldav
 from vobject.base import ContentLine
 
-from shared import config
+from shared import config, date_filter_short, time_filter_short
 
 
 class CalendarException(Exception):
@@ -62,32 +62,90 @@ class Client:
                 return calendar
         raise CalendarException("No calendar named {}.".format(calendar_name))
 
-    def set_event_at(self, begin, name, description):
+    def set_event_at(self, begin, name, description=None, categories=None, append_categories=True, end=None):
         if not config.CALENDAR_ACTIVE:
             return
+        if end is None:
+            end = begin + timedelta(hours=config.CALENDAR_DEFAULT_DURATION)
+
+        if isinstance(end, time):
+            end = datetime.combine(date=begin.date(), time=end, tzinfo=begin.tzinfo)
+
         candidates = [
             Event.from_raw_event(raw_event)
-            for raw_event in self.calendar.date_search(
-                begin, begin + timedelta(hours=1))
+            for raw_event in self.calendar.search(
+                start=begin,
+                end=end + timedelta(hours=1),
+                comp_class=caldav.objects.Event,
+                expand=True,
+            )
         ]
         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))
+            event = Event(None, name, description or '', begin, end, categories or [])
             vevent = self.calendar.add_event(event.to_vcal())
             event.vevent = vevent
         else:
             event = candidates[0]
-        event.set_description(description)
+            if append_categories:
+                # make sure that the categories are not duplicated
+                for category in categories:
+                    if category not in event.categories:
+                        event.categories.append(category)
+        if description is not None:
+            event.set_description(description)
         event.vevent.save()
 
+    def get_events(self, start, lookahead=14, included_categories=None, protocoltype_id=None):
+        if not config.CALENDAR_ACTIVE:
+            return []
+        events = []
+        if lookahead <= 0:
+            return events
+        if not included_categories:
+            included_categories = None
+        else:
+            included_categories = set(included_categories)
+        end = start + timedelta(days=lookahead)
+        if protocoltype_id is not None:
+            protocol_category = "Protokoll_{}".format(protocoltype_id)
+
+        for i in range(config.CALENDAR_MAX_REQUESTS):
+            try:
+                raw_events = self.calendar.search(
+                    start=start,
+                    end=end,
+                    comp_class=caldav.objects.Event,
+                    expand=True,
+                    # split_expanded=True
+                )
+                for raw_event in raw_events:
+                    event = Event.from_raw_event(raw_event)
+                    if protocoltype_id is not None and protocol_category in event.categories:
+                        if event.begin.date() != start.date():
+                            events.append(event)
+                    elif included_categories is None or (not event.categories) or any(category in included_categories
+                             for category in event.categories):
+                        events.append(event)
+                    else:
+                        print("Event {} not in categories {}".format(
+                            event.categories, included_categories))
+                break
+            except Exception as exc:
+                if i == config.CALENDAR_MAX_REQUESTS - 1:
+                    raise
+                print("Got exception {} from caldav, retrying".format(
+                    str(exc)))
+        return events
+
 
 NAME_KEY = "summary"
 DESCRIPTION_KEY = "description"
 BEGIN_KEY = "dtstart"
 END_KEY = "dtend"
+CATEGORIES_KEY = "categories"
 
 
 def _get_item(content, key):
@@ -97,12 +155,14 @@ def _get_item(content, key):
 
 
 class Event:
-    def __init__(self, vevent, name, description, begin, end):
+    def __init__(self, vevent, name, description, begin, end, category: list, uid=None):
         self.vevent = vevent
         self.name = name
         self.description = description
         self.begin = begin
         self.end = end
+        self.categories = category
+        self.uid = uid if uid is not None else create_uid()
 
     @staticmethod
     def from_raw_event(vevent):
@@ -110,11 +170,14 @@ class Event:
         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)
+        offset = get_timezone_offset()
+        begin = _get_item(content, BEGIN_KEY) + offset
+        end = _get_item(content, END_KEY) + offset
+        category = _get_item(content, CATEGORIES_KEY) or []
+        uid = _get_item(content, "uid")
         return Event(
             vevent=vevent, name=name, description=description,
-            begin=begin, end=end)
+            begin=begin, end=end, category=category, uid=uid)
 
     def set_description(self, description):
         raw_event = self.vevent.instance.contents["vevent"][0]
@@ -129,8 +192,8 @@ class Event:
             content_line.params["ENCODING"] = ["QUOTED-PRINTABLE"]
 
     def __repr__(self):
-        return "<Event(name='{}', description='{}', begin={}, end={})>".format(
-            self.name, self.description, self.begin, self.end)
+        return "<Event(uid='{}', name='{}', description='{}', begin={}, end={}, categories={})>".format(
+            self.uid, self.name, self.description, self.begin, self.end, self.categories)
 
     def to_vcal(self):
         offset = get_timezone_offset()
@@ -144,14 +207,26 @@ DTSTART:{begin}
 DTEND:{end}
 SUMMARY:{summary}
 DESCRIPTION;ENCODING=QUOTED-PRINTABLE:{description}
+CATEGORIES:{categories}
 END:VEVENT
 END:VCALENDAR""".format(
-            uid=create_uid(),
+            uid=self.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))
+            description=encode_quopri(self.description),
+            categories=','.join(self.categories)
+        )
+
+    def render_template(self):
+        """parts = ["termin", self.name or '', self.description or '', date_filter_short(self.begin),
+                 time_filter_short(self.begin), date_filter_short(self.end), time_filter_short(self.end),
+                 ','.join(self.categories or tuple()), self.uid]"""  # This would be more complete but no one wants to read that in etherpad
+        parts = ["termin", date_filter_short(self.begin),
+                 time_filter_short(self.begin), time_filter_short(self.end), self.name or '',
+                 ','.join(self.categories or tuple())]
+        return "[{}]".format(";".join(parts))
 
 
 def create_uid():
diff --git a/migrations/versions/94c0610e845e_adds_lookahead_calendar_days.py b/migrations/versions/94c0610e845e_adds_lookahead_calendar_days.py
new file mode 100644
index 0000000..e365f13
--- /dev/null
+++ b/migrations/versions/94c0610e845e_adds_lookahead_calendar_days.py
@@ -0,0 +1,30 @@
+"""Adds lookahead calendar days
+
+Revision ID: 94c0610e845e
+Revises: da846db78ff9
+Create Date: 2024-05-17 12:06:29.723797
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '94c0610e845e'
+down_revision = 'da846db78ff9'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('protocoltypes', sa.Column('calendar_lookahead', sa.Integer(), nullable=True))
+    op.add_column('protocoltypes', sa.Column('calendar_categories', sa.Text(), nullable=True))
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('protocoltypes', 'calendar_categories')
+    op.drop_column('protocoltypes', 'calendar_lookahead')
+    # ### end Alembic commands ###
diff --git a/models/database.py b/models/database.py
index 6d98e43..b209003 100644
--- a/models/database.py
+++ b/models/database.py
@@ -20,6 +20,7 @@ from sqlalchemy.orm import relationship, backref
 
 from todostates import make_states, make_state_glyphes
 
+from calendarpush import Client as CalendarClient
 
 class DatabaseModel(db.Model):
     __abstract__ = True
@@ -75,6 +76,8 @@ class ProtocolType(DatabaseModel):
     wiki_only_public = db.Column(db.Boolean)
     printer = db.Column(db.Text)
     calendar = db.Column(db.Text)
+    calendar_lookahead = db.Column(db.Integer)
+    calendar_categories = db.Column(db.Text)
     restrict_networks = db.Column(db.Boolean)
     allowed_networks = db.Column(db.Text)
     latex_template = db.Column(db.Text)
@@ -380,6 +383,18 @@ class Protocol(DatabaseModel):
             if not todo.is_done()
         ]
 
+    def get_calendar_categories(self):
+        cats = self.protocoltype.calendar_categories.replace(" ", ",").split(",")
+        return {cat for cat in cats if cat != ""}
+
+    def get_events(self):
+        if self.protocoltype.calendar is None or self.protocoltype.calendar == "":
+            return []
+        client = CalendarClient(self.protocoltype.calendar)
+        start = self.get_timezone_aware_start_date()
+        return client.get_events(start=start, lookahead=self.protocoltype.calendar_lookahead,
+                                 included_categories=self.get_calendar_categories(), protocoltype_id=self.protocoltype.id)
+
     def has_compiled_document(self, private=None):
         candidates = [
             document for document in self.documents
diff --git a/protoparser.py b/protoparser.py
index c755c59..9551385 100644
--- a/protoparser.py
+++ b/protoparser.py
@@ -1,10 +1,11 @@
+import flask
 import regex as re
 import sys
 from collections import OrderedDict
 from enum import Enum
 
 from shared import escape_tex
-from utils import footnote_hash
+from utils import footnote_hash, parse_datetime_from_string
 
 from shared import config
 
@@ -225,6 +226,13 @@ class Tag:
         self.fork = fork
 
     def render(self, render_type, show_private, level=None, protocol=None, decision_render=False, top_render=False):
+        if self.name == "sitzung":
+            new_protocol_date = parse_datetime_from_string(self.values[0]).date()
+            protocols = protocol.protocoltype.get_protocols_on_date(new_protocol_date)
+            for protocol in protocols:
+                if protocol.get_time() == self.values[1]:
+                    break
+
         if render_type == RenderType.latex and not top_render:
             if self.name == "url":
                 return r"\url{{{}}}".format(self.values[0])
@@ -241,6 +249,14 @@ class Tag:
                     return r"\Beschluss{{{}}}".format(self.decision.content)
             elif self.name == "footnote":
                 return r"\footnote{{{}}}".format(self.values[0])
+            elif self.name == "sitzung":
+                if protocol is not None:
+                    return r"\Sitzung[{0}/protocol/show/{1}]{{{2}}}{{{3}}}".format(config.SERVER_NAME, protocol.id, protocol.date, protocol.start_time)
+                else:
+                    return r"\Sitzung{{{1}}}{{{2}}}".format(self.values[0], self.values[1])
+            elif self.name == "termin":
+                return r"\Termin{{{0}}}{{{1}}}{{{2}}}{{{3}}}".format(
+                    self.values[0], self.values[1], self.values[2], escape_tex(self.values[3]))
             return r"\textbf{{{}:}} {}".format(
                 escape_tex(self.name.capitalize()),
                 escape_tex(";".join(self.values)))
@@ -257,6 +273,14 @@ class Tag:
                             + r"\end{itemize} \end{tcolorbox}")
             elif self.name == "footnote":
                 return r"\footnote{{{}}}".format(self.values[0])
+            elif self.name == "sitzung":
+                if protocol is not None:
+                    return r"\Sitzung[{0}/protocol/show/{1}]{{{2}}}{{{3}}}".format(config.SERVER_NAME, protocol.id, protocol.date, protocol.start_time)
+                else:
+                    return r"\Sitzung{{{1}}}{{{2}}}".format(self.values[0], self.values[1])
+            elif self.name == "termin":
+                return r"\Termin{{{0}}}{{{1}}}{{{2}}}{{{3}}}".format(
+                    self.values[0], self.values[1], self.values[2], escape_tex(self.values[3]))
             return r"\textbf{{{}:}} {}".format(
                 escape_tex(self.name.capitalize()),
                 escape_tex(";".join(self.values)))
@@ -305,6 +329,15 @@ class Tag:
                 return (
                     '<sup id="#fnref{0}"><a href="#fn{0}">Fn</a></sup>'.format(
                         footnote_hash(self.values[0])))
+            elif self.name == "sitzung":
+                if protocol is not None:
+                    return ("<a href=\"/protocol/show/{0}\"><b>Sitzung</b> am {1} um {2}</a>"
+                            .format(protocol.id, protocol.date, protocol.start_time))
+                else:
+                    return "<b>Sitzung</b> am {0} um {1}".format(self.values[0], self.values[1])
+            elif self.name == "termin":
+                return "<b>Termin:</b> {3} {0} {1} - {2}".format(
+                    self.values[0], self.values[1], self.values[2], flask.escape(self.values[3]))
             return "[{}: {}]".format(self.name, ";".join(self.values))
         elif render_type == RenderType.dokuwiki:
             if self.name == "url":
@@ -343,7 +376,7 @@ class Tag:
 
     PATTERN = r"\[(?<content>[^\]]*)\]"
 
-    KNOWN_TAGS = ["todo", "url", "beschluss", "footnote", "sitzung"]
+    KNOWN_TAGS = ["todo", "url", "beschluss", "footnote", "sitzung", "termin"]
 
 
 class Empty(Element):
diff --git a/requirements.txt b/requirements.txt
index 5727135..e748ecf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,7 +10,7 @@ blessed==1.19.1
 blessings==1.7
 blinker==1.5
 bpython==0.23
-caldav==0.10.0
+caldav~=1.3.6
 celery==5.2.7
 certifi==2022.9.24
 chardet==5.0.0
diff --git a/tasks.py b/tasks.py
index 909d51a..13ff7e7 100644
--- a/tasks.py
+++ b/tasks.py
@@ -5,7 +5,7 @@ import subprocess
 import shutil
 import tempfile
 from datetime import datetime, timedelta
-import time
+from dateutil import tz
 import traceback
 from copy import copy
 import xmlrpc.client
@@ -506,6 +506,51 @@ def parse_protocol_async_inner(protocol, ignore_old_date=False):
         if new_protocol_date > datetime.now().date():
             Protocol.create_new_protocol(protocol.protocoltype, new_protocol_date)
 
+    # Appointments
+    appointment_tags = [tag for tag in tags if tag.name == "termin"]
+    for appointment_tag in appointment_tags:
+        if len(appointment_tag.values) not in {4, 5}:
+            return _make_error(
+                protocol, "Parsing", "Invalid appointment",
+                "The appointment in line {} has to have either 4 or 5 values".format(
+                    appointment_tag.linenumber))
+        try:
+            date = parse_datetime_from_string(appointment_tag.values[0])
+        except ValueError as exc:
+            return _make_error(
+                protocol, "Parsing", "Invalid date",
+                "'{}' is not a valid date.".format(
+                    appointment_tag.values[0]))
+
+        try:
+            begin_time = datetime.strptime(appointment_tag.values[1], "%H:%M").time()
+            end_time = datetime.strptime(appointment_tag.values[2], "%H:%M").time()
+        except ValueError as exc:
+            return _make_error(
+                protocol, "Parsing", "Invalid time",
+                "The time in line {} is not valid.".format(
+                    appointment_tag.linenumber))
+        name = appointment_tag.values[3]
+        if len(appointment_tag.values) == 5:
+            categories = appointment_tag.values[4].split(",")
+            categories = [category.strip() for category in categories]
+            categories = [category for category in categories if category]
+            if len(categories) > 0:
+                included_categories = protocol.protocoltype.get_calendar_categories()
+                if included_categories:
+                    included_categories.add("Protokoll_{}".format(protocol.protocoltype.id))
+                if included_categories and not any(category in included_categories for category in categories):
+                    raise ValueError("The appointment in line {} has the categories {}, but needs to have at least "
+                                     "one of the following categories: {}."
+                                     .format(appointment_tag.linenumber,
+                                             ", ".join(categories), ", ".join(included_categories)))
+
+        else:
+            categories = []
+
+        start = datetime.combine(date, begin_time, tzinfo=tz.tzlocal())
+        push_appointments_to_calendar(protocol, start, end_time, name, categories)
+
     # TOPs
     old_tops = list(protocol.tops)
     tops = []
@@ -994,13 +1039,40 @@ def push_tops_to_calendar_async(protocol_id):
             client = CalendarClient(protocol.protocoltype.calendar)
             client.set_event_at(
                 begin=protocol.get_datetime(),
-                name=protocol.protocoltype.short_name, description=description)
+                name=protocol.protocoltype.short_name, description=description,
+                categories=["Sitzung", "Protokoll_" + str(protocol.protocoltype.id)])
         except CalendarException as exc:
             return _make_error(
                 protocol, "Calendar",
                 "Pushing TOPs to Calendar failed", str(exc))
 
 
+def push_appointments_to_calendar(protocol, start, end, name, categories):
+    push_appointments_to_calendar_async.delay(protocol.id, start, end, name, categories)
+
+
+@celery.task
+def push_appointments_to_calendar_async(protocol_id, start, end, name, categories):
+    if not config.CALENDAR_ACTIVE:
+        return
+    with app.app_context():
+        protocol = Protocol.query.filter_by(id=protocol_id).first()
+        if protocol.protocoltype.calendar == "":
+            return
+
+        try:
+            client = CalendarClient(protocol.protocoltype.calendar)
+            client.set_event_at(
+                begin=start,
+                end=end,
+                name=name,
+                categories=categories)
+        except CalendarException as exc:
+            return _make_error(
+                protocol, "Calendar",
+                "Pushing Appointments to Calendar failed", str(exc))
+
+
 def set_etherpad_content(protocol):
     # wait for the users browser to open the etherpad
     # and for etherpad to create it, otherwise the import will fail
diff --git a/templates/documentation-syntax-tags.html b/templates/documentation-syntax-tags.html
index 2d82715..d3805fe 100644
--- a/templates/documentation-syntax-tags.html
+++ b/templates/documentation-syntax-tags.html
@@ -34,6 +34,7 @@
 						<li><a href="#todo">Todo</a></li>
 						<li><a href="#footnote">Fußnote</a></li>
 						<li><a href="#session">Sitzung</a></li>
+                        <li><a href="#termin">Termin</a></li>
 					</ul>
 				</div>
             </div>
@@ -145,5 +146,16 @@
 	</p>
 	<figure>
 		<pre class="highlight"><code><span class="nt">[sitzung</span>;<span class="mi">Datum</span>;<span class="mi">Uhrzeit</span><span class="nt">]</span></code></pre>
-	</figure>	
+	</figure>
+
+    <h4 id="termin">Termin-Tag</h4>
+    Werden in einer Sitzung Termine festgelegt, so können diese direkt aus dem Protokoll heraus angelegt und im Protokoll entsprechend hervorgehoben werden. Sie werden, sofern konfiguriert, darüber hinaus auch im Kalender des Protokolltyps angelegt und in kommenden Sitzungen unter dem TOP „Kalender“ aufgeführt.
+    Es wird der Tag des Types <code class="highlight" style="color: inherit;"><span class="nt">termin</span></code> genutzt.
+    Als erstes Argument nimmt der Tag das Datum in der Form <code class="highlight" style="color: inherit;"><span class="nt">[</span>…;<span class="mi">dd.mm.yyyy</span><span class="nt">]</span></code> entgegen, als zweites die Startzeit in der Form <code class="highlight" style="color: inherit;"><span class="nt">[</span>…;<span class="mi">h:mm</span><span class="nt">]</span></code> und als drittes die Endzeit im selben Format. Anschließend muss der Titel des Termins angegeben werden. Optional können noch die Kategorien des Termins angegeben werden, um Termine zu bestimmten Themen zusammenfassen zu können und für Kalender filtern zu können.
+    <figure>
+        <pre class="highlight"><code><span class="nt">[termin</span>;<span class="mi">Datum</span>;<span class="mi">Startzeit</span>;<span class="mi">Endzeit</span>;<span class="sx">Titel</span>;<span class="sx">Kategorie1,Kategorie2,…</span><span class="nt">]</span></code></pre>
+    </figure>
+
+    Falls der Kalender eine Liste von Kategorien voreingestellt hat, so werden nur diese Kategorien in der Liste angezeigt, sowie alle Termine ohne Kategorie.
+
 {% endblock %}
diff --git a/templates/protocol-template.txt b/templates/protocol-template.txt
index 8a4d1fd..7e853c4 100644
--- a/templates/protocol-template.txt
+++ b/templates/protocol-template.txt
@@ -20,6 +20,13 @@
                     {% endfor %}
                 {% else %}
                 {% endif %}
+            {% elif top.name == "Kalender" %}
+                {% set events=protocol.get_events() %}
+                {% if events|length > 0 %}
+                    {% for event in events %}
+                        {{event.render_template()}};
+                    {% endfor %}
+                {% endif %}
             {% else %}
                 {% if use_description %}
 					{% if top.description|length > 0 %}
diff --git a/templates/protokoll2.cls b/templates/protokoll2.cls
index 0c7a630..874a07d 100644
--- a/templates/protokoll2.cls
+++ b/templates/protokoll2.cls
@@ -253,3 +253,13 @@
 % Styling der Todo und Beschlusstags im Protokoll
 \newcommand{\Todo}[4]{\textbf{{#1}}: #2: #3 -- #4}
 \newcommand{\Beschluss}[2][]{\textbf{Beschluss:} #2 \def\temp{#1}\ifx\temp\empty\else\textit{(#1)}\fi}
+\newcommand{\Sitzung}[3][]{
+    \ifthenelse{\equal{#1}{}}{
+        \textbf{Sitzung} am #2 um #3 Uhr
+    }{
+        \href{#1}{\textbf{Sitzung} am #2 um #3 Uhr}
+    }
+}
+\newcommand{\Termin}[4]{
+    \textbf{#1} von #2 Uhr bis #3 Uhr: #4
+}
diff --git a/templates/top.tex b/templates/top.tex
index b3d34e3..0510784 100644
--- a/templates/top.tex
+++ b/templates/top.tex
@@ -97,6 +97,16 @@
 
 \newcommand{\Beschluss}[2][]{\textbf{Beschluss:} #2 \def\temp{#1}\ifx\temp\empty\else\textit{(#1)}\fi}
 \newcommand{\Todo}[4]{\textbf{{#1}}: #2: #3 -- #4}
+\newcommand{\Sitzung}[3][]{
+    \ifthenelse{\equal{#1}{}}{
+        \textbf{Sitzung} am #2 um #3 Uhr
+    }{
+        \href{#1}{\textbf{Sitzung} am #2 um #3 Uhr}
+    }
+}
+\newcommand{\Termin}[4]{
+    \textbf{#1} von #2 Uhr bis #3 Uhr: #4
+}
 
 \ENV{if show_private}\setboolean{intern}{true}\ENV{endif}
 \intern{\SetWatermarkText{INTERN} \SetWatermarkScale{1}}{}
diff --git a/views/forms.py b/views/forms.py
index 5517633..a2f2551 100644
--- a/views/forms.py
+++ b/views/forms.py
@@ -174,6 +174,8 @@ class ProtocolTypeForm(FlaskForm):
     wiki_only_public = BooleanField("Wiki ist öffentlich")
     printer = SelectField("Drucker", choices=[])
     calendar = SelectField("Kalender", choices=[])
+    calendar_lookahead = IntegerField('Anzahl Tage im Kalender', validators=[Optional()])
+    calendar_categories = StringField('Kalender Kategorien', validators=[Optional()])
     recurrence = IntegerField("Turnus (in Tagen)", validators=[Optional()])
     restrict_networks = BooleanField("Netzwerke einschränken")
     allowed_networks = IPNetworkField("Erlaubte Netzwerke")
diff --git a/views/tables.py b/views/tables.py
index d2efafa..c37e8fe 100644
--- a/views/tables.py
+++ b/views/tables.py
@@ -234,7 +234,7 @@ class ProtocolTypeTable(SingleValueTable):
             wiki_headers.append("Wiki-Kategorie")
         if not config.WIKI_ACTIVE:
             wiki_headers = []
-        calendar_headers = ["Kalender"]
+        calendar_headers = ["Kalender", 'Anzahl Tage im Kalender', 'Termin Kategorien']
         if not config.CALENDAR_ACTIVE:
             calendar_headers = []
         recurrence_headers = ["Turnus"]
@@ -295,7 +295,7 @@ class ProtocolTypeTable(SingleValueTable):
             wiki_part = []
         calendar_part = [
             self.value.calendar
-            if self.value.calendar is not None else ""]
+            if self.value.calendar is not None else "", self.value.calendar_lookahead, self.value.calendar_categories or '']
         if not config.CALENDAR_ACTIVE:
             calendar_part = []
         recurrence_part = [f"{self.value.recurrence} Tage" if self.value.recurrence is not None else ""]
-- 
GitLab


From e1560a4839c9ab7f3c7e52e92f4b2bb6d0a77f10 Mon Sep 17 00:00:00 2001
From: veni-vidi-code <tom.mucke@web.de>
Date: Sat, 19 Apr 2025 13:34:37 +0200
Subject: [PATCH 4/4] Refact to allow newer dependencies, Bugfix

Refactors some parts to allow for newer version of packages in the dependencies
(e.g. the escape used for Termine is no longer available from Flask and should be imported from Markupsafe), removes one unused import

Small Bugfix for Termine in the compile
---
 protoparser.py  | 4 ++--
 server.py       | 4 ++--
 tasks.py        | 2 +-
 views/tables.py | 3 ++-
 4 files changed, 7 insertions(+), 6 deletions(-)

diff --git a/protoparser.py b/protoparser.py
index 9551385..40188f9 100644
--- a/protoparser.py
+++ b/protoparser.py
@@ -1,4 +1,4 @@
-import flask
+import markupsafe
 import regex as re
 import sys
 from collections import OrderedDict
@@ -337,7 +337,7 @@ class Tag:
                     return "<b>Sitzung</b> am {0} um {1}".format(self.values[0], self.values[1])
             elif self.name == "termin":
                 return "<b>Termin:</b> {3} {0} {1} - {2}".format(
-                    self.values[0], self.values[1], self.values[2], flask.escape(self.values[3]))
+                    self.values[0], self.values[1], self.values[2], markupsafe.escape(self.values[3]))
             return "[{}: {}]".format(self.name, ";".join(self.values))
         elif render_type == RenderType.dokuwiki:
             if self.name == "url":
diff --git a/server.py b/server.py
index 0997e7c..6c5c1f8 100755
--- a/server.py
+++ b/server.py
@@ -4,8 +4,8 @@ locale.setlocale(locale.LC_TIME, "de_DE.utf8")
 
 from flask import (
     Flask, request, session, flash, redirect,
-    url_for, abort, render_template, Response, Markup)
-import click
+    url_for, abort, render_template, Response)
+from markupsafe import Markup
 from werkzeug.utils import secure_filename
 from flask_migrate import Migrate
 from celery import Celery
diff --git a/tasks.py b/tasks.py
index 13ff7e7..60b382d 100644
--- a/tasks.py
+++ b/tasks.py
@@ -536,7 +536,7 @@ def parse_protocol_async_inner(protocol, ignore_old_date=False):
             categories = [category.strip() for category in categories]
             categories = [category for category in categories if category]
             if len(categories) > 0:
-                included_categories = protocol.protocoltype.get_calendar_categories()
+                included_categories = protocol.get_calendar_categories()
                 if included_categories:
                     included_categories.add("Protokoll_{}".format(protocol.protocoltype.id))
                 if included_categories and not any(category in included_categories for category in categories):
diff --git a/views/tables.py b/views/tables.py
index c37e8fe..28af0d7 100644
--- a/views/tables.py
+++ b/views/tables.py
@@ -1,4 +1,5 @@
-from flask import Markup, url_for
+from flask import url_for
+from markupsafe import Markup
 from shared import date_filter, datetime_filter, time_filter, current_user
 from common.csrf import get_csrf_token
 
-- 
GitLab