#!/usr/bin/env python3 """ fredagsbar_ics_generator_v3.py Genererer en samlet .ics fil med fredagsbar-invitationer. Features: - Konfigurerbar via config.toml - Skipper danske helligdage - Skipper "indeklemte fredage" (dagen efter Kr. Himmelfart) - Skipper specificerede ferieuger (vinterferie, sommerferie etc.) - Advarer hvis der opstår store huller i kalenderen (> 21 dage) Kør: $ pip install -r requirements.txt $ python generator.py stories/min_historie.json """ import json import argparse import sys from datetime import datetime, date, timedelta, timezone from html import escape import os import re import tomllib import uuid from html.parser import HTMLParser from typing import Any, Mapping, Optional, TypedDict from zoneinfo import ZoneInfo import holidays from ics import Calendar, Event from validation import validate_config, validate_story_data, validate_uid_mapping # Farvekoder til terminal output class Colors: HEADER = '\033[95m' OKBLUE = '\033[94m' OKGREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' def disable_colors() -> None: for attr in ("HEADER", "OKBLUE", "OKGREEN", "WARNING", "FAIL", "ENDC"): setattr(Colors, attr, "") # Standard output mappe OUTPUT_DIR = "fredagsbar_output" CONFIG_FILE_TOML = "config.toml" CONFIG_FILE_JSON = "config.json" DEFAULT_TIMEZONE = "Europe/Copenhagen" TIMEZONE = ZoneInfo(DEFAULT_TIMEZONE) def load_json(filepath: str) -> Any: try: with open(filepath, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"{Colors.FAIL}Fejl ved indlæsning af {filepath}: {e}{Colors.ENDC}") sys.exit(1) def load_toml(filepath: str) -> Any: try: with open(filepath, "rb") as f: return tomllib.load(f) except Exception as e: print(f"{Colors.FAIL}Fejl ved indlæsning af {filepath}: {e}{Colors.ENDC}") sys.exit(1) def load_config(config_path: Optional[str] = None) -> tuple[dict[str, Any], str]: if config_path: if not os.path.exists(config_path): print(f"{Colors.FAIL}Config fil ikke fundet: {config_path}{Colors.ENDC}") sys.exit(1) ext = os.path.splitext(config_path)[1].lower() if ext in (".toml", ".tml"): return load_toml(config_path), config_path if ext == ".json": return load_json(config_path), config_path print(f"{Colors.FAIL}Ukendt config filtype: {config_path}{Colors.ENDC}") sys.exit(1) if os.path.exists(CONFIG_FILE_TOML): return load_toml(CONFIG_FILE_TOML), CONFIG_FILE_TOML if os.path.exists(CONFIG_FILE_JSON): return load_json(CONFIG_FILE_JSON), CONFIG_FILE_JSON print( f"{Colors.FAIL}Mangler config.toml (eller config.json) fil!{Colors.ENDC}" ) sys.exit(1) def parse_config(config_path: Optional[str] = None) -> dict[str, Any]: """Indlæser config og konverterer strenge til dato-objekter.""" cfg, source = load_config(config_path) try: return validate_config(cfg) except ValueError as e: print(f"{Colors.FAIL}{e}\n(Kilde: {source}){Colors.ENDC}") sys.exit(1) def load_timezone(timezone_name: str) -> ZoneInfo: try: return ZoneInfo(timezone_name) except Exception as e: print(f"{Colors.FAIL}Ugyldig timezone '{timezone_name}': {e}{Colors.ENDC}") sys.exit(1) def next_or_same_friday(d: date) -> date: return d + timedelta(days=(4 - d.weekday()) % 7) def sanitize_filename(s: str) -> str: s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE) s = re.sub(r"\s+", "_", s.strip()) s = s[:120] return s or "story" class _ListState(TypedDict): type: str index: int class StoryHTMLParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.lines: list[str] = [] self.current: list[str] = [] self.list_stack: list[_ListState] = [] self.in_pre = False def _append_text(self, text: str, preserve_leading: bool = False) -> None: if not text: return if not self.current and not preserve_leading: text = text.lstrip() if text: self.current.append(text) def _line_break(self, blank: bool = False) -> None: text = "".join(self.current).rstrip() self.current = [] if text: self.lines.append(text) if blank: if not self.lines or self.lines[-1] != "": self.lines.append("") def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None: tag = tag.lower() if tag == "br": self._line_break() return if tag == "pre": self._line_break(blank=True) self.in_pre = True return if tag in {"p", "div", "section", "article", "header", "footer", "blockquote"}: self._line_break(blank=True) return if tag in {"ul", "ol"}: self.list_stack.append({"type": tag, "index": 0}) self._line_break(blank=True) return if tag == "li": self._line_break() indent = " " * max(len(self.list_stack) - 1, 0) prefix = "- " if self.list_stack and self.list_stack[-1]["type"] == "ol": self.list_stack[-1]["index"] += 1 prefix = f"{self.list_stack[-1]['index']}. " self._append_text(indent + prefix, preserve_leading=True) def handle_endtag(self, tag: str) -> None: tag = tag.lower() if tag == "pre": self._line_break(blank=True) self.in_pre = False return if tag in {"p", "div", "section", "article", "header", "footer", "blockquote"}: self._line_break(blank=True) return if tag in {"ul", "ol"}: if self.list_stack: self.list_stack.pop() self._line_break(blank=True) return if tag == "li": self._line_break() def handle_data(self, data: str) -> None: if not data: return if self.in_pre: for chunk in data.splitlines(True): if chunk.endswith(("\n", "\r")): self._append_text(chunk.rstrip("\r\n"), preserve_leading=True) self._line_break() else: self._append_text(chunk, preserve_leading=True) return normalized = re.sub(r"\s+", " ", data) self._append_text(normalized) def get_text(self) -> str: self._line_break() while self.lines and self.lines[-1] == "": self.lines.pop() return "\n".join(self.lines).strip() def html_to_text(text: str) -> str: parser = StoryHTMLParser() parser.feed(text) parser.close() return parser.get_text() def normalize_uid(value: Optional[object]) -> Optional[str]: if value is None: return None uid = str(value).strip() return uid if uid else None def clear_story_uids(story_data: dict[str, Any]) -> int: removed = 0 for event_entry in story_data.get("events", []): if "uid" in event_entry: del event_entry["uid"] removed += 1 return removed def deterministic_event_uid( story_path: str, meta: dict[str, Any], event_entry: dict[str, Any], log_num: int, config: dict[str, Any], ) -> str: """Return a deterministic UID derived from story and event identifiers.""" uid_namespace = str(config.get("uid_namespace", "fredagsbar-meeting-generator")) story_id = meta.get("id") or os.path.splitext(os.path.basename(story_path))[0] event_id = event_entry.get("id") or event_entry.get("title") or str(log_num) seed = f"{story_id}:{event_id}" namespace_uuid = uuid.uuid5(uuid.NAMESPACE_URL, uid_namespace) return str(uuid.uuid5(namespace_uuid, seed)) LOG_HEADER_RE = re.compile(r"^//\\s+.*?\\s+(\\d+)\\s*/\\s*(\\d+)\\s+//") def extract_log_index(description: str) -> tuple[Optional[int], Optional[int]]: if not description: return None, None first_line = description.split("\\n", 1)[0] match = LOG_HEADER_RE.match(first_line) if not match: return None, None return int(match.group(1)), int(match.group(2)) def unfold_ics_lines(text: str) -> list[str]: lines = text.splitlines() unfolded = [] for line in lines: if line.startswith((" ", "\\t")) and unfolded: unfolded[-1] += line[1:] else: unfolded.append(line) return unfolded def load_existing_uids(ics_path: str) -> tuple[dict[str, str], dict[int, str], set[int], set[str]]: if not os.path.exists(ics_path): return {}, {}, set(), set() with open(ics_path, "r", encoding="utf-8", errors="replace") as f: text = f.read() events = [] current = None for line in unfold_ics_lines(text): if line == "BEGIN:VEVENT": current = {} continue if line == "END:VEVENT": if current is not None: events.append(current) current = None continue if current is None or ":" not in line: continue prop, value = line.split(":", 1) prop_name = prop.split(";", 1)[0].upper() if prop_name == "UID": current["uid"] = value.strip() elif prop_name == "SUMMARY": current["summary"] = value.strip() elif prop_name == "DESCRIPTION": current["description"] = value title_map = {} duplicate_titles = set() index_map = {} totals = set() for ev in events: uid = normalize_uid(ev.get("uid")) summary = ev.get("summary") if uid and summary: if summary in title_map: duplicate_titles.add(summary) else: title_map[summary] = uid log_idx, log_total = extract_log_index(ev.get("description", "")) if uid and log_idx is not None: if log_idx in index_map and index_map[log_idx] != uid: print(f"{Colors.FAIL}Uoverensstemmende UID for log index {log_idx} i eksisterende ICS.{Colors.ENDC}") sys.exit(1) index_map[log_idx] = uid if log_total is not None: totals.add(log_total) for title in duplicate_titles: title_map.pop(title, None) return title_map, index_map, totals, duplicate_titles def is_blocked_date( d: date, dk_holidays: Mapping[date, str], config: dict[str, Any], ) -> tuple[bool, Optional[str]]: """ Tjekker om en dato er blokeret pga: 1. Helligdag 2. Ferieuge (fra config) 3. Manuelt blokeret dato (fra config) 4. Dagen efter Kr. Himmelfart """ iso_week = d.isocalendar()[1] d_str = d.strftime("%Y-%m-%d") # 1. Danske Helligdage if d in dk_holidays: return True, f"Helligdag ({dk_holidays.get(d)})" # 2. Ferieuger if iso_week in config.get('blocked_weeks', []): return True, f"Ferieuge (Uge {iso_week})" # 3. Specifikke datoer if d_str in config.get('blocked_dates', []): return True, "Manuelt blokeret dato" month_day = d.strftime("%m-%d") if month_day in config.get('blocked_month_days', []): return True, "Manuelt blokeret dato" # 4. Dagen efter Kr. Himmelfart if config.get('skip_day_after_ascension', False): yesterday = d - timedelta(days=1) if yesterday in dk_holidays: holiday_name = dk_holidays.get(yesterday) if "Kristi Himmelfart" in holiday_name or "Ascension" in holiday_name: return True, "Dagen efter Kr. Himmelfart" return False, None def generate_html_content( event_data: dict[str, Any], meta: dict[str, Any], log_index: int, total_logs: int, repo_url: str, ) -> str: title = event_data["title"] story_text = event_data["story"] hint = event_data.get("hint", "") font = meta.get("font", "Arial, sans-serif") bg_color = meta.get("bg_color", "#f0f0f0") text_color = meta.get("text_color", "#000000") theme_color = meta.get("theme_color", "#000000") log_prefix = meta.get("log_prefix", "LOG") header_text = f"// {log_prefix} {log_index:02d}/{total_logs} // SUBJECT: {title.upper()}" if hint: header_text += f" // CODE: {hint}" html = ( f"
" f"{header_text}

" f"
" f"{story_text}" f"

" "
" "OBS: Dette er en invitation til en fredagsbar.
" "Ingen forberedelse nødvendig.

" "" f"Vibecoded source: {repo_url}" "" "
" ) return html.replace("\n", "") def build_preview_html( meta: dict[str, Any], config: dict[str, Any], preview_items: list[dict[str, Any]], skipped_dates: list[dict[str, str]], timezone_name: str, ) -> str: title = meta.get("name", "Story preview") start_label = config.get("start_date", "") items_html = [] for item in preview_items: start_dt = item["start"] end_dt = item["end"] date_label = start_dt.strftime("%Y-%m-%d") time_label = f"{start_dt:%H:%M}-{end_dt:%H:%M}" header = f"{date_label} {time_label} - {escape(item['title'])}" items_html.append( "
" f"

{header}

" f"
{item['html']}
" "
" ) items_block = "\n".join(items_html) skipped_block = "" if skipped_dates: skipped_items = "\n".join( f"
  • {escape(item['date'])}: {escape(item['reason'])}
  • " for item in skipped_dates ) skipped_block = ( "
    " "

    Skipped dates

    " "" "
    " ) return ( "" "" "" "" f"{escape(title)} - Preview" "" "" "" "
    " f"

    {escape(title)}

    " f"
    Preview schedule from {escape(start_label)} ({escape(timezone_name)})
    " f"{items_block}" f"{skipped_block}" "
    " "" "" ) def inject_outlook_hacks( ical_text: str, uid_html_map: dict[str, str], last_modified: Optional[str] = None, ) -> str: """Indsætter HTML og Busy-status i den rå ICS tekst.""" injection_status = "TRANSP:OPAQUE\r\nX-MICROSOFT-CDO-BUSYSTATUS:BUSY\r\n" vevent_pattern = re.compile(r"(BEGIN:VEVENT[\s\S]*?END:VEVENT)", flags=re.IGNORECASE) uid_pattern = re.compile(r"UID:(.*?)\s", flags=re.IGNORECASE) last_modified_pattern = re.compile(r"^LAST-MODIFIED", flags=re.IGNORECASE | re.MULTILINE) def fold_ical_line(line: str, limit: int = 75) -> str: """Fold line per RFC 5545 (75 octets) using CRLF + space continuation.""" if not line: return line lines = [] current = [] current_len = 0 for ch in line: ch_len = len(ch.encode("utf-8")) if current and current_len + ch_len > limit: lines.append("".join(current)) current = [ch] current_len = ch_len else: current.append(ch) current_len += ch_len if current: lines.append("".join(current)) if len(lines) == 1: return lines[0] return "\r\n ".join(lines) def replacer(match): block = match.group(1) uid_match = uid_pattern.search(block) if not uid_match: return block uid = uid_match.group(1).strip() html_content = uid_html_map.get(uid) if last_modified and not last_modified_pattern.search(block): block = block.replace( "\r\nEND:VEVENT", f"\r\nLAST-MODIFIED:{last_modified}\r\nEND:VEVENT", ) if html_content: html_payload = f"{html_content}" alt_desc_line = f"ALT-DESC;FMTTYPE=text/html:{html_payload}" x_alt_desc_line = f"X-ALT-DESC;FMTTYPE=text/html:{html_payload}" injection_alt_desc = fold_ical_line(alt_desc_line) injection_x_alt_desc = fold_ical_line(x_alt_desc_line) if "X-MICROSOFT-CDO-BUSYSTATUS" not in block: block = block.replace("\r\nEND:VEVENT", "\r\n" + injection_status + "END:VEVENT") block = block.replace( "\r\nEND:VEVENT", "\r\n" + injection_alt_desc + "\r\n" + injection_x_alt_desc + "\r\nEND:VEVENT", ) return block text_normalized = ical_text.replace("\r\n", "\n").replace("\r", "\n") text_crlf = text_normalized.replace("\n", "\r\n") return vevent_pattern.sub(replacer, text_crlf) def generate_single_file( story_path: str, dry_run: bool = False, clear_uids: bool = False, preview_html: bool = False, config_path: Optional[str] = None, output_dir: str = OUTPUT_DIR, timezone_name: str = DEFAULT_TIMEZONE, duration_minutes: int = 60, ) -> None: # Load data config = parse_config(config_path) story_data = load_json(story_path) try: story_data = validate_story_data(story_data) except ValueError as e: print(f"{Colors.FAIL}{e}{Colors.ENDC}") sys.exit(1) if clear_uids: removed = clear_story_uids(story_data) if removed: if dry_run: print(f"{Colors.OKBLUE}[DRY RUN] Fjernede {removed} UID'er fra story-data (ingen filskrivning).{Colors.ENDC}") else: try: with open(story_path, "w", encoding="utf-8") as f: json.dump(story_data, f, ensure_ascii=False, indent=2) f.write("\n") except Exception as e: print(f"{Colors.FAIL}Kunne ikke rydde UID'er i historie-fil: {e}{Colors.ENDC}") sys.exit(1) else: msg = "Ingen UID'er at fjerne." if dry_run: print(f"{Colors.OKBLUE}[DRY RUN] {msg}{Colors.ENDC}") else: print(f"{Colors.OKBLUE}{msg}{Colors.ENDC}") return if not isinstance(duration_minutes, int) or duration_minutes <= 0: print(f"{Colors.FAIL}duration_minutes skal være et positivt heltal.{Colors.ENDC}") sys.exit(1) timezone_info = TIMEZONE if timezone_name == DEFAULT_TIMEZONE else load_timezone(timezone_name) meta = story_data["meta"] events_data = story_data["events"] repo_url = config.get("repo_url", "") story_slug = sanitize_filename(meta.get("name", "story")) if not dry_run: os.makedirs(output_dir, exist_ok=True) filename = f"FULL_SERIES_{story_slug}.ics" out_path = os.path.join(output_dir, filename) existing_title_uids, existing_index_uids, existing_totals, duplicate_titles = load_existing_uids(out_path) try: allow_uid_reuse = validate_uid_mapping(events_data, duplicate_titles, existing_totals) except ValueError as e: print(f"{Colors.FAIL}{e}{Colors.ENDC}") sys.exit(1) if not allow_uid_reuse: existing_title_uids = {} existing_index_uids = {} existing_totals = set() existing_total = next(iter(existing_totals)) if len(existing_totals) == 1 else None use_index_map = existing_total == len(events_data) if existing_total is not None else False # Setup Helligdage dk_holidays = holidays.DK() print(f"{Colors.HEADER}--- Genererer kalender: {meta.get('name')} ---{Colors.ENDC}") print(f"Startdato: {config['start_date']}") print(f"Blokerede uger: {config['blocked_weeks']}") if dry_run: print(f"{Colors.OKBLUE}[DRY RUN] Ingen filer skrives, og JSON opdateres ikke.{Colors.ENDC}") cal = Calendar() uid_html_map: dict[str, str] = {} seen_uids: set[str] = set() preview_items: list[dict[str, Any]] = [] skipped_dates: list[dict[str, str]] = [] story_updated = False # Start Logic current_date = next_or_same_friday(config['start_date_obj']) last_event_date = None total_logs = len(events_data) # Loop over hver HISTORIE del (vi skipper ikke historien, kun datoen) for idx, event_entry in enumerate(events_data): log_num = idx + 1 # Find næste ledige dato while True: is_blocked, reason = is_blocked_date(current_date, dk_holidays, config) if not is_blocked: break # Datoen er god! # Hvis blokeret, print info og hop en uge frem print(f"{Colors.FAIL} >> SKIPPER {current_date}: {reason}{Colors.ENDC}") skipped_dates.append( { "date": current_date.strftime("%Y-%m-%d"), "reason": reason or "Blokeret dato", } ) current_date += timedelta(weeks=1) # Gap Check (Advarsel hvis der er lang tid siden sidst) if last_event_date: days_since_last = (current_date - last_event_date).days if days_since_last > 21: print(f"{Colors.WARNING} [!] ADVARSEL: Stort hul i kalenderen ({days_since_last} dage) mellem sidste event og nu.{Colors.ENDC}") # UID validering og autofyld til story JSON title = event_entry.get("title", "") uid_from_ics = None if title and title in existing_title_uids: uid_from_ics = existing_title_uids[title] elif use_index_map and log_num in existing_index_uids: uid_from_ics = existing_index_uids[log_num] existing_uid_raw = event_entry.get("uid") existing_uid = normalize_uid(existing_uid_raw) if existing_uid_raw is not None and not existing_uid: print(f"{Colors.FAIL}Ugyldigt UID (tom værdi) for '{event_entry.get('title')}'.{Colors.ENDC}") sys.exit(1) if existing_uid and uid_from_ics and existing_uid != uid_from_ics: print(f"{Colors.FAIL}UID mismatch for '{event_entry.get('title')}': {existing_uid} != {uid_from_ics}{Colors.ENDC}") sys.exit(1) if existing_uid: my_uid = existing_uid elif uid_from_ics: my_uid = uid_from_ics else: my_uid = deterministic_event_uid(story_path, meta, event_entry, log_num, config) if my_uid in seen_uids: print(f"{Colors.FAIL}Duplikeret UID '{my_uid}' for '{event_entry.get('title')}'.{Colors.ENDC}") sys.exit(1) seen_uids.add(my_uid) if not dry_run and existing_uid != my_uid: event_entry["uid"] = my_uid story_updated = True # Opret Event start_dt = datetime.combine(current_date, config['start_time_obj']).replace(tzinfo=timezone_info) end_dt = start_dt + timedelta(minutes=duration_minutes) event = Event() event.uid = my_uid event.name = event_entry["title"] event.begin = start_dt event.end = end_dt event.status = "CONFIRMED" event.classification = "PUBLIC" if config.get("organizer_email"): event.organizer = config.get("organizer_email") html_content = generate_html_content(event_entry, meta, log_num, total_logs, repo_url) preview_items.append( { "start": start_dt, "end": end_dt, "title": event_entry["title"], "html": html_content, } ) clean_story = html_to_text(event_entry["story"]) hint_text = f" // {event_entry.get('hint', '')}" if event_entry.get('hint') else "" log_prefix = meta.get("log_prefix", "LOG") plain_desc = ( f"// {log_prefix} {log_num}/{total_logs} // {event_entry['title']}{hint_text}\n\n" f"{clean_story}\n\n" "-------------------\n" f"{repo_url}" ) event.description = plain_desc cal.events.add(event) uid_html_map[my_uid] = html_content print(f"{Colors.OKGREEN}[{log_num}/{total_logs}] {current_date}: {event_entry['title']}{Colors.ENDC}") last_event_date = current_date # Gør klar til næste uge current_date += timedelta(weeks=1) if dry_run: print(f"\n{Colors.OKBLUE}Dry-run færdig. Ingen filer skrevet.{Colors.ENDC}") return # Gem fil full_ical_text = cal.serialize() last_modified = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ") final_text = inject_outlook_hacks(full_ical_text, uid_html_map, last_modified=last_modified) with open(out_path, "w", encoding="utf-8", newline="") as f: f.write(final_text) if preview_html: preview_filename = f"PREVIEW_{story_slug}.html" preview_path = os.path.join(output_dir, preview_filename) preview_html_text = build_preview_html( meta, config, preview_items, skipped_dates, timezone_name, ) with open(preview_path, "w", encoding="utf-8") as f: f.write(preview_html_text) print(f"{Colors.OKBLUE}HTML preview gemt: {preview_path}{Colors.ENDC}") if story_updated: try: with open(story_path, "w", encoding="utf-8") as f: json.dump(story_data, f, ensure_ascii=False, indent=2) f.write("\n") except Exception as e: print(f"{Colors.FAIL}Kunne ikke opdatere historie-fil med UID'er: {e}{Colors.ENDC}") sys.exit(1) print(f"\n{Colors.OKBLUE}Færdig! Fil gemt: {out_path}{Colors.ENDC}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Generer ICS fil med feriehåndtering.") parser.add_argument("story_file", help="Sti til JSON filen") parser.add_argument( "--dry-run", action="store_true", help="Vis planlagte datoer og skippede uger uden at skrive filer eller opdatere JSON.", ) parser.add_argument( "--clear-uids", action="store_true", help="Fjern events[].uid fra story JSON før generering (ingen filskrivning ved --dry-run).", ) parser.add_argument( "--preview-html", action="store_true", help="Gem en HTML preview med planlagte datoer og HTML-indhold.", ) parser.add_argument( "--config", help="Sti til config.toml (eller config.json).", ) parser.add_argument( "--output-dir", default=OUTPUT_DIR, help="Output mappe til .ics og preview.", ) parser.add_argument( "--timezone", default=DEFAULT_TIMEZONE, help="IANA timezone navn (fx Europe/Copenhagen).", ) parser.add_argument( "--duration-minutes", type=int, default=60, help="Event varighed i minutter.", ) parser.add_argument( "--no-color", action="store_true", help="Slå farver fra i terminal output.", ) args = parser.parse_args() if args.no_color: disable_colors() if not os.path.exists(args.story_file): print(f"{Colors.FAIL}Historie-fil ikke fundet.{Colors.ENDC}") sys.exit(1) generate_single_file( args.story_file, dry_run=args.dry_run, clear_uids=args.clear_uids, preview_html=args.preview_html, config_path=args.config, output_dir=args.output_dir, timezone_name=args.timezone, duration_minutes=args.duration_minutes, )