#!/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 = (
""
)
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:
injection_line = f"X-ALT-DESC;FMTTYPE=text/html:{html_content}"
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,
)