823 lines
28 KiB
Python
823 lines
28 KiB
Python
#!/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"<div style='font-family: {font}; color: {text_color};'>"
|
|
f"<b style='color:{theme_color}'>{header_text}</b><br><br>"
|
|
f"<div style='background-color: {bg_color}; padding: 15px; border-left: 5px solid {theme_color};'>"
|
|
f"{story_text}"
|
|
f"</div><br>"
|
|
"<hr>"
|
|
"<b>OBS: Dette er en invitation til en fredagsbar.</b><br>"
|
|
"Ingen forberedelse nødvendig.<br><br>"
|
|
"<span style='font-size: 10px; color: #666;'>"
|
|
f"Vibecoded source: <a href='{repo_url}'>{repo_url}</a>"
|
|
"</span>"
|
|
"</div>"
|
|
)
|
|
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(
|
|
"<section class='event'>"
|
|
f"<h2>{header}</h2>"
|
|
f"<div class='event-html'>{item['html']}</div>"
|
|
"</section>"
|
|
)
|
|
|
|
items_block = "\n".join(items_html)
|
|
skipped_block = ""
|
|
if skipped_dates:
|
|
skipped_items = "\n".join(
|
|
f"<li>{escape(item['date'])}: {escape(item['reason'])}</li>"
|
|
for item in skipped_dates
|
|
)
|
|
skipped_block = (
|
|
"<section class='skipped'>"
|
|
"<h2>Skipped dates</h2>"
|
|
"<ul>"
|
|
f"{skipped_items}"
|
|
"</ul>"
|
|
"</section>"
|
|
)
|
|
return (
|
|
"<!DOCTYPE html>"
|
|
"<html lang='da'>"
|
|
"<head>"
|
|
"<meta charset='utf-8'>"
|
|
f"<title>{escape(title)} - Preview</title>"
|
|
"<style>"
|
|
"body{font-family:Arial,Helvetica,sans-serif;margin:32px;background:#f7f6f2;color:#222;}"
|
|
".wrap{max-width:960px;margin:0 auto;}"
|
|
"h1{margin-bottom:4px;}"
|
|
".meta{color:#555;margin-bottom:24px;}"
|
|
".event{background:#fff;border:1px solid #e0ded8;border-radius:12px;padding:18px;margin-bottom:24px;}"
|
|
".event h2{margin-top:0;font-size:18px;color:#222;}"
|
|
".event-html{margin-top:12px;}"
|
|
".skipped{background:#fff7e6;border:1px solid #f0d3a3;border-radius:12px;padding:16px;}"
|
|
".skipped h2{margin-top:0;font-size:16px;color:#5b3a00;}"
|
|
"</style>"
|
|
"</head>"
|
|
"<body>"
|
|
"<div class='wrap'>"
|
|
f"<h1>{escape(title)}</h1>"
|
|
f"<div class='meta'>Preview schedule from {escape(start_label)} ({escape(timezone_name)})</div>"
|
|
f"{items_block}"
|
|
f"{skipped_block}"
|
|
"</div>"
|
|
"</body>"
|
|
"</html>"
|
|
)
|
|
|
|
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:
|
|
injection_line = f"X-ALT-DESC;FMTTYPE=text/html:<!DOCTYPE html><html><body>{html_content}</body></html>"
|
|
injection_html = fold_ical_line(injection_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_html + "\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,
|
|
)
|