diff --git a/.gitignore b/.gitignore index d070f77..466e106 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ fredagsbar_output/* +**/*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..19e804c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# Repository Guidelines + +## Project Structure and Module Organization +- python version is `python3.13` +- `generator.py` is the main CLI that builds a single `.ics` calendar file from a story JSON. +- `config.toml` holds schedule rules (start date/time, blocked weeks/dates, organizer info). +- `stories/` contains story inputs; each file includes a `meta` object and an `events` list. +- `stories/template.json` is a starter template for new stories. +- `fredagsbar_output/` stores generated `.ics` and preview `.html` files (local artifacts ignored via `.gitignore`). +- `requirements.txt` pins Python dependencies. + +## Build, Test, and Development Commands +- `python -m venv .venv` and `source .venv/bin/activate` to create a local virtualenv. +- `pip install -r requirements.txt` installs dependencies. +- `python generator.py stories/object_87-B.json` generates a calendar file in `fredagsbar_output/`. +- `python generator.py stories/object_87-B.json --preview-html` also writes an HTML preview to `fredagsbar_output/`. +- Optional flags: `--config`, `--output-dir`, `--timezone`, `--duration-minutes`, `--no-color`. +- `python generator.py stories/.json` runs the generator with another story file. + +## Coding Style and Naming Conventions +- Use standard Python style: 4-space indentation, snake_case for functions and variables. +- Keep JSON keys consistent with existing stories (`meta.name`, `meta.theme_color`, `events[].title`, `events[].story`). +- For stable calendar updates, set `meta.id` and `events[].id` (or `events[].uid` to force a specific UID). Optionally set `config.toml` `uid_namespace` to change the deterministic UID namespace. +- The generator writes back `events[].uid` after a successful run and validates existing UID values for mismatches or duplicates. +- If a prior `fredagsbar_output/FULL_SERIES_.ics` exists, its UIDs are reused and injected into the story JSON. +- Name story files with descriptive, lowercase filenames using underscores or hyphens (for example: `stories/new_story.json`). +- Output filenames are derived from `meta.name` and written as `fredagsbar_output/FULL_SERIES_.ics`. + +## Testing Guidelines +- Automated tests live in `tests/`. Run `python -m unittest discover`. +- Coverage includes date skipping, UID mapping, HTML injection, and preview rendering. +- When altering scheduling logic, confirm blocked weeks/dates and holiday skipping behavior from `config.toml`. + +## Commit and Pull Request Guidelines +- Use short, imperative commit subjects consistent with history (for example: "update repo path"). +- Do not commit generated `.ics` files; `fredagsbar_output/` is treated as a local artifact directory. +- In PRs, describe the story or scheduling changes, and mention the output filename you generated for verification. +- If you modify `config.toml`, call out changes to dates, blocked weeks, or organizer settings in the PR description. + +## Configuration and Content Tips +- Stories can include HTML in `events[].story`; the generator converts HTML to plain text for descriptions and renders the HTML in previews. +- Keep `repo_url` in `config.toml` accurate, as it is embedded in the event description. +- `blocked_dates` supports both `YYYY-MM-DD` and `MM-DD` entries. +- Optional `repo_url`, `organizer_email`, and `uid_namespace` must be strings when set. +- HTML previews include a skipped-dates section when blocked dates/weeks are encountered. diff --git a/README.md b/README.md index 66dd002..a8ddb5d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ -# fredagsbar-meeting-generator +# Vibecoded meeting generator -generere en fredagsbar .ics fil som indeholder en historie.. \ No newline at end of file +Generate a single calendar (.ics) file from a story JSON, plus an optional HTML preview of the same content. +There are currently written two stories in an SCP-inspired universe located in `.stories/`. + +## Setup + +To run the generator first you need to install the requirements, the recommended approach is with a virtual env. + +`$ python -m venv .venv` + +Then to use pip: +`$ .venv/bin/activate` +`$ pip install -r requirements.txt` + +## Usage + +`$ python generator.py stories/object_87-B.json` + +This writes the calendar to `fredagsbar_output/FULL_SERIES_.ics`. + +`$ python generator.py stories/object_87-B.json --preview-html` + +This also writes a preview to `fredagsbar_output/PREVIEW_.html` with the scheduled dates, the same HTML that lands in the calendar, and a skipped-dates section when any dates are blocked. + +Optional flags: +`--config /path/to/config.toml` +`--output-dir /path/to/output` +`--timezone Europe/Copenhagen` +`--duration-minutes 90` +`--no-color` + +Example: +`$ python generator.py stories/object_87-B.json --config config.toml --output-dir out --timezone UTC --duration-minutes 90 --preview-html` + +Note: batch generation is intentionally not supported to avoid overlapping Friday events. + +## Config + +Edit `config.toml`. `blocked_dates` accepts full dates (`YYYY-MM-DD`) and yearly repeats (`MM-DD`). Optional `repo_url`, `organizer_email`, and `uid_namespace` must be strings when set. + +## Create stories + +There is a Gem-bot that can generate stories here: +https://gemini.google.com/gem/1zo7ssHuPGce4rx02upq7iprgdXq7RvvB?usp=sharing + +## Tests + +`$ python -m unittest discover` diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..f8d5820 --- /dev/null +++ b/config.toml @@ -0,0 +1,9 @@ +# Schedule config for fredagsbar generator. +# Dates use YYYY-MM-DD. blocked_dates also supports MM-DD for yearly repeats. +start_date = "2026-02-04" +start_time = "15:00" +repo_url = "https://gitea.weircon.dk/agw/fredagsbar-meeting-generator" +organizer_email = "social-club@cgi.com" +blocked_weeks = [8, 28, 29, 30, 31, 42, 52, 53] +blocked_dates = ["05-01"] +skip_day_after_ascension = true diff --git a/generator.py b/generator.py index 68e4fd3..4644317 100644 --- a/generator.py +++ b/generator.py @@ -2,38 +2,109 @@ """ fredagsbar_ics_generator_v3.py -Genererer .ics invitationer baseret på JSON stories. -Understøtter nu 'hints' i filnavne og headers. +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 ics import Calendar, Event -from datetime import datetime, date, time, timedelta -from zoneinfo import ZoneInfo -import uuid +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 -# ====== STANDARD INDSTILLINGER ====== -START_DATE = date(2026, 1, 29) -START_TIME = time(15, 00) -DURATION_MINUTES = 60 -TIMEZONE = ZoneInfo("Europe/Copenhagen") -OUTPUT_BASE_DIR = "fredagsbar_output" -REPO = "https://gitea.weircon.dk/agw/fredagsbar-meeting-generator"; +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 load_story(filepath): - """Indlæser JSON fil og validerer strukturen.""" +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: - data = json.load(f) - return data + return json.load(f) except Exception as e: - print(f"Fejl ved indlæsning af {filepath}: {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: @@ -42,143 +113,710 @@ def next_or_same_friday(d: date) -> date: def sanitize_filename(s: str) -> str: s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE) s = re.sub(r"\s+", "_", s.strip()) - return s[:120] + s = s[:120] + return s or "story" -def strip_html_tags(text: str) -> str: - clean = re.compile('<.*?>') - return re.sub(clean, '', text) +class _ListState(TypedDict): + type: str + index: int -def inject_outlook_lines(ical_text: str, html_description: str) -> str: - """Indsætter Outlook-specifikke linjer og HTML beskrivelse.""" - injection_status = "TRANSP:OPAQUE\r\nX-MICROSOFT-CDO-BUSYSTATUS:BUSY\r\n" - # Outlook kræver often at HTML er på én linje eller foldet korrekt - html_oneline = html_description.replace("\n", "") - injection_html = f"X-ALT-DESC;FMTTYPE=text/html:{html_oneline}\r\n" - def replacer(match): - vevent = match.group(0) - if "X-MICROSOFT-CDO-BUSYSTATUS" not in vevent: - vevent = vevent.replace("\r\nEND:VEVENT", "\r\n" + injection_status + "END:VEVENT") - # Overskriv eller indsæt X-ALT-DESC - if "X-ALT-DESC" in vevent: - # Simpel håndtering: Hvis vi allerede har det, ignorer (eller implementer regex replace af X-ALT-DESC) - pass +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: - vevent = vevent.replace("\r\nEND:VEVENT", "\r\n" + injection_html + "END:VEVENT") - return vevent + unfolded.append(line) + return unfolded - text_crlf = ical_text.replace("\n", "\r\n") - vevent_pattern = re.compile(r"BEGIN:VEVENT[\s\S]*?END:VEVENT", flags=re.IGNORECASE) - return vevent_pattern.sub(replacer, text_crlf) +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() -def create_event_ics_file(event_data, meta, log_index, total_logs, start_dt, end_dt, out_path): + 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", "") # Hent hint hvis det findes + hint = event_data.get("hint", "") - cal = Calendar() - event = Event() - event.uid = str(uuid.uuid4()) - - # Hvis der er et hint, sæt det evt. ind i titlen eller behold titlen ren corporate - event.name = title - event.begin = start_dt - event.end = end_dt - event.status = "CONFIRMED" - event.classification = "PUBLIC" - - # Meta styling 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") - # Byg Header strengen header_text = f"// {log_prefix} {log_index:02d}/{total_logs} // SUBJECT: {title.upper()}" if hint: header_text += f" // CODE: {hint}" - # --- HTML Version (Outlook) --- - html_desc = ( + html = ( f"
" f"{header_text}

" f"
" - f"{story_text}" # Her indsætter vi story direkte, da den nu indeholder HTML tags fra JSON + f"{story_text}" f"

" "
" "OBS: Dette er en invitation til en fredagsbar.
" - "Dette event er automatisk genereret til Fredagsbar, med en title der giver Nicolaj mulighed for at deltage.
" "Ingen forberedelse nødvendig.

" "" - "Vibecoded sourcecode til generering kan findes her: " + repo + "" + f"Vibecoded source: {repo_url}" "" "
" ) + return html.replace("\n", "") - # --- Plain Text Version --- - # Vi erstatter
med \n for læsbarhed i plain text - plain_story = story_text.replace("
", "\n").replace("", "").replace("", "").replace("", "").replace("", "") - plain_desc = ( - f"{header_text}\n\n" - f"{plain_story}\n\n" - "--------------------------------------------------\n" - "OBS: Dette er en invitation til en fredagsbar.\n" - "Ingen forberedelse nødvendig.\n" - + repo +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

    " + "
      " + f"{skipped_items}" + "
    " + "
    " + ) + 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) - event.description = plain_desc - cal.events.add(event) - - serialized = cal.serialize() - processed = inject_outlook_lines(serialized, html_desc) - - with open(out_path, "w", encoding="utf-8", newline="\r\n") as f: - f.write(processed) - - print(f"[{log_index}/{total_logs}] '{title}' -> {out_path}") - -def generate_series(json_path): - data = load_story(json_path) - meta = data["meta"] - events = data["events"] + meta = story_data["meta"] + events_data = story_data["events"] + repo_url = config.get("repo_url", "") story_slug = sanitize_filename(meta.get("name", "story")) - output_dir = os.path.join(OUTPUT_BASE_DIR, story_slug) - os.makedirs(output_dir, exist_ok=True) - - print(f"--- Starter generering af: {meta.get('name')} ---") - - first_friday = next_or_same_friday(START_DATE) - total_logs = len(events) - - for idx, event_entry in enumerate(events): - event_date = first_friday + timedelta(weeks=idx) - start_dt = datetime.combine(event_date, START_TIME).replace(tzinfo=TIMEZONE) - end_dt = start_dt + timedelta(minutes=DURATION_MINUTES) + 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 - # Bruger hint i filnavnet hvis det findes - hint_part = "" - if "hint" in event_entry: - hint_part = f"_{sanitize_filename(event_entry['hint'])}" + # 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! - fname = f"{event_date.strftime('%Y%m%d')}_Log{log_num}{hint_part}_{sanitize_filename(event_entry['title'])}.ics" - out_path = os.path.join(output_dir, fname) + # 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) - create_event_ics_file(event_entry, meta, log_num, total_logs, start_dt, end_dt, out_path) + # 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 Fredagsbar ICS filer fra JSON historie.") - parser.add_argument("story_file", help="Sti til JSON filen med historien (f.eks. stories/story_scp.json)") - + 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"Fejl: Filen '{args.story_file}' findes ikke.") + print(f"{Colors.FAIL}Historie-fil ikke fundet.{Colors.ENDC}") sys.exit(1) - generate_series(args.story_file) - print("\nFærdig!") \ No newline at end of file + 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, + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c8c10bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +arrow==1.4.0 +attrs==25.4.0 +holidays==0.89 +ics==0.7.2 +python-dateutil==2.9.0.post0 +six==1.17.0 +TatSu==5.16.0 +tzdata==2025.3 diff --git a/stories/mysteriet_i_java_hulen.json b/stories/mysteriet_i_java_hulen.json new file mode 100644 index 0000000..a55791a --- /dev/null +++ b/stories/mysteriet_i_java_hulen.json @@ -0,0 +1,73 @@ +{ + "meta": { + "name": "Mysteriet i Java Hulen", + "theme_color": "#2b2b2b", + "text_color": "#5cdb95", + "bg_color": "#05386b", + "font": "Consolas, monospace", + "organizer": "System Administrator (Unknown)", + "log_prefix": "RUNTIME_LOG" + }, + "events": [ + { + "title": "FB: Initialisering af Fredags-Sprint", + "hint": "COMPILE_ERROR", + "story": "OFFICIELT FORMÅL:
    • Gennemgang af ugens kodebase
    • Syntax check

    LOG 15:00:
    Asger har fundet en fejl i kompilatoren. Hver gang han skriver public void, retter IDE'en det automatisk til public bar.

    Mathias sværger på, at han så kaffemaskinen blinke i morsekode. Beskeden var: \"JAVA ER IKKE LÆNGERE KAFFE.\"

    Hulen ændrer sig. Luften smager pludselig af humle.", + "uid": "2f10eec2-fd1a-5178-bfe4-7ae1810419f2" + }, + { + "title": "FB: Audit af Lokale-Ressourcer", + "hint": "TOMHED", + "story": "OFFICIELT FORMÅL:
    • Inventaroptælling
    • Ressourceallokering

    LOG 15:15:
    Vi sendte Tom ind for at hente whiteboards. Han kom tilbage uden noget.
    Rapporten lyder: \"Der er helt Tomt derinde.\"

    Men Martin påpeger, at selvom Tom siger det er tomt, kan vi høre lyden af 40 mennesker, der skåler, når døren står på klem.
    Tom kigger tomt ud i luften. Han har set noget.", + "uid": "4332fcba-389c-5881-8c20-ccf5c37d52eb" + }, + { + "title": "FB: Legacy System Integration", + "hint": "GHOST_COMMIT", + "story": "OFFICIELT FORMÅL:
    • Integration af ældre systemer

    LOG 15:30:
    Hans og Lasse har fundet commits i git-historikken, dateret til \"Fredag kl. 25:00\".
    Commit-beskeden er blot: \"Morten var her.\"

    Hvilken Morten? Vi spurgte Morten. Han anede intet.
    Vi spurgte Morten II (Den Anden). Han smilede bare og pegede på en post-it note, der var dukket op på væggen: \"Glem koden. Drik væsken.\"", + "uid": "db3a8d71-7dbf-5625-8547-dee20324ba16" + }, + { + "title": "FB: Identitets-Verifikation (Double-Check)", + "hint": "DOBBELTGÆNGER", + "story": "OFFICIELT FORMÅL:
    • Sikkerhedsopdatering af brugerprofiler

    LOG 15:45:
    Jacob og Christoffer forsøgte at debugge virkeligheden.
    De observerede, at når lyset i Java Hulen flimrer, kaster Morten II en skygge, der ligner den første Morten.

    Tapper prøvede at tappe vand fra hanen. Der kom mørk stout ud.
    \"Det er en feature, ikke en bug,\" hviskede Lene fra hjørnet, mens hun stirrede ind i en skærm, der var slukket.", + "uid": "dd9460dd-e4f9-5ec9-a422-e9ebe254160c" + }, + { + "title": "FB: Akustisk Frekvens-Analyse", + "hint": "EKKO", + "story": "OFFICIELT FORMÅL:
    • Støjmåling i storrumskontor

    LOG 16:00:
    Anne og Jan melder om uregelmæssigheder.
    Når man er stille, kan man høre tastaturerne skrive af sig selv.
    Rytmen lyder som åbningen af dåser: Pscht. Klik. Slurk.

    Lise forsøgte at forlade rummet, men døren førte bare ind i den anden ende af Java Hulen.
    \"Vi er i et loop,\" sagde hun. \"Vi må drikke os ud.\"", + "uid": "908d795f-26a5-578f-9cc5-25ca9ab7ff99" + }, + { + "title": "FB: Hardware Stress-Test", + "hint": "OVERCLOCK", + "story": "OFFICIELT FORMÅL:
    • Belastningstest af servere

    LOG 16:15:
    Mikkel og Claus har målt temperaturen. Den falder drastisk nær køleskabet.
    Lars Erik fandt en manual under gulvtæppet tituleret: \"Protokol for Fredags-Transformationen\".

    Siderne er blanke, indtil man spilder væske på dem.
    Der står nu: \"Morten II er nøglen. Eller måske låsen.\"", + "uid": "9116a36e-16ef-55f8-88cc-cc9c6c4a3879" + }, + { + "title": "FB: Tidsmæssig Synkroniseringsfejl", + "hint": "LAG", + "story": "OFFICIELT FORMÅL:
    • NTP Server justering

    LOG 16:30:
    Rune kiggede på sit ur. Viserne går baglæns.
    Emil påstår, at han har haft den samme samtale med Kathrine 4 gange nu.

    \"Vi compilerer ikke længere kode,\" sagde Kathrine tørt. \"Vi compilerer promiller.\"
    Væggene i Java Hulen begynder at ligne binære tal, der flyder nedad (Matrix-style), men tallene er priser fra den lokale bar.", + "uid": "526fa96f-c227-54c5-b1f2-52d5ce8f7807" + }, + { + "title": "FB: Bruger-Accept Test (UAT)", + "hint": "RUNTIME", + "story": "OFFICIELT FORMÅL:
    • Godkendelse af leverancer

    LOG 16:45:
    Morten (den første) er begyndt at fade ud. Han bliver mere og mere gennemsigtig.
    Morten II (Den Anden) bliver mere solid.

    \"Der kan kun være én Runtime-Morten,\" grinede Asger nervøst.
    Tom tjekkede sin flaske. Den var Tom. Da han kiggede igen, var den fuld.
    \"Uendelig løkke!\" råbte Tapper begejstret.", + "uid": "592ad6f5-f0ec-5c5a-9022-6733745cec71" + }, + { + "title": "FB: Ledelsesmæssig Eskalering", + "hint": "CRITICAL", + "story": "OFFICIELT FORMÅL:
    • Eskalering til styregruppen

    LOG 16:55:
    Mathias og Martin forsøger at holde fast i bordene.
    Tyngdekraften i Java Hulen skifter retning.
    Lene skriver febrilsk på tavlen: \"HVIS VI IKKE TØMMER FADET FØR KL 17, BLIVER VI HER FOR EVIGT.\"

    Alle mand til pumperne. Dette er en kritisk deployering.", + "uid": "44cc8a42-a51d-5886-b46a-ad7d22b2acaf" + }, + { + "title": "FB: System Reboot / Shut Down", + "hint": "GENSTART", + "story": "OFFICIELT FORMÅL:
    • Lukning af systemer før weekend

    LOG 17:01:
    Lyset blinkede én gang.
    Java Hulen er bare et mødelokale igen. Ingen øl. Ingen mystik.

    Lars Erik står med en kaffekop, der er rygende varm, selvom han drak kold pilsner for 2 minutter siden.
    På whiteboardet står der med utydelig håndskrift:
    \"Tak for testen. Vi ses i næste uge. Hilsen Morten...ne.\"", + "uid": "5b8c5211-5f89-513b-8777-25cfff347d1d" + } + ] +} diff --git a/stories/sagen_om_koglepen.json b/stories/sagen_om_koglepen.json new file mode 100644 index 0000000..c2b3db9 --- /dev/null +++ b/stories/sagen_om_koglepen.json @@ -0,0 +1,133 @@ +{ + "meta": { + "name": "Sagen om Kuglepennen (Case #734)", + "theme_color": "#0e2a36", + "text_color": "#d1d1d1", + "bg_color": "#050f14", + "font": "Georgia, serif", + "organizer": "External Investigator Z", + "log_prefix": "EVIDENCE" + }, + "events": [ + { + "title": "FB - Ekstern revisionsstart", + "hint": "ANKOMST", + "story": "OFFICIELT FORMÅL:
    • Opstart af inventar-undersøgelse
    • Identificering af nøglepersoner

    LOG #001:
    Jeg ankom til CGI Aalborg kl. 14:58. Regnen udenfor faldt ikke nedad, men... sidelæns.
    Opgaven er simpel: Find en stjålet kuglepen (mærke: 'Parker', model: 'Jotter').
    Døren gik op før jeg rørte håndtaget. Tapper stod bag baren. Han blinkede ikke.", + "uid": "bd2867d9-5662-5ad0-90b5-0e6aa7fa8931" + }, + { + "title": "FB - Indledende interessent-analyse", + "hint": "LASSE", + "story": "OFFICIELT FORMÅL:
    • Kortlægning af vidner

    LOG #002:
    Har afhørt subjektet Lasse. Han hævder, han ikke har set kuglepennen.
    Men mens han talte, tegnede hans fingre komplekse geometriske figurer i kondensen på hans ølglas.
    Han hviskede: \"Den skriver ikke med blæk, Z. Den skriver med tid.\"
    Jeg noterer ham som 'mistænkelig'.", + "uid": "bf5d58e7-9c8b-5b9c-9a95-9f56f96393b0" + }, + { + "title": "FB - Materiel beholdningskontrol", + "hint": "TOM", + "story": "OFFICIELT FORMÅL:
    • Fysisk gennemgang af aktiver

    LOG #003:
    Mødte Tom ved kaffemaskinen. Han tilbød mig en kop \"sort væske\".
    Væsken var kold, men dampen steg stadig op.
    Tom grinede nervøst: \"Vi mistede ikke pennen. Vi slap den fri.\"
    Hvad gemmer de her mennesker i deres Outlook-kalendere?", + "uid": "f75d3d7f-33aa-5b1e-9941-04899b422146" + }, + { + "title": "FB - Netværksinfrastruktur Audit", + "hint": "MARTIN", + "story": "OFFICIELT FORMÅL:
    • Gennemgang af kabling og forbindelser

    LOG #004:
    Fandt Martin i serverrummet (eller var det toilettet? Rummet skifter form).
    Han stirrede ind i et netværksstik.
    \"Kan du høre det, detektiv?\" spurgte han. \"Kuglepennen kradser i firewallen.\"
    Jeg har brug for en drink. En stærk en.", + "uid": "b25a002a-db05-5293-a62e-9c18952b7d62" + }, + { + "title": "FB - Kapacitetsvurdering (Mental)", + "hint": "MATHIAS", + "story": "OFFICIELT FORMÅL:
    • Stress-test af systemet

    LOG #005:
    Mathias sad i et hjørne, der var mørkere end belysningen tillader.
    Han holdt en notesblok, men ingen pen.
    \"Den valgte Asger,\" mumlede han.
    Da jeg spurgte hvad han mente, pegede han bare mod Limfjorden.", + "uid": "71f3b982-da8c-5476-9377-4215f48a1ff9" + }, + { + "title": "FB - Strategisk Vidensdeling", + "hint": "ASGER", + "story": "OFFICIELT FORMÅL:
    • Vidensdeling på senior-niveau

    LOG #006:
    Konfronterede Asger. Han virkede rolig. For rolig.
    Han talte om \"Legacy Systemer\" på en måde, der lød som om han mente \"Ældgamle Guder\".
    \"Pennen er et interface, Z. Du kan ikke bare 'finde' den. Du skal logge ind.\"", + "uid": "135fb9b9-a1ef-5370-a162-1a886c0da5fa" + }, + { + "title": "FB - Brugeroplevelses-optimering", + "hint": "JACOB", + "story": "OFFICIELT FORMÅL:
    • UX Review

    LOG #007:
    Jacob kom løbende. Han var bleg.
    Han påstod, at han havde set kuglepennen svæve over billardbordet.
    \"Kuglerne trillede ikke,\" sagde han. \"De... vibrerede.\"
    Jeg begynder at tro, at dette ikke er en almindelig tyverisag.", + "uid": "a330fd4b-9f64-544f-9d48-d27835abe347" + }, + { + "title": "FB - Logistik og Forsyning", + "hint": "TAPPER", + "story": "OFFICIELT FORMÅL:
    • Genopfyldning af lagre

    LOG #008:
    Gik tilbage til baren. Tapper pudsede et glas. Det samme glas som for en time siden.
    Det skreg, da kluden rørte det.
    \"Vil du have en IPA?\" spurgte han. \"Den er brygget på vand fra R'lyeh.\"
    Jeg takkede nej. Jeg holder mig til pilsner.", + "uid": "315d69d3-2648-5e2a-82c6-b77976d1a9f4" + }, + { + "title": "FB - Tværgående Alignment", + "hint": "SKRIFTEN", + "story": "OFFICIELT FORMÅL:
    • Koordinering på tværs af siloer

    LOG #009:
    Der er skrift på væggen bag dartskiven. Det er skrevet med den savnede pen.
    Teksten er på C# men variablerne er navne på dæmoner.
    Martin siger, det kompilerer uden fejl.", + "uid": "6b2df9ce-218e-5be4-bd1f-2e0c80e615c6" + }, + { + "title": "FB - Risikovurdering", + "hint": "LYDEN", + "story": "OFFICIELT FORMÅL:
    • Vurdering af operationelle risici

    LOG #010:
    Lyden i lokalet har ændret sig. Muzakken er erstattet af en lavfrekvent brummen.
    Lasse og Tom står og nikker i takt til den.
    Det er lyden af en kuglepen, der klikkes ind og ud. Igen og igen. Uendeligt højt.", + "uid": "a0e9675a-34ec-5a50-ae5f-4f2000c2a0d8" + }, + { + "title": "FB - Compliance Check (Okkult)", + "hint": "REGLERNE", + "story": "OFFICIELT FORMÅL:
    • Sikring af overholdelse af standarder

    LOG #011:
    Fandt en proceduremanual under sofaen.
    Regel 1: \"Spørg ikke om pennen.\"
    Regel 2: \"Hvis Asger begynder at chante, så chant med.\"
    Regel 3: \"Fredagsbaren slutter aldrig. Vi pauser den kun.\"", + "uid": "25b80771-6343-5074-acad-b1463890ffc4" + }, + { + "title": "FB - System Integration Test", + "hint": "Sammensmeltning", + "story": "OFFICIELT FORMÅL:
    • Test af systemernes samspil

    LOG #012:
    Mathias har nu blæk på hænderne. Han rørte ved ingenting.
    Han siger, at pennen skriver historien om os alle sammen lige nu.
    \"Jeg er bare en bi-karakter i referatet,\" græd han.", + "uid": "d212adcc-5d00-5d9e-a53f-defc2cf7009d" + }, + { + "title": "FB - Legacy System Migration", + "hint": "FORTIDEN", + "story": "OFFICIELT FORMÅL:
    • Flytning af data fra gamle systemer

    LOG #013:
    Lyset gik ud. Da nødgeneratoren startede, var vi ikke længere i Aalborg.
    Uden for vinduet var der kun hav. Grønt, oprørt hav.
    Jacob kiggede ud: \"CGI Atlantis afdelingen... de har også fredagsbar.\"", + "uid": "da5a5ed4-d072-589a-8a72-a4777f816628" + }, + { + "title": "FB - Performance Review", + "hint": "DOMMEN", + "story": "OFFICIELT FORMÅL:
    • Individuel evaluering

    LOG #014:
    Tapper kaldte mig op til baren.
    Han lagde en genstand på disken. En Parker Jotter.
    Den pulserede.
    \"Den er ikke stjålet,\" sagde Tapper med en stemme som knust grus. \"Den ventede bare på en ny bruger.\"", + "uid": "236a2354-b293-582d-bafc-2621001f1b32" + }, + { + "title": "FB - Kontraktforhandling", + "hint": "UNDERSKRIFT", + "story": "OFFICIELT FORMÅL:
    • Finalisering af aftaler

    LOG #015:
    Alle kigger på mig. Lasse, Tom, Martin, Mathias, Asger, Jacob.
    De danner en halvcirkel.
    Asger rækker mig et stykke papir. Det ligner en timeregistrering, men feltet for 'Timer' er uendeligt.
    \"Skriv under, Z.\"", + "uid": "c9f7554d-b5b5-56b1-acf1-ec7083dbd578" + }, + { + "title": "FB - Implementeringsfase", + "hint": "OPTIVELSE", + "story": "OFFICIELT FORMÅL:
    • Idriftsættelse

    LOG #016:
    Jeg tog pennen. Den var varm som kød.
    Da jeg rørte papiret, hørte jeg ikke ridsen af kugle mod papir, men et skrig.
    Mit eget skrig? Eller pennens?", + "uid": "89bcc0ae-122d-51c3-ab0f-64e3130d87b1" + }, + { + "title": "FB - Kvalitetssikring", + "hint": "SLØRET", + "story": "OFFICIELT FORMÅL:
    • QA af leverancen

    LOG #017:
    Verden er blevet skarpere. Farverne er... anderledes.
    Tom har lige fortalt en vittighed, jeg hørte for 1000 år siden.
    Vi lo. Vi lo så tænderne raslede.", + "uid": "69ad60fa-cf13-577d-a83a-dd857f629671" + }, + { + "title": "FB - Driftsstabilisering", + "hint": "GLEMSEL", + "story": "OFFICIELT FORMÅL:
    • Sikring af stabil drift

    LOG #???
    Hvad ledte jeg efter? En blyant? En mus?
    Det betyder ikke noget.
    Tapper skænkede mig en ny øl. Den smager af jern og stjerner.", + "uid": "d3d54e76-3ec7-5610-862d-829700238f20" + }, + { + "title": "FB - Arkivering af Sagsakter", + "hint": "LUKKET", + "story": "OFFICIELT FORMÅL:
    • Lukning af sagen

    LOG FINAL:
    Sagen er lukket. Ingen uregelmæssigheder fundet.
    Detektiv Z eksisterer ikke længere som ekstern konsulent.
    Jeg er blevet onboardet.", + "uid": "dddc2291-efd8-5104-9031-7e3cb9b3db9d" + }, + { + "title": "FB - Gentagende begivenhed", + "hint": "EVIGT", + "story": "OFFICIELT FORMÅL:
    • Fastlæggelse af næste uges møde

    STATUS:
    Vi ses næste fredag.
    Og næste.
    Og næste.
    Ph'nglui mglw'nafh Cthulhu Aalborg wgah'nagl fhtagn.", + "uid": "4e8f196f-2e97-50e3-b212-3c4ef46d902f" + } + ] +} diff --git a/stories/sagen_om_konsulent_n.json b/stories/sagen_om_konsulent_n.json index 8df581e..1162f25 100644 --- a/stories/sagen_om_konsulent_n.json +++ b/stories/sagen_om_konsulent_n.json @@ -12,102 +12,122 @@ { "title": "Ugentlig tværgående synkronisering", "hint": "AFVIGELSE", - "story": "OFFICIELT FORMÅL:
    • Hurtig synk af ugens løse ender
    • Ingen forberedelse nødvendig (seriøst)

    FORTROLIGT UDDRAG:
    Vi har en afvigelse i kalenderdomænet. Konsulent “N.” har stået som *OPTAGET* siden sidste fredag.
    Der findes ingen mødeindkaldelse i historikken — kun en gentagelse.

    Bemærkning:
    Hvis du hører Outlook-påmindelsen uden at have en påmindelse: ignorer den, og mød op alligevel.
    " + "story": "OFFICIELT FORMÅL:
    • Hurtig synk af ugens løse ender
    • Ingen forberedelse nødvendig (seriøst)

    FORTROLIGT UDDRAG:
    Vi har en afvigelse i kalenderdomænet. Konsulent “N.” har stået som *OPTAGET* siden sidste fredag.
    Der findes ingen mødeindkaldelse i historikken — kun en gentagelse.

    Bemærkning:
    Hvis du hører Outlook-påmindelsen uden at have en påmindelse: ignorer den, og mød op alligevel.
    ", + "uid": "b6fd22f2-0311-445e-bf42-8e31f9cad0a9" }, { "title": "Obligatorisk sjov", "hint": "BADGE", - "story": "OFFICIELT FORMÅL:
    • “Teambuilding” uden slide deck
    • Interessentpleje (humør)

    FORTROLIGT UDDRAG:
    Receptionen fandt et adgangskort med teksten: “KONSULENT N.” på gulvet ved køleskabet.
    Kortet var lunt, som om det lige var blevet brugt — men ingen har registreret adgang.

    Til stedeværende anbefales:
    Smil normalt. Lad være med at sige ordet “gentagelse” højt før kl. 15:05.
    " + "story": "OFFICIELT FORMÅL:
    • “Teambuilding” uden slide deck
    • Interessentpleje (humør)

    FORTROLIGT UDDRAG:
    Receptionen fandt et adgangskort med teksten: “KONSULENT N.” på gulvet ved køleskabet.
    Kortet var lunt, som om det lige var blevet brugt — men ingen har registreret adgang.

    Til stedeværende anbefales:
    Smil normalt. Lad være med at sige ordet “gentagelse” højt før kl. 15:05.
    ", + "uid": "fe8114b0-cbed-4645-8599-cde5bdc9679c" }, { "title": "Afsluttende statusmøde (uformelt)", "hint": "SIGNAL", - "story": "OFFICIELT FORMÅL:
    • Uformel status og weekendoverdragelse
    • Opsamling på “wins”

    FORTROLIGT UDDRAG:
    Kl. 15:00 præcis kom der et systemevent:
    “Your meeting has been updated.”
    Ingen opdaterede noget.

    Kort efter: en enkelt linje i beskrivelsesfeltet (nu slettet):
    “…jeg kan høre isterningerne, men jeg kan ikke nå dem.”" + "story": "OFFICIELT FORMÅL:
    • Uformel status og weekendoverdragelse
    • Opsamling på “wins”

    FORTROLIGT UDDRAG:
    Kl. 15:00 præcis kom der et systemevent:
    “Your meeting has been updated.”
    Ingen opdaterede noget.

    Kort efter: en enkelt linje i beskrivelsesfeltet (nu slettet):
    “…jeg kan høre isterningerne, men jeg kan ikke nå dem.”", + "uid": "bf2d0a93-fd77-42cb-b7d9-5a6d1728f134" }, { "title": "Kvalitativ erfaringsudveksling", "hint": "NOTE", - "story": "OFFICIELT FORMÅL:
    • Del en ting der virkede denne uge
    • Del en ting der ikke gjorde

    FORTROLIGT UDDRAG:
    Der cirkulerer en printet mødeagenda med håndskrift nederst:

    “Hvis I læser dette, så er jeg ikke forsvundet.
    Jeg er bare blevet flyttet til et lokale, der kun findes om fredagen.”

    Papiret lugter af citrus og… mødelokale-tæppe." + "story": "OFFICIELT FORMÅL:
    • Del en ting der virkede denne uge
    • Del en ting der ikke gjorde

    FORTROLIGT UDDRAG:
    Der cirkulerer en printet mødeagenda med håndskrift nederst:

    “Hvis I læser dette, så er jeg ikke forsvundet.
    Jeg er bare blevet flyttet til et lokale, der kun findes om fredagen.”

    Papiret lugter af citrus og… mødelokale-tæppe.", + "uid": "e000454e-3359-4fe9-8c5a-89dd86194e29" }, { "title": "Strategisk afrunding af arbejdsugen", "hint": "INDDÆMNING", - "story": "OFFICIELT FORMÅL:
    • Strategisk afrunding (aka: “god weekend”)

    FORTROLIGT UDDRAG:
    IT har forsøgt at “aflyse serien”.
    Kalenderen genopretter den inden for 9 sekunder.

    Foreløbig inddæmningsprocedure:
    • Indkaldelsen må ikke slettes
    • Deltagere skal møde fysisk op
    • Der må gerne medbringes snacks (observationsmæssigt stabiliserende)" + "story": "OFFICIELT FORMÅL:
    • Strategisk afrunding (aka: “god weekend”)

    FORTROLIGT UDDRAG:
    IT har forsøgt at “aflyse serien”.
    Kalenderen genopretter den inden for 9 sekunder.

    Foreløbig inddæmningsprocedure:
    • Indkaldelsen må ikke slettes
    • Deltagere skal møde fysisk op
    • Der må gerne medbringes snacks (observationsmæssigt stabiliserende)", + "uid": "867282a6-2da5-4773-84d2-35dac2ac2f01" }, { "title": "Tværfaglig alignment-session", "hint": "STØTTE", - "story": "OFFICIELT FORMÅL:
    • Let alignment på tværs (ingen action items)

    FORTROLIGT UDDRAG:
    HR, IT og Facilities blev spurgt, om der findes et “Mødelokale 4B (uofficielt)”.
    Facilities svarede: “Det gør der ikke.”
    Så tilføjede de: “Men døren står nogle gange på klem om fredagen.”

    Anbefaling:
    Kom som du er. Tag evt. en ekstra ven med. (Flere vidner = færre glitch).
    " + "story": "OFFICIELT FORMÅL:
    • Let alignment på tværs (ingen action items)

    FORTROLIGT UDDRAG:
    HR, IT og Facilities blev spurgt, om der findes et “Mødelokale 4B (uofficielt)”.
    Facilities svarede: “Det gør der ikke.”
    Så tilføjede de: “Men døren står nogle gange på klem om fredagen.”

    Anbefaling:
    Kom som du er. Tag evt. en ekstra ven med. (Flere vidner = færre glitch).
    ", + "uid": "dee99abf-127a-48c5-af54-e80071f5e120" }, { "title": "Ad hoc interessentdialog", "hint": "KØLESKAB", - "story": "OFFICIELT FORMÅL:
    • Løst og socialt touchpoint

    FORTROLIGT UDDRAG:
    Ny interessent identificeret: Køleskabet.
    Det afgiver en svag brummen i en rytme, der minder om en mødepåmindelse.

    Ved forsøg på åbning kl. 14:59:
    • håndtag koldt
    • håndtag “giver sig” først efter en skål

    Hypotese:
    Køleskabet reagerer på ritualiseret fredagsbar-adfærd." + "story": "OFFICIELT FORMÅL:
    • Løst og socialt touchpoint

    FORTROLIGT UDDRAG:
    Ny interessent identificeret: Køleskabet.
    Det afgiver en svag brummen i en rytme, der minder om en mødepåmindelse.

    Ved forsøg på åbning kl. 14:59:
    • håndtag koldt
    • håndtag “giver sig” først efter en skål

    Hypotese:
    Køleskabet reagerer på ritualiseret fredagsbar-adfærd.", + "uid": "1e74e9e9-2ed5-42be-8489-b9a9e2d6d080" }, { "title": "Intern kapacitetsudligning", "hint": "KAPACITET", - "story": "OFFICIELT FORMÅL:
    • Afbalancering af ugens energiniveau

    FORTROLIGT UDDRAG:
    Vi har brug for kapacitetsplanlægning:
    • 1 person: isterninger
    • 1 person: musik
    • 1 person: “spørg ikke hvorfor, bare gør det” (koordinator)

    Konsulent N. er muligvis låst til en times varighed ad gangen.
    Hvis nogen forlader før tid, bliver rummet… mindre." + "story": "OFFICIELT FORMÅL:
    • Afbalancering af ugens energiniveau

    FORTROLIGT UDDRAG:
    Vi har brug for kapacitetsplanlægning:
    • 1 person: isterninger
    • 1 person: musik
    • 1 person: “spørg ikke hvorfor, bare gør det” (koordinator)

    Konsulent N. er muligvis låst til en times varighed ad gangen.
    Hvis nogen forlader før tid, bliver rummet… mindre.", + "uid": "7f04c11d-c39a-4869-bb7e-56d051210f6d" }, { "title": "Letvægts-retrospektiv", "hint": "RETRO", - "story": "OFFICIELT FORMÅL:
    • Hvad skal vi gøre mere af? Mindre af?

    FORTROLIGT UDDRAG:
    Root cause er sandsynligvis denne handling:
    Konsulent N. trykkede “Accepter alle forekomster” uden at læse beskrivelsen.

    Der findes nu en gentagende mødeserie, som opfører sig som en beholder.
    Og N. er “i mødet” — ikke i bygningen." + "story": "OFFICIELT FORMÅL:
    • Hvad skal vi gøre mere af? Mindre af?

    FORTROLIGT UDDRAG:
    Root cause er sandsynligvis denne handling:
    Konsulent N. trykkede “Accepter alle forekomster” uden at læse beskrivelsen.

    Der findes nu en gentagende mødeserie, som opfører sig som en beholder.
    Og N. er “i mødet” — ikke i bygningen.", + "uid": "70ad1e5f-093d-4e7c-b24c-990bcb240f14" }, { "title": "Uformel leverancegennemgang", "hint": "NØGLE", - "story": "OFFICIELT FORMÅL:
    • Gennemgang af ugens “leverancer” (uformelt)

    FORTROLIGT UDDRAG:
    Lost & Found indeholdt én genstand, der ikke burde være der:
    En nøgle mærket “FB-LOKAL” i en plastikpose.
    Posen var dateret næste fredag.

    Bemærkning:
    Tid opfører sig dårligt i nærheden af serien.
    Vi tester nøglen ved første skål.
    " + "story": "OFFICIELT FORMÅL:
    • Gennemgang af ugens “leverancer” (uformelt)

    FORTROLIGT UDDRAG:
    Lost & Found indeholdt én genstand, der ikke burde være der:
    En nøgle mærket “FB-LOKAL” i en plastikpose.
    Posen var dateret næste fredag.

    Bemærkning:
    Tid opfører sig dårligt i nærheden af serien.
    Vi tester nøglen ved første skål.
    ", + "uid": "3e347783-cda3-4d7b-b8db-5250de8c3268" }, { "title": "Operationel efterbearbejdning", "hint": "EKKO", - "story": "OFFICIELT FORMÅL:
    • Efterbearbejdning af ugen (lavt gear)

    FORTROLIGT UDDRAG:
    Når glas rammer glas, kommer der et ekko, der ikke passer til rummet.
    Ekkoet lyder som nogen der prøver at sige:
    “…er I her…?”

    Hvis du hører ekkoet:
    Svar ikke direkte. Skål bare igen. (Det virker mere stabilt)." + "story": "OFFICIELT FORMÅL:
    • Efterbearbejdning af ugen (lavt gear)

    FORTROLIGT UDDRAG:
    Når glas rammer glas, kommer der et ekko, der ikke passer til rummet.
    Ekkoet lyder som nogen der prøver at sige:
    “…er I her…?”

    Hvis du hører ekkoet:
    Svar ikke direkte. Skål bare igen. (Det virker mere stabilt).", + "uid": "02e1fc1b-4ac0-4384-a6b0-90221605ebac" }, { "title": "Tværorganisatorisk vidensdeling", "hint": "SITES", - "story": "OFFICIELT FORMÅL:
    • Vidensdeling (uformel)

    FORTROLIGT UDDRAG:
    Andre kontorer rapporterer lignende:
    “En fredagsbar-invite der bliver ved med at vende tilbage.”
    De kalder det: “Den Tilbagevendende Invitation”.

    Fælles observation:
    Når folk møder op og hygger, falder antallet af fejl i kalenderen midlertidigt." + "story": "OFFICIELT FORMÅL:
    • Vidensdeling (uformel)

    FORTROLIGT UDDRAG:
    Andre kontorer rapporterer lignende:
    “En fredagsbar-invite der bliver ved med at vende tilbage.”
    De kalder det: “Den Tilbagevendende Invitation”.

    Fælles observation:
    Når folk møder op og hygger, falder antallet af fejl i kalenderen midlertidigt.", + "uid": "f8e736ee-c9b0-4a64-88cd-41e914b33656" }, { "title": "Kalibreringsmøde (lav intensitet)", "hint": "TÆRSKEL", - "story": "OFFICIELT FORMÅL:
    • Kalibrering uden intensitet

    FORTROLIGT UDDRAG:
    Kalibreringsparameter fundet:
    Temperatur.
    Hvis køleskabet når præcis “fredagskoldt”, bliver lyden i rummet klarere.

    Strengt forbudt:
    0,0% øl omtalt som “sikkert alternativ”.
    Det gjorde rummet… meget stille sidst." + "story": "OFFICIELT FORMÅL:
    • Kalibrering uden intensitet

    FORTROLIGT UDDRAG:
    Kalibreringsparameter fundet:
    Temperatur.
    Hvis køleskabet når præcis “fredagskoldt”, bliver lyden i rummet klarere.

    Strengt forbudt:
    0,0% øl omtalt som “sikkert alternativ”.
    Det gjorde rummet… meget stille sidst.", + "uid": "b73190a0-0e24-4985-b8ab-1e9c5a5313e6" }, { "title": "Afrunding af ugens initiativer", "hint": "ÅBNING", - "story": "OFFICIELT FORMÅL:
    • Afrunding af igangværende initiativer

    FORTROLIGT UDDRAG:
    Nøglen passer i en dør, der normalt ikke har nøglehul.
    Døren dukker op bag kaffemaskinen præcis 15:02.

    På den anden side:
    Et lokale med projektor.
    Projektoren viser kun én slide:
    “STATUS: OPTAGET”" + "story": "OFFICIELT FORMÅL:
    • Afrunding af igangværende initiativer

    FORTROLIGT UDDRAG:
    Nøglen passer i en dør, der normalt ikke har nøglehul.
    Døren dukker op bag kaffemaskinen præcis 15:02.

    På den anden side:
    Et lokale med projektor.
    Projektoren viser kun ét slide:
    “STATUS: OPTAGET”", + "uid": "f9a29201-fab2-4aab-91fc-e6417918c2a5" }, { "title": "Intern koordinering uden agenda", "hint": "STILHED", - "story": "OFFICIELT FORMÅL:
    • Koordinering uden agenda

    FORTROLIGT UDDRAG:
    Vi opdagede at en agenda gør det værre.
    Når nogen skriver “Agenda:” i rummet, forsvinder døren igen.

    Så:
    • Ingen agenda
    • Ingen referat
    • Ingen action items
    Kun fredagsbar.

    (Det føles næsten for corporate til at virke. Men det virker.)" + "story": "OFFICIELT FORMÅL:
    • Koordinering uden agenda

    FORTROLIGT UDDRAG:
    Vi opdagede at en agenda gør det værre.
    Når nogen skriver “Agenda:” i rummet, forsvinder døren igen.

    Så:
    • Ingen agenda
    • Ingen referat
    • Ingen action items
    Kun fredagsbar.

    (Det føles næsten for corporate til at virke. Men det virker.)", + "uid": "b2d86cb7-d5b9-4ddc-9cde-7b53355dfb3c" }, { "title": "Eksperimentel samarbejdsramme", "hint": "RITUAL", - "story": "OFFICIELT FORMÅL:
    • Eksperimentel ramme (aka: prøv noget)

    FORTROLIGT UDDRAG:
    Nyt retrieval-eksperiment:
    Kl. 15:07 foretages en synkroniseret skål.
    Efterfulgt af: “God weekend” sagt én gang — ikke to.

    Da vi testede det, kom der en notifikation:
    “Konsulent N. forsøger at deltage.”" + "story": "OFFICIELT FORMÅL:
    • Eksperimentel ramme (aka: prøv noget)

    FORTROLIGT UDDRAG:
    Nyt retrieval-eksperiment:
    Kl. 15:07 foretages en synkroniseret skål.
    Efterfulgt af: “God weekend” sagt én gang — ikke to.

    Da vi testede det, kom der en notifikation:
    “Konsulent N. forsøger at deltage.”", + "uid": "47462f0d-b093-477d-931e-479438f1b2cf" }, { "title": "Socialt orienteret statusafstemning", "hint": "PULS", - "story": "OFFICIELT FORMÅL:
    • Social statusafstemning (hvordan går det egentlig?)

    FORTROLIGT UDDRAG:
    Lyden fra “mødelokalet på den anden side” har nu en puls.
    Den synker, når nogen griner.
    Den stiger, når nogen siger “lige hurtigt”.

    Vi kan muligvis trække N. tættere på ved at gøre rummet… menneskeligt." + "story": "OFFICIELT FORMÅL:
    • Social statusafstemning (hvordan går det egentlig?)

    FORTROLIGT UDDRAG:
    Lyden fra “mødelokalet på den anden side” har nu en puls.
    Den synker, når nogen griner.
    Den stiger, når nogen siger “lige hurtigt”.

    Vi kan muligvis trække N. tættere på ved at gøre rummet… menneskeligt.", + "uid": "5716d05a-585b-4351-ad3f-513c35727718" }, { "title": "Procesmæssig nedlukning af ugen", "hint": "PORTAL", - "story": "OFFICIELT FORMÅL:
    • Luk ugen ned på den rigtige måde

    FORTROLIGT UDDRAG:
    Portalen er stabil i cirka 58 minutter.
    Ved 59:30 begynder projektoren at vise “MØDET ER FORLÆNGET”.

    Hvis vi ikke lukker rigtigt:
    Serien udvider sig med et ekstra “opfølgningsmøde”.
    Ingen ønsker et follow-up til fredagsbar. Ingen." + "story": "OFFICIELT FORMÅL:
    • Luk ugen ned på den rigtige måde

    FORTROLIGT UDDRAG:
    Portalen er stabil i cirka 58 minutter.
    Ved 59:30 begynder projektoren at vise “MØDET ER FORLÆNGET”.

    Hvis vi ikke lukker rigtigt:
    Serien udvider sig med et ekstra “opfølgningsmøde”.
    Ingen ønsker et follow-up til fredagsbar. Ingen.", + "uid": "9f0f35c4-7aa0-4aca-8406-9ab3dda97022" }, { "title": "Konsensusbaseret afrunding", "hint": "BESLUTNING", - "story": "OFFICIELT FORMÅL:
    • Konsensusbaseret afrunding (enighed + hygge)

    FORTROLIGT UDDRAG:
    Vi skal beslutte:
    Åbner vi køleskabet helt?

    Sidste gang blev døren på klem, og en hånd (med et tastatur-mærke) nåede ud.
    Den slap en Post-it:
    “Jeg er her. Jeg er stadig optaget.”

    Konsensus kræver tilstedeværelse. (Og måske chips)." + "story": "OFFICIELT FORMÅL:
    • Konsensusbaseret afrunding (enighed + hygge)

    FORTROLIGT UDDRAG:
    Vi skal beslutte:
    Åbner vi køleskabet helt?

    Sidste gang blev døren på klem, og en hånd (med et tastatur-mærke) nåede ud.
    Den slap en Post-it:
    “Jeg er her. Jeg er stadig optaget.”

    Konsensus kræver tilstedeværelse. (Og måske chips).", + "uid": "7cf2d01a-94e6-480c-87c3-20def1316ad7" }, { "title": "Frivillig deltagelse i fælles kontekst", "hint": "HJEMKOMST", - "story": "OFFICIELT FORMÅL:
    • Frivillig deltagelse, fælles kontekst, afslappet

    FORTROLIGT UDDRAG:
    Hvis retrieval lykkes, vil N. fremstå normal.
    Undtagen:
    • Hans Outlook vil muligvis vise 1900 ulæste reminders
    • Han vil reagere på ordet “gentagelse” som på en høj lyd

    Afsluttende note (fra N., modtaget som kalenderopdatering):
    “Tak. Bliv ved med at møde op. Det er sådan man holder virkeligheden på plads om fredagen.”
    " + "story": "OFFICIELT FORMÅL:
    • Frivillig deltagelse, fælles kontekst, afslappet

    FORTROLIGT UDDRAG:
    Hvis retrieval lykkes, vil N. fremstå normal.
    Undtagen:
    • Hans Outlook vil muligvis vise 1900 ulæste reminders
    • Han vil reagere på ordet “gentagelse” som på en høj lyd

    Afsluttende note (fra N., modtaget som kalenderopdatering):
    “Tak. Bliv ved med at møde op. Det er sådan man holder virkeligheden på plads om fredagen.”
    ", + "uid": "fd74a09e-f082-4605-b00e-ddf73c172d5d" } ] -} \ No newline at end of file +} diff --git a/stories/template.json b/stories/template.json new file mode 100644 index 0000000..1f7474b --- /dev/null +++ b/stories/template.json @@ -0,0 +1,23 @@ +{ + "meta": { + "name": "Example Story Name", + "theme_color": "#000000", + "text_color": "#111111", + "bg_color": "#f0f0f0", + "font": "Arial, sans-serif", + "organizer": "Example Organizer", + "log_prefix": "LOG" + }, + "events": [ + { + "title": "TITLE[1]", + "hint": "OPTIONAL_CODE", + "story": "HTML CONTENT FOR ENTRY 1" + }, + { + "title": "TITLE[2]", + "hint": "OPTIONAL_CODE", + "story": "HTML CONTENT FOR ENTRY 2" + } + ] +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..38bb211 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test package.""" diff --git a/tests/test_cli_options.py b/tests/test_cli_options.py new file mode 100644 index 0000000..f532e30 --- /dev/null +++ b/tests/test_cli_options.py @@ -0,0 +1,126 @@ +import json +import os +import tempfile +import unittest + +import generator + + +class CliOptionsTest(unittest.TestCase): + def test_output_dir_and_duration_minutes(self) -> None: + config_date = "2026-02-06" + config_time = "15:00" + story_data = { + "meta": {"name": "CLI Options Story", "log_prefix": "LOG"}, + "events": [ + {"id": "log_01", "title": "Kickoff", "story": "Hello"}, + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + with open("config.toml", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + f'start_date = "{config_date}"', + f'start_time = "{config_time}"', + "blocked_weeks = []", + "blocked_dates = []", + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + + story_path = os.path.join(tmpdir, "story.json") + with open(story_path, "w", encoding="utf-8") as f: + json.dump(story_data, f, ensure_ascii=False, indent=2) + + output_dir = "custom_output" + generator.generate_single_file( + story_path, + preview_html=True, + output_dir=output_dir, + duration_minutes=90, + ) + + slug = generator.sanitize_filename(story_data["meta"]["name"]) + preview_path = os.path.join(output_dir, f"PREVIEW_{slug}.html") + ics_path = os.path.join(output_dir, f"FULL_SERIES_{slug}.ics") + self.assertTrue(os.path.exists(preview_path)) + self.assertTrue(os.path.exists(ics_path)) + + with open(preview_path, "r", encoding="utf-8") as f: + preview_html = f.read() + + self.assertIn("15:00-16:30", preview_html) + finally: + os.chdir(previous_cwd) + + def test_config_path_override_and_timezone(self) -> None: + story_data = { + "meta": {"name": "CLI Config Story", "log_prefix": "LOG"}, + "events": [ + {"id": "log_01", "title": "Kickoff", "story": "Hello"}, + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + with open("config.toml", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + 'start_date = "2026-02-06"', + 'start_time = "15:00"', + "blocked_weeks = []", + "blocked_dates = []", + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + override_path = os.path.join(tmpdir, "override.toml") + with open(override_path, "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + 'start_date = "2026-02-06"', + 'start_time = "14:00"', + "blocked_weeks = []", + "blocked_dates = []", + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + + story_path = os.path.join(tmpdir, "story.json") + with open(story_path, "w", encoding="utf-8") as f: + json.dump(story_data, f, ensure_ascii=False, indent=2) + + output_dir = "custom_output" + generator.generate_single_file( + story_path, + preview_html=True, + output_dir=output_dir, + config_path=override_path, + timezone_name="UTC", + ) + + slug = generator.sanitize_filename(story_data["meta"]["name"]) + preview_path = os.path.join(output_dir, f"PREVIEW_{slug}.html") + self.assertTrue(os.path.exists(preview_path)) + + with open(preview_path, "r", encoding="utf-8") as f: + preview_html = f.read() + + self.assertIn("14:00-15:00", preview_html) + self.assertIn("(UTC)", preview_html) + finally: + os.chdir(previous_cwd) diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..ca95167 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,38 @@ +import unittest + +from validation import validate_config + + +class ConfigValidationTest(unittest.TestCase): + def test_optional_fields_must_be_strings(self) -> None: + config = { + "start_date": "2026-02-04", + "start_time": "15:00", + "repo_url": 123, + "organizer_email": ["not", "a", "string"], + "uid_namespace": {"bad": "type"}, + "blocked_weeks": [], + "blocked_dates": [], + "skip_day_after_ascension": False, + } + + with self.assertRaises(ValueError) as ctx: + validate_config(config) + + message = str(ctx.exception) + self.assertIn("repo_url skal være en streng", message) + self.assertIn("organizer_email skal være en streng", message) + self.assertIn("uid_namespace skal være en streng", message) + + def test_optional_fields_accept_none_or_missing(self) -> None: + config = { + "start_date": "2026-02-04", + "start_time": "15:00", + "blocked_weeks": [], + "blocked_dates": [], + "skip_day_after_ascension": False, + "repo_url": None, + } + + validated = validate_config(config) + self.assertEqual(validated["start_date"], "2026-02-04") diff --git a/tests/test_date_skipping.py b/tests/test_date_skipping.py new file mode 100644 index 0000000..04d0d8a --- /dev/null +++ b/tests/test_date_skipping.py @@ -0,0 +1,56 @@ +import unittest +from datetime import date + +from validation import validate_config +from generator import is_blocked_date + + +class DateSkippingTest(unittest.TestCase): + def test_blocked_week_is_skipped(self) -> None: + target_date = date(2026, 2, 6) + config = validate_config( + { + "start_date": "2026-02-04", + "start_time": "15:00", + "blocked_weeks": [target_date.isocalendar()[1]], + "blocked_dates": [], + "skip_day_after_ascension": False, + } + ) + + blocked, reason = is_blocked_date(target_date, {}, config) + self.assertTrue(blocked) + self.assertIn("Ferieuge", reason or "") + + def test_blocked_date_is_skipped(self) -> None: + target_date = date(2026, 2, 6) + config = validate_config( + { + "start_date": "2026-02-04", + "start_time": "15:00", + "blocked_weeks": [], + "blocked_dates": ["02-06"], + "skip_day_after_ascension": False, + } + ) + + blocked, reason = is_blocked_date(target_date, {}, config) + self.assertTrue(blocked) + self.assertEqual(reason, "Manuelt blokeret dato") + + def test_day_after_ascension_is_skipped(self) -> None: + target_date = date(2026, 5, 15) + config = validate_config( + { + "start_date": "2026-02-04", + "start_time": "15:00", + "blocked_weeks": [], + "blocked_dates": [], + "skip_day_after_ascension": True, + } + ) + holidays = {date(2026, 5, 14): "Kristi Himmelfart"} + + blocked, reason = is_blocked_date(target_date, holidays, config) + self.assertTrue(blocked) + self.assertEqual(reason, "Dagen efter Kr. Himmelfart") diff --git a/tests/test_html_injection.py b/tests/test_html_injection.py new file mode 100644 index 0000000..2c628ac --- /dev/null +++ b/tests/test_html_injection.py @@ -0,0 +1,25 @@ +import unittest + +from generator import inject_outlook_hacks + + +class HtmlInjectionTest(unittest.TestCase): + def test_html_injection_adds_folded_alt_desc(self) -> None: + ical_text = "\n".join( + [ + "BEGIN:VCALENDAR", + "BEGIN:VEVENT", + "UID:abc123", + "END:VEVENT", + "END:VCALENDAR", + ] + ) + long_html = "A" * 200 + result = inject_outlook_hacks(ical_text, {"abc123": long_html}) + + self.assertIn("X-ALT-DESC;FMTTYPE=text/html:", result) + self.assertIn("X-MICROSOFT-CDO-BUSYSTATUS:BUSY", result) + self.assertIn("TRANSP:OPAQUE", result) + alt_index = result.find("X-ALT-DESC;FMTTYPE=text/html:") + self.assertNotEqual(alt_index, -1) + self.assertIn("\r\n ", result[alt_index : alt_index + 200]) diff --git a/tests/test_no_color.py b/tests/test_no_color.py new file mode 100644 index 0000000..7c2f5f1 --- /dev/null +++ b/tests/test_no_color.py @@ -0,0 +1,60 @@ +import io +import json +import os +import tempfile +import unittest +from contextlib import redirect_stdout + +import generator + + +class NoColorTest(unittest.TestCase): + def test_disable_colors_removes_ansi_codes(self) -> None: + color_snapshot = { + name: getattr(generator.Colors, name) + for name in ("HEADER", "OKBLUE", "OKGREEN", "WARNING", "FAIL", "ENDC") + } + config_date = "2026-02-06" + config_time = "15:00" + story_data = { + "meta": {"name": "No Color Story", "log_prefix": "LOG"}, + "events": [ + {"id": "log_01", "title": "Kickoff", "story": "Hello"}, + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + with open("config.toml", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + f'start_date = "{config_date}"', + f'start_time = "{config_time}"', + "blocked_weeks = []", + "blocked_dates = []", + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + + story_path = os.path.join(tmpdir, "story.json") + with open(story_path, "w", encoding="utf-8") as f: + json.dump(story_data, f, ensure_ascii=False, indent=2) + + generator.disable_colors() + buffer = io.StringIO() + with redirect_stdout(buffer): + generator.generate_single_file(story_path) + + output = buffer.getvalue() + self.assertNotIn("\x1b[", output) + for name in ("HEADER", "OKBLUE", "OKGREEN", "WARNING", "FAIL", "ENDC"): + self.assertEqual(getattr(generator.Colors, name), "") + finally: + for name, value in color_snapshot.items(): + setattr(generator.Colors, name, value) + os.chdir(previous_cwd) diff --git a/tests/test_preview_html.py b/tests/test_preview_html.py new file mode 100644 index 0000000..c7751d5 --- /dev/null +++ b/tests/test_preview_html.py @@ -0,0 +1,132 @@ +import json +import os +import tempfile +import unittest +from datetime import datetime, timedelta + +import generator + + +class PreviewHtmlTest(unittest.TestCase): + def test_preview_html_output(self) -> None: + config_date = "2026-02-06" + config_time = "15:00" + story_data = { + "meta": { + "name": "Preview Test Story", + "log_prefix": "LOG", + }, + "events": [ + { + "id": "log_01", + "title": "Kickoff", + "story": "HELLO
    World", + } + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + with open("config.toml", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + f'start_date = "{config_date}"', + f'start_time = "{config_time}"', + 'repo_url = "https://example.com/repo"', + "blocked_weeks = []", + "blocked_dates = []", + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + + story_path = os.path.join(tmpdir, "story.json") + with open(story_path, "w", encoding="utf-8") as f: + json.dump(story_data, f, ensure_ascii=False, indent=2) + + generator.generate_single_file(story_path, preview_html=True) + + slug = generator.sanitize_filename(story_data["meta"]["name"]) + preview_path = os.path.join( + "fredagsbar_output", + f"PREVIEW_{slug}.html", + ) + self.assertTrue(os.path.exists(preview_path)) + + with open(preview_path, "r", encoding="utf-8") as f: + preview_html = f.read() + + expected_date = generator.next_or_same_friday( + datetime.strptime(config_date, "%Y-%m-%d").date() + ) + self.assertIn(expected_date.strftime("%Y-%m-%d"), preview_html) + self.assertIn("15:00-16:00", preview_html) + self.assertIn(story_data["events"][0]["title"], preview_html) + self.assertIn(story_data["events"][0]["story"], preview_html) + finally: + os.chdir(previous_cwd) + + def test_preview_includes_skipped_dates(self) -> None: + config_date = "2026-02-06" + config_time = "15:00" + story_data = { + "meta": { + "name": "Preview Skip Story", + "log_prefix": "LOG", + }, + "events": [ + { + "id": "log_01", + "title": "Kickoff", + "story": "HELLO
    World", + } + ], + } + + with tempfile.TemporaryDirectory() as tmpdir: + previous_cwd = os.getcwd() + os.chdir(tmpdir) + try: + with open("config.toml", "w", encoding="utf-8") as f: + f.write( + "\n".join( + [ + f'start_date = "{config_date}"', + f'start_time = "{config_time}"', + 'repo_url = "https://example.com/repo"', + "blocked_weeks = []", + 'blocked_dates = ["02-06"]', + "skip_day_after_ascension = false", + ] + ) + + "\n" + ) + + story_path = os.path.join(tmpdir, "story.json") + with open(story_path, "w", encoding="utf-8") as f: + json.dump(story_data, f, ensure_ascii=False, indent=2) + + generator.generate_single_file(story_path, preview_html=True) + + slug = generator.sanitize_filename(story_data["meta"]["name"]) + preview_path = os.path.join( + "fredagsbar_output", + f"PREVIEW_{slug}.html", + ) + self.assertTrue(os.path.exists(preview_path)) + + with open(preview_path, "r", encoding="utf-8") as f: + preview_html = f.read() + + start_date = datetime.strptime(config_date, "%Y-%m-%d").date() + expected_date = generator.next_or_same_friday(start_date) + timedelta(weeks=1) + self.assertIn("Skipped dates", preview_html) + self.assertIn(start_date.strftime("%Y-%m-%d"), preview_html) + self.assertIn("Manuelt blokeret dato", preview_html) + self.assertIn(expected_date.strftime("%Y-%m-%d"), preview_html) + finally: + os.chdir(previous_cwd) diff --git a/tests/test_uid_mapping.py b/tests/test_uid_mapping.py new file mode 100644 index 0000000..6f8e2a0 --- /dev/null +++ b/tests/test_uid_mapping.py @@ -0,0 +1,27 @@ +import unittest + +from validation import validate_uid_mapping + + +class UidMappingTest(unittest.TestCase): + def test_ambiguous_mapping_requires_ids(self) -> None: + events = [ + {"title": "Event A"}, + {"title": "Event B"}, + ] + + with self.assertRaises(ValueError) as ctx: + validate_uid_mapping(events, {"Event A"}, {2}) + + message = str(ctx.exception) + self.assertIn("Ambivalent UID-mapping", message) + self.assertIn("events[].id", message) + + def test_ambiguous_mapping_with_ids_returns_false(self) -> None: + events = [ + {"id": "log_01", "title": "Event A"}, + {"id": "log_02", "title": "Event B"}, + ] + + allow = validate_uid_mapping(events, {"Event A"}, {2}) + self.assertFalse(allow) diff --git a/validation.py b/validation.py new file mode 100644 index 0000000..500f783 --- /dev/null +++ b/validation.py @@ -0,0 +1,212 @@ +from datetime import datetime, time +from typing import Any, Optional + +DATE_FORMAT = "%Y-%m-%d" +TIME_FORMAT = "%H:%M" + + +def validate_config(config: dict[str, Any]) -> dict[str, Any]: + errors: list[str] = [] + + if not isinstance(config, dict): + raise ValueError("config skal være et JSON-objekt") + + start_date_value = config.get("start_date") + start_time_value = config.get("start_time") + + if not isinstance(start_date_value, str): + errors.append("start_date skal være en streng i format YYYY-MM-DD") + start_date_obj = None + else: + try: + start_date_obj = datetime.strptime(start_date_value, DATE_FORMAT).date() + except ValueError: + errors.append("start_date skal være i format YYYY-MM-DD") + start_date_obj = None + + if not isinstance(start_time_value, str): + errors.append("start_time skal være en streng i format HH:MM") + start_time_obj = None + else: + try: + t_hour, t_min = map(int, start_time_value.split(":")) + start_time_obj = time(t_hour, t_min) + except (ValueError, TypeError): + errors.append("start_time skal være i format HH:MM") + start_time_obj = None + + blocked_weeks = config.get("blocked_weeks", []) + if blocked_weeks is None: + blocked_weeks = [] + if not isinstance(blocked_weeks, list): + errors.append("blocked_weeks skal være en liste af heltal") + else: + invalid_weeks = [ + week for week in blocked_weeks + if not isinstance(week, int) or week < 1 or week > 53 + ] + if invalid_weeks: + errors.append( + "blocked_weeks skal indeholde heltal mellem 1 og 53: " + f"{invalid_weeks}" + ) + + blocked_dates = config.get("blocked_dates", []) + if blocked_dates is None: + blocked_dates = [] + if not isinstance(blocked_dates, list): + errors.append("blocked_dates skal være en liste af dato-strenge") + else: + invalid_dates = [] + blocked_full_dates: list[str] = [] + blocked_month_days: list[str] = [] + for date_value in blocked_dates: + if not isinstance(date_value, str): + invalid_dates.append(date_value) + continue + try: + date_obj = datetime.strptime(date_value, DATE_FORMAT).date() + except ValueError: + month_day = _parse_month_day(date_value) + if month_day: + blocked_month_days.append(month_day) + else: + invalid_dates.append(date_value) + else: + blocked_full_dates.append(date_obj.strftime(DATE_FORMAT)) + if invalid_dates: + errors.append( + "blocked_dates skal være i format YYYY-MM-DD eller MM-DD: " + f"{invalid_dates}" + ) + + if "skip_day_after_ascension" in config and not isinstance( + config["skip_day_after_ascension"], bool + ): + errors.append("skip_day_after_ascension skal være true/false") + + for key in ("repo_url", "organizer_email", "uid_namespace"): + if key in config and config[key] is not None and not isinstance(config[key], str): + errors.append(f"{key} skal være en streng") + + if errors: + raise ValueError("Konfigurationsfejl:\n- " + "\n- ".join(errors)) + + config["start_date_obj"] = start_date_obj + config["start_time_obj"] = start_time_obj + config["blocked_weeks"] = blocked_weeks + config["blocked_dates"] = blocked_full_dates + config["blocked_month_days"] = blocked_month_days + return config + + +def validate_story_data(story_data: Any) -> dict[str, Any]: + errors: list[str] = [] + + if not isinstance(story_data, dict): + raise ValueError("Story JSON skal være et objekt med meta og events") + + meta = story_data.get("meta") + if not isinstance(meta, dict): + errors.append("meta skal være et objekt") + else: + name = meta.get("name") + if not isinstance(name, str) or not name.strip(): + errors.append("meta.name skal være en ikke-tom streng") + for key in ("id", "theme_color", "text_color", "bg_color", "font", "log_prefix"): + value = meta.get(key) + if value is not None and not isinstance(value, str): + errors.append(f"meta.{key} skal være en streng hvis den er sat") + + events = story_data.get("events") + if not isinstance(events, list): + errors.append("events skal være en liste") + else: + for idx, event_entry in enumerate(events, start=1): + if not isinstance(event_entry, dict): + errors.append(f"events[{idx}] skal være et objekt") + continue + title = event_entry.get("title") + if not isinstance(title, str) or not title.strip(): + errors.append(f"events[{idx}].title skal være en ikke-tom streng") + story = event_entry.get("story") + if not isinstance(story, str) or not story.strip(): + errors.append(f"events[{idx}].story skal være en ikke-tom streng") + hint = event_entry.get("hint") + if hint is not None and not isinstance(hint, str): + errors.append(f"events[{idx}].hint skal være en streng hvis den er sat") + event_id = event_entry.get("id") + if event_id is not None and not isinstance(event_id, str): + errors.append(f"events[{idx}].id skal være en streng hvis den er sat") + uid = event_entry.get("uid") + if uid is not None and not isinstance(uid, str): + errors.append(f"events[{idx}].uid skal være en streng hvis den er sat") + + if errors: + raise ValueError("Historie-fejl:\n- " + "\n- ".join(errors)) + + return story_data + + +def _parse_month_day(value: str) -> Optional[str]: + parts = value.split("-") + if len(parts) != 2: + return None + if not all(part.isdigit() for part in parts): + return None + month = int(parts[0]) + day = int(parts[1]) + try: + datetime(2000, month, day) + except ValueError: + return None + return f"{month:02d}-{day:02d}" + + +def _events_missing_ids(events_data: list[dict[str, Any]]) -> list[int]: + missing: list[int] = [] + for idx, event_entry in enumerate(events_data, start=1): + uid_value = event_entry.get("uid") + if isinstance(uid_value, str): + uid_value = uid_value.strip() or None + if not event_entry.get("id") and not uid_value: + missing.append(idx) + return missing + + +def validate_uid_mapping( + events_data: list[dict[str, Any]], + duplicate_titles: set[str], + log_totals: set[int], +) -> bool: + ambiguous_reasons: list[str] = [] + + if duplicate_titles: + titles_list = ", ".join(sorted(duplicate_titles)) + ambiguous_reasons.append(f"duplikerede titler i eksisterende .ics ({titles_list})") + + if len(log_totals) > 1: + totals_list = ", ".join(str(total) for total in sorted(log_totals)) + ambiguous_reasons.append(f"flere log-totaler i eksisterende .ics ({totals_list})") + elif len(log_totals) == 1: + total = next(iter(log_totals)) + if total != len(events_data): + ambiguous_reasons.append( + "log-total matcher ikke antal events i story " + f"({total} != {len(events_data)})" + ) + + if not ambiguous_reasons: + return True + + missing = _events_missing_ids(events_data) + if missing: + missing_list = ", ".join(str(index) for index in missing) + raise ValueError( + "Ambivalent UID-mapping: " + + "; ".join(ambiguous_reasons) + + ". Tilfoej events[].id eller events[].uid for events: " + + missing_list + ) + + return False