several updates to functionality

This commit is contained in:
2026-02-02 13:16:18 +01:00
parent 1f3ba037d6
commit 3ac987ac34
19 changed files with 1808 additions and 135 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
fredagsbar_output/* fredagsbar_output/*
**/*.pyc

45
AGENTS.md Normal file
View File

@@ -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/<your_story>.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_<slug>.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_<slug>.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.

View File

@@ -1,3 +1,49 @@
# fredagsbar-meeting-generator # Vibecoded meeting generator
generere en fredagsbar .ics fil som indeholder en historie.. 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_<slug>.ics`.
`$ python generator.py stories/object_87-B.json --preview-html`
This also writes a preview to `fredagsbar_output/PREVIEW_<slug>.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`

9
config.toml Normal file
View File

@@ -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

View File

@@ -2,38 +2,109 @@
""" """
fredagsbar_ics_generator_v3.py fredagsbar_ics_generator_v3.py
Genererer .ics invitationer baseret på JSON stories. Genererer en samlet .ics fil med fredagsbar-invitationer.
Understøtter nu 'hints' i filnavne og headers. 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 json
import argparse import argparse
import sys import sys
from ics import Calendar, Event from datetime import datetime, date, timedelta, timezone
from datetime import datetime, date, time, timedelta from html import escape
from zoneinfo import ZoneInfo
import uuid
import os import os
import re import re
import tomllib
import uuid
from html.parser import HTMLParser
from typing import Any, Mapping, Optional, TypedDict
from zoneinfo import ZoneInfo
# ====== STANDARD INDSTILLINGER ====== import holidays
START_DATE = date(2026, 1, 29) from ics import Calendar, Event
START_TIME = time(15, 00) from validation import validate_config, validate_story_data, validate_uid_mapping
DURATION_MINUTES = 60
TIMEZONE = ZoneInfo("Europe/Copenhagen")
OUTPUT_BASE_DIR = "fredagsbar_output"
REPO = "https://gitea.weircon.dk/agw/fredagsbar-meeting-generator";
# ==================================== # 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): def disable_colors() -> None:
"""Indlæser JSON fil og validerer strukturen.""" 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: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
data = json.load(f) return json.load(f)
return data
except Exception as e: 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) sys.exit(1)
def next_or_same_friday(d: date) -> date: 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: def sanitize_filename(s: str) -> str:
s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE) s = re.sub(r"[^\w\s-]", "", s, flags=re.UNICODE)
s = re.sub(r"\s+", "_", s.strip()) s = re.sub(r"\s+", "_", s.strip())
return s[:120] s = s[:120]
return s or "story"
def strip_html_tags(text: str) -> str: class _ListState(TypedDict):
clean = re.compile('<.*?>') type: str
return re.sub(clean, '', text) 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:<!DOCTYPE html><html><body>{html_oneline}</body></html>\r\n"
def replacer(match): class StoryHTMLParser(HTMLParser):
vevent = match.group(0) def __init__(self) -> None:
if "X-MICROSOFT-CDO-BUSYSTATUS" not in vevent: super().__init__(convert_charrefs=True)
vevent = vevent.replace("\r\nEND:VEVENT", "\r\n" + injection_status + "END:VEVENT") self.lines: list[str] = []
# Overskriv eller indsæt X-ALT-DESC self.current: list[str] = []
if "X-ALT-DESC" in vevent: self.list_stack: list[_ListState] = []
# Simpel håndtering: Hvis vi allerede har det, ignorer (eller implementer regex replace af X-ALT-DESC) self.in_pre = False
pass
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: else:
vevent = vevent.replace("\r\nEND:VEVENT", "\r\n" + injection_html + "END:VEVENT") unfolded.append(line)
return vevent return unfolded
text_crlf = ical_text.replace("\n", "\r\n") def load_existing_uids(ics_path: str) -> tuple[dict[str, str], dict[int, str], set[int], set[str]]:
vevent_pattern = re.compile(r"BEGIN:VEVENT[\s\S]*?END:VEVENT", flags=re.IGNORECASE) if not os.path.exists(ics_path):
return vevent_pattern.sub(replacer, text_crlf) 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"] title = event_data["title"]
story_text = event_data["story"] 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") font = meta.get("font", "Arial, sans-serif")
bg_color = meta.get("bg_color", "#f0f0f0") bg_color = meta.get("bg_color", "#f0f0f0")
text_color = meta.get("text_color", "#000000") text_color = meta.get("text_color", "#000000")
theme_color = meta.get("theme_color", "#000000") theme_color = meta.get("theme_color", "#000000")
log_prefix = meta.get("log_prefix", "LOG") log_prefix = meta.get("log_prefix", "LOG")
# Byg Header strengen
header_text = f"// {log_prefix} {log_index:02d}/{total_logs} // SUBJECT: {title.upper()}" header_text = f"// {log_prefix} {log_index:02d}/{total_logs} // SUBJECT: {title.upper()}"
if hint: if hint:
header_text += f" // CODE: {hint}" header_text += f" // CODE: {hint}"
# --- HTML Version (Outlook) --- html = (
html_desc = (
f"<div style='font-family: {font}; color: {text_color};'>" f"<div style='font-family: {font}; color: {text_color};'>"
f"<b style='color:{theme_color}'>{header_text}</b><br><br>" f"<b style='color:{theme_color}'>{header_text}</b><br><br>"
f"<div style='background-color: {bg_color}; padding: 15px; border-left: 5px solid {theme_color};'>" f"<div style='background-color: {bg_color}; padding: 15px; border-left: 5px solid {theme_color};'>"
f"{story_text}" # Her indsætter vi story direkte, da den nu indeholder HTML tags fra JSON f"{story_text}"
f"</div><br>" f"</div><br>"
"<hr>" "<hr>"
"<b>OBS: Dette er en invitation til en fredagsbar.</b><br>" "<b>OBS: Dette er en invitation til en fredagsbar.</b><br>"
"Dette event er automatisk genereret til Fredagsbar, med en title der giver Nicolaj mulighed for at deltage.<br>"
"Ingen forberedelse nødvendig.<br><br>" "Ingen forberedelse nødvendig.<br><br>"
"<span style='font-size: 10px; color: #666;'>" "<span style='font-size: 10px; color: #666;'>"
"Vibecoded sourcecode til generering kan findes her: <a href='"+ repo + "'> " + repo + "</a>" f"Vibecoded source: <a href='{repo_url}'>{repo_url}</a>"
"</span>" "</span>"
"</div>" "</div>"
) )
return html.replace("\n", "")
# --- Plain Text Version --- def build_preview_html(
# Vi erstatter <br> med \n for læsbarhed i plain text meta: dict[str, Any],
plain_story = story_text.replace("<br>", "\n").replace("<b>", "").replace("</b>", "").replace("<i>", "").replace("</i>", "") config: dict[str, Any],
plain_desc = ( preview_items: list[dict[str, Any]],
f"{header_text}\n\n" skipped_dates: list[dict[str, str]],
f"{plain_story}\n\n" timezone_name: str,
"--------------------------------------------------\n" ) -> str:
"OBS: Dette er en invitation til en fredagsbar.\n" title = meta.get("name", "Story preview")
"Ingen forberedelse nødvendig.\n" start_label = config.get("start_date", "")
+ repo items_html = []
for item in preview_items:
start_dt = item["start"]
end_dt = item["end"]
date_label = start_dt.strftime("%Y-%m-%d")
time_label = f"{start_dt:%H:%M}-{end_dt:%H:%M}"
header = f"{date_label} {time_label} - {escape(item['title'])}"
items_html.append(
"<section class='event'>"
f"<h2>{header}</h2>"
f"<div class='event-html'>{item['html']}</div>"
"</section>"
)
items_block = "\n".join(items_html)
skipped_block = ""
if skipped_dates:
skipped_items = "\n".join(
f"<li>{escape(item['date'])}: {escape(item['reason'])}</li>"
for item in skipped_dates
)
skipped_block = (
"<section class='skipped'>"
"<h2>Skipped dates</h2>"
"<ul>"
f"{skipped_items}"
"</ul>"
"</section>"
)
return (
"<!DOCTYPE html>"
"<html lang='da'>"
"<head>"
"<meta charset='utf-8'>"
f"<title>{escape(title)} - Preview</title>"
"<style>"
"body{font-family:Arial,Helvetica,sans-serif;margin:32px;background:#f7f6f2;color:#222;}"
".wrap{max-width:960px;margin:0 auto;}"
"h1{margin-bottom:4px;}"
".meta{color:#555;margin-bottom:24px;}"
".event{background:#fff;border:1px solid #e0ded8;border-radius:12px;padding:18px;margin-bottom:24px;}"
".event h2{margin-top:0;font-size:18px;color:#222;}"
".event-html{margin-top:12px;}"
".skipped{background:#fff7e6;border:1px solid #f0d3a3;border-radius:12px;padding:16px;}"
".skipped h2{margin-top:0;font-size:16px;color:#5b3a00;}"
"</style>"
"</head>"
"<body>"
"<div class='wrap'>"
f"<h1>{escape(title)}</h1>"
f"<div class='meta'>Preview schedule from {escape(start_label)} ({escape(timezone_name)})</div>"
f"{items_block}"
f"{skipped_block}"
"</div>"
"</body>"
"</html>"
) )
def inject_outlook_hacks(
ical_text: str,
uid_html_map: dict[str, str],
last_modified: Optional[str] = None,
) -> str:
"""Indsætter HTML og Busy-status i den rå ICS tekst."""
injection_status = "TRANSP:OPAQUE\r\nX-MICROSOFT-CDO-BUSYSTATUS:BUSY\r\n"
vevent_pattern = re.compile(r"(BEGIN:VEVENT[\s\S]*?END:VEVENT)", flags=re.IGNORECASE)
uid_pattern = re.compile(r"UID:(.*?)\s", flags=re.IGNORECASE)
last_modified_pattern = re.compile(r"^LAST-MODIFIED", flags=re.IGNORECASE | re.MULTILINE)
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold line per RFC 5545 (75 octets) using CRLF + space continuation."""
if not line:
return line
lines = []
current = []
current_len = 0
for ch in line:
ch_len = len(ch.encode("utf-8"))
if current and current_len + ch_len > limit:
lines.append("".join(current))
current = [ch]
current_len = ch_len
else:
current.append(ch)
current_len += ch_len
if current:
lines.append("".join(current))
if len(lines) == 1:
return lines[0]
return "\r\n ".join(lines)
def replacer(match):
block = match.group(1)
uid_match = uid_pattern.search(block)
if not uid_match:
return block
uid = uid_match.group(1).strip()
html_content = uid_html_map.get(uid)
if last_modified and not last_modified_pattern.search(block):
block = block.replace(
"\r\nEND:VEVENT",
f"\r\nLAST-MODIFIED:{last_modified}\r\nEND:VEVENT",
)
if html_content:
injection_line = f"X-ALT-DESC;FMTTYPE=text/html:<!DOCTYPE html><html><body>{html_content}</body></html>"
injection_html = fold_ical_line(injection_line)
if "X-MICROSOFT-CDO-BUSYSTATUS" not in block:
block = block.replace("\r\nEND:VEVENT", "\r\n" + injection_status + "END:VEVENT")
block = block.replace("\r\nEND:VEVENT", "\r\n" + injection_html + "\r\nEND:VEVENT")
return block
text_normalized = ical_text.replace("\r\n", "\n").replace("\r", "\n")
text_crlf = text_normalized.replace("\n", "\r\n")
return vevent_pattern.sub(replacer, text_crlf)
def generate_single_file(
story_path: str,
dry_run: bool = False,
clear_uids: bool = False,
preview_html: bool = False,
config_path: Optional[str] = None,
output_dir: str = OUTPUT_DIR,
timezone_name: str = DEFAULT_TIMEZONE,
duration_minutes: int = 60,
) -> None:
# Load data
config = parse_config(config_path)
story_data = load_json(story_path)
try:
story_data = validate_story_data(story_data)
except ValueError as e:
print(f"{Colors.FAIL}{e}{Colors.ENDC}")
sys.exit(1)
if clear_uids:
removed = clear_story_uids(story_data)
if removed:
if dry_run:
print(f"{Colors.OKBLUE}[DRY RUN] Fjernede {removed} UID'er fra story-data (ingen filskrivning).{Colors.ENDC}")
else:
try:
with open(story_path, "w", encoding="utf-8") as f:
json.dump(story_data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception as e:
print(f"{Colors.FAIL}Kunne ikke rydde UID'er i historie-fil: {e}{Colors.ENDC}")
sys.exit(1)
else:
msg = "Ingen UID'er at fjerne."
if dry_run:
print(f"{Colors.OKBLUE}[DRY RUN] {msg}{Colors.ENDC}")
else:
print(f"{Colors.OKBLUE}{msg}{Colors.ENDC}")
return
if not isinstance(duration_minutes, int) or duration_minutes <= 0:
print(f"{Colors.FAIL}duration_minutes skal være et positivt heltal.{Colors.ENDC}")
sys.exit(1)
timezone_info = TIMEZONE if timezone_name == DEFAULT_TIMEZONE else load_timezone(timezone_name)
event.description = plain_desc meta = story_data["meta"]
cal.events.add(event) events_data = story_data["events"]
repo_url = config.get("repo_url", "")
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"]
story_slug = sanitize_filename(meta.get("name", "story")) story_slug = sanitize_filename(meta.get("name", "story"))
output_dir = os.path.join(OUTPUT_BASE_DIR, story_slug) if not dry_run:
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
filename = f"FULL_SERIES_{story_slug}.ics"
print(f"--- Starter generering af: {meta.get('name')} ---") out_path = os.path.join(output_dir, filename)
existing_title_uids, existing_index_uids, existing_totals, duplicate_titles = load_existing_uids(out_path)
first_friday = next_or_same_friday(START_DATE) try:
total_logs = len(events) allow_uid_reuse = validate_uid_mapping(events_data, duplicate_titles, existing_totals)
except ValueError as e:
for idx, event_entry in enumerate(events): print(f"{Colors.FAIL}{e}{Colors.ENDC}")
event_date = first_friday + timedelta(weeks=idx) sys.exit(1)
start_dt = datetime.combine(event_date, START_TIME).replace(tzinfo=TIMEZONE)
end_dt = start_dt + timedelta(minutes=DURATION_MINUTES)
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 log_num = idx + 1
# Bruger hint i filnavnet hvis det findes # Find næste ledige dato
hint_part = "" while True:
if "hint" in event_entry: is_blocked, reason = is_blocked_date(current_date, dk_holidays, config)
hint_part = f"_{sanitize_filename(event_entry['hint'])}" 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" # Hvis blokeret, print info og hop en uge frem
out_path = os.path.join(output_dir, fname) 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generer Fredagsbar ICS filer fra JSON historie.") parser = argparse.ArgumentParser(description="Generer ICS fil med feriehåndtering.")
parser.add_argument("story_file", help="Sti til JSON filen med historien (f.eks. stories/story_scp.json)") 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() args = parser.parse_args()
if args.no_color:
disable_colors()
if not os.path.exists(args.story_file): 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) sys.exit(1)
generate_series(args.story_file) generate_single_file(
print("\nFærdig!") 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,
)

8
requirements.txt Normal file
View File

@@ -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

View File

@@ -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": "<b>OFFICIELT FORMÅL:</b><br>• Gennemgang af ugens kodebase<br>• Syntax check<br><br><b>LOG 15:00:</b><br>Asger har fundet en fejl i kompilatoren. Hver gang han skriver <code>public void</code>, retter IDE'en det automatisk til <code>public bar</code>.<br><br>Mathias sværger på, at han så kaffemaskinen blinke i morsekode. Beskeden var: \"JAVA ER IKKE LÆNGERE KAFFE.\"<br><br><i>Hulen ændrer sig. Luften smager pludselig af humle.</i>",
"uid": "2f10eec2-fd1a-5178-bfe4-7ae1810419f2"
},
{
"title": "FB: Audit af Lokale-Ressourcer",
"hint": "TOMHED",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Inventaroptælling<br>• Ressourceallokering<br><br><b>LOG 15:15:</b><br>Vi sendte Tom ind for at hente whiteboards. Han kom tilbage uden noget.<br>Rapporten lyder: \"Der er helt <b>Tomt</b> derinde.\"<br><br>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.<br>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": "<b>OFFICIELT FORMÅL:</b><br>• Integration af ældre systemer<br><br><b>LOG 15:30:</b><br>Hans og Lasse har fundet commits i git-historikken, dateret til \"Fredag kl. 25:00\".<br>Commit-beskeden er blot: <i>\"Morten var her.\"</i><br><br>Hvilken Morten? Vi spurgte Morten. Han anede intet.<br>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": "<b>OFFICIELT FORMÅL:</b><br>• Sikkerhedsopdatering af brugerprofiler<br><br><b>LOG 15:45:</b><br>Jacob og Christoffer forsøgte at debugge virkeligheden.<br>De observerede, at når lyset i Java Hulen flimrer, kaster Morten II en skygge, der ligner den første Morten.<br><br>Tapper prøvede at tappe vand fra hanen. Der kom mørk stout ud.<br>\"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": "<b>OFFICIELT FORMÅL:</b><br>• Støjmåling i storrumskontor<br><br><b>LOG 16:00:</b><br>Anne og Jan melder om uregelmæssigheder.<br>Når man er stille, kan man høre tastaturerne skrive af sig selv.<br>Rytmen lyder som åbningen af dåser: <i>Pscht. Klik. Slurk.</i><br><br>Lise forsøgte at forlade rummet, men døren førte bare ind i den anden ende af Java Hulen.<br>\"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": "<b>OFFICIELT FORMÅL:</b><br>• Belastningstest af servere<br><br><b>LOG 16:15:</b><br>Mikkel og Claus har målt temperaturen. Den falder drastisk nær køleskabet.<br>Lars Erik fandt en manual under gulvtæppet tituleret: \"Protokol for Fredags-Transformationen\".<br><br>Siderne er blanke, indtil man spilder væske på dem.<br>Der står nu: <i>\"Morten II er nøglen. Eller måske låsen.\"</i>",
"uid": "9116a36e-16ef-55f8-88cc-cc9c6c4a3879"
},
{
"title": "FB: Tidsmæssig Synkroniseringsfejl",
"hint": "LAG",
"story": "<b>OFFICIELT FORMÅL:</b><br>• NTP Server justering<br><br><b>LOG 16:30:</b><br>Rune kiggede på sit ur. Viserne går baglæns.<br>Emil påstår, at han har haft den samme samtale med Kathrine 4 gange nu.<br><br>\"Vi compilerer ikke længere kode,\" sagde Kathrine tørt. \"Vi compilerer promiller.\"<br>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": "<b>OFFICIELT FORMÅL:</b><br>• Godkendelse af leverancer<br><br><b>LOG 16:45:</b><br>Morten (den første) er begyndt at fade ud. Han bliver mere og mere gennemsigtig.<br>Morten II (Den Anden) bliver mere solid.<br><br>\"Der kan kun være én Runtime-Morten,\" grinede Asger nervøst.<br>Tom tjekkede sin flaske. Den var Tom. Da han kiggede igen, var den fuld.<br>\"Uendelig løkke!\" råbte Tapper begejstret.",
"uid": "592ad6f5-f0ec-5c5a-9022-6733745cec71"
},
{
"title": "FB: Ledelsesmæssig Eskalering",
"hint": "CRITICAL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Eskalering til styregruppen<br><br><b>LOG 16:55:</b><br>Mathias og Martin forsøger at holde fast i bordene.<br>Tyngdekraften i Java Hulen skifter retning.<br>Lene skriver febrilsk på tavlen: <i>\"HVIS VI IKKE TØMMER FADET FØR KL 17, BLIVER VI HER FOR EVIGT.\"</i><br><br>Alle mand til pumperne. Dette er en kritisk deployering.",
"uid": "44cc8a42-a51d-5886-b46a-ad7d22b2acaf"
},
{
"title": "FB: System Reboot / Shut Down",
"hint": "GENSTART",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Lukning af systemer før weekend<br><br><b>LOG 17:01:</b><br>Lyset blinkede én gang.<br>Java Hulen er bare et mødelokale igen. Ingen øl. Ingen mystik.<br><br>Lars Erik står med en kaffekop, der er rygende varm, selvom han drak kold pilsner for 2 minutter siden.<br>På whiteboardet står der med utydelig håndskrift:<br><i>\"Tak for testen. Vi ses i næste uge. Hilsen Morten...ne.\"</i>",
"uid": "5b8c5211-5f89-513b-8777-25cfff347d1d"
}
]
}

View File

@@ -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": "<b>OFFICIELT FORMÅL:</b><br>• Opstart af inventar-undersøgelse<br>• Identificering af nøglepersoner<br><br><b>LOG #001:</b><br>Jeg ankom til CGI Aalborg kl. 14:58. Regnen udenfor faldt ikke nedad, men... sidelæns.<br>Opgaven er simpel: Find en stjålet kuglepen (mærke: 'Parker', model: 'Jotter').<br>Døren gik op før jeg rørte håndtaget. <b>Tapper</b> stod bag baren. Han blinkede ikke.",
"uid": "bd2867d9-5662-5ad0-90b5-0e6aa7fa8931"
},
{
"title": "FB - Indledende interessent-analyse",
"hint": "LASSE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Kortlægning af vidner<br><br><b>LOG #002:</b><br>Har afhørt subjektet <b>Lasse</b>. Han hævder, han ikke har set kuglepennen.<br>Men mens han talte, tegnede hans fingre komplekse geometriske figurer i kondensen på hans ølglas.<br>Han hviskede: <i>\"Den skriver ikke med blæk, Z. Den skriver med tid.\"</i><br>Jeg noterer ham som 'mistænkelig'.",
"uid": "bf5d58e7-9c8b-5b9c-9a95-9f56f96393b0"
},
{
"title": "FB - Materiel beholdningskontrol",
"hint": "TOM",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Fysisk gennemgang af aktiver<br><br><b>LOG #003:</b><br>Mødte <b>Tom</b> ved kaffemaskinen. Han tilbød mig en kop \"sort væske\".<br>Væsken var kold, men dampen steg stadig op.<br>Tom grinede nervøst: <i>\"Vi mistede ikke pennen. Vi slap den fri.\"</i><br>Hvad gemmer de her mennesker i deres Outlook-kalendere?",
"uid": "f75d3d7f-33aa-5b1e-9941-04899b422146"
},
{
"title": "FB - Netværksinfrastruktur Audit",
"hint": "MARTIN",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Gennemgang af kabling og forbindelser<br><br><b>LOG #004:</b><br>Fandt <b>Martin</b> i serverrummet (eller var det toilettet? Rummet skifter form).<br>Han stirrede ind i et netværksstik.<br><i>\"Kan du høre det, detektiv?\"</i> spurgte han. <i>\"Kuglepennen kradser i firewallen.\"</i><br>Jeg har brug for en drink. En stærk en.",
"uid": "b25a002a-db05-5293-a62e-9c18952b7d62"
},
{
"title": "FB - Kapacitetsvurdering (Mental)",
"hint": "MATHIAS",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Stress-test af systemet<br><br><b>LOG #005:</b><br><b>Mathias</b> sad i et hjørne, der var mørkere end belysningen tillader.<br>Han holdt en notesblok, men ingen pen.<br><i>\"Den valgte Asger,\"</i> mumlede han.<br>Da jeg spurgte hvad han mente, pegede han bare mod Limfjorden.",
"uid": "71f3b982-da8c-5476-9377-4215f48a1ff9"
},
{
"title": "FB - Strategisk Vidensdeling",
"hint": "ASGER",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Vidensdeling på senior-niveau<br><br><b>LOG #006:</b><br>Konfronterede <b>Asger</b>. Han virkede rolig. For rolig.<br>Han talte om \"Legacy Systemer\" på en måde, der lød som om han mente \"Ældgamle Guder\".<br><i>\"Pennen er et interface, Z. Du kan ikke bare 'finde' den. Du skal logge ind.\"</i>",
"uid": "135fb9b9-a1ef-5370-a162-1a886c0da5fa"
},
{
"title": "FB - Brugeroplevelses-optimering",
"hint": "JACOB",
"story": "<b>OFFICIELT FORMÅL:</b><br>• UX Review<br><br><b>LOG #007:</b><br><b>Jacob</b> kom løbende. Han var bleg.<br>Han påstod, at han havde set kuglepennen svæve over billardbordet.<br><i>\"Kuglerne trillede ikke,\"</i> sagde han. <i>\"De... vibrerede.\"</i><br>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": "<b>OFFICIELT FORMÅL:</b><br>• Genopfyldning af lagre<br><br><b>LOG #008:</b><br>Gik tilbage til baren. <b>Tapper</b> pudsede et glas. Det samme glas som for en time siden.<br>Det skreg, da kluden rørte det.<br><i>\"Vil du have en IPA?\"</i> spurgte han. <i>\"Den er brygget på vand fra R'lyeh.\"</i><br>Jeg takkede nej. Jeg holder mig til pilsner.",
"uid": "315d69d3-2648-5e2a-82c6-b77976d1a9f4"
},
{
"title": "FB - Tværgående Alignment",
"hint": "SKRIFTEN",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Koordinering på tværs af siloer<br><br><b>LOG #009:</b><br>Der er skrift på væggen bag dartskiven. Det er skrevet med den savnede pen.<br>Teksten er på C# men variablerne er navne på dæmoner.<br><b>Martin</b> siger, det kompilerer uden fejl.",
"uid": "6b2df9ce-218e-5be4-bd1f-2e0c80e615c6"
},
{
"title": "FB - Risikovurdering",
"hint": "LYDEN",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Vurdering af operationelle risici<br><br><b>LOG #010:</b><br>Lyden i lokalet har ændret sig. Muzakken er erstattet af en lavfrekvent brummen.<br><b>Lasse</b> og <b>Tom</b> står og nikker i takt til den.<br>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": "<b>OFFICIELT FORMÅL:</b><br>• Sikring af overholdelse af standarder<br><br><b>LOG #011:</b><br>Fandt en proceduremanual under sofaen.<br>Regel 1: \"Spørg ikke om pennen.\"<br>Regel 2: \"Hvis <b>Asger</b> begynder at chante, så chant med.\"<br>Regel 3: \"Fredagsbaren slutter aldrig. Vi pauser den kun.\"",
"uid": "25b80771-6343-5074-acad-b1463890ffc4"
},
{
"title": "FB - System Integration Test",
"hint": "Sammensmeltning",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Test af systemernes samspil<br><br><b>LOG #012:</b><br><b>Mathias</b> har nu blæk på hænderne. Han rørte ved ingenting.<br>Han siger, at pennen skriver historien om os alle sammen lige nu.<br><i>\"Jeg er bare en bi-karakter i referatet,\"</i> græd han.",
"uid": "d212adcc-5d00-5d9e-a53f-defc2cf7009d"
},
{
"title": "FB - Legacy System Migration",
"hint": "FORTIDEN",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Flytning af data fra gamle systemer<br><br><b>LOG #013:</b><br>Lyset gik ud. Da nødgeneratoren startede, var vi ikke længere i Aalborg.<br>Uden for vinduet var der kun hav. Grønt, oprørt hav.<br><b>Jacob</b> kiggede ud: <i>\"CGI Atlantis afdelingen... de har også fredagsbar.\"</i>",
"uid": "da5a5ed4-d072-589a-8a72-a4777f816628"
},
{
"title": "FB - Performance Review",
"hint": "DOMMEN",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Individuel evaluering<br><br><b>LOG #014:</b><br><b>Tapper</b> kaldte mig op til baren.<br>Han lagde en genstand på disken. En Parker Jotter.<br>Den pulserede.<br><i>\"Den er ikke stjålet,\"</i> sagde Tapper med en stemme som knust grus. <i>\"Den ventede bare på en ny bruger.\"</i>",
"uid": "236a2354-b293-582d-bafc-2621001f1b32"
},
{
"title": "FB - Kontraktforhandling",
"hint": "UNDERSKRIFT",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Finalisering af aftaler<br><br><b>LOG #015:</b><br>Alle kigger på mig. <b>Lasse, Tom, Martin, Mathias, Asger, Jacob</b>.<br>De danner en halvcirkel.<br>Asger rækker mig et stykke papir. Det ligner en timeregistrering, men feltet for 'Timer' er uendeligt.<br><i>\"Skriv under, Z.\"</i>",
"uid": "c9f7554d-b5b5-56b1-acf1-ec7083dbd578"
},
{
"title": "FB - Implementeringsfase",
"hint": "OPTIVELSE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Idriftsættelse<br><br><b>LOG #016:</b><br>Jeg tog pennen. Den var varm som kød.<br>Da jeg rørte papiret, hørte jeg ikke ridsen af kugle mod papir, men et skrig.<br>Mit eget skrig? Eller pennens?",
"uid": "89bcc0ae-122d-51c3-ab0f-64e3130d87b1"
},
{
"title": "FB - Kvalitetssikring",
"hint": "SLØRET",
"story": "<b>OFFICIELT FORMÅL:</b><br>• QA af leverancen<br><br><b>LOG #017:</b><br>Verden er blevet skarpere. Farverne er... anderledes.<br><b>Tom</b> har lige fortalt en vittighed, jeg hørte for 1000 år siden.<br>Vi lo. Vi lo så tænderne raslede.",
"uid": "69ad60fa-cf13-577d-a83a-dd857f629671"
},
{
"title": "FB - Driftsstabilisering",
"hint": "GLEMSEL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Sikring af stabil drift<br><br><b>LOG #???</b><br>Hvad ledte jeg efter? En blyant? En mus?<br>Det betyder ikke noget.<br><b>Tapper</b> 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": "<b>OFFICIELT FORMÅL:</b><br>• Lukning af sagen<br><br><b>LOG FINAL:</b><br>Sagen er lukket. Ingen uregelmæssigheder fundet.<br>Detektiv Z eksisterer ikke længere som ekstern konsulent.<br>Jeg er blevet onboardet.",
"uid": "dddc2291-efd8-5104-9031-7e3cb9b3db9d"
},
{
"title": "FB - Gentagende begivenhed",
"hint": "EVIGT",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Fastlæggelse af næste uges møde<br><br><b>STATUS:</b><br>Vi ses næste fredag.<br>Og næste.<br>Og næste.<br><i>Ph'nglui mglw'nafh Cthulhu Aalborg wgah'nagl fhtagn.</i>",
"uid": "4e8f196f-2e97-50e3-b212-3c4ef46d902f"
}
]
}

View File

@@ -12,102 +12,122 @@
{ {
"title": "Ugentlig tværgående synkronisering", "title": "Ugentlig tværgående synkronisering",
"hint": "AFVIGELSE", "hint": "AFVIGELSE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Hurtig synk af ugens løse ender<br>• Ingen forberedelse nødvendig (seriøst)<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi har en afvigelse i kalenderdomænet. Konsulent “N.” har stået som *OPTAGET* siden sidste fredag.<br>Der findes ingen mødeindkaldelse i historikken — kun en gentagelse.<br><br><i>Bemærkning:<br>Hvis du hører Outlook-påmindelsen uden at have en påmindelse: ignorer den, og mød op alligevel.</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Hurtig synk af ugens løse ender<br>• Ingen forberedelse nødvendig (seriøst)<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi har en afvigelse i kalenderdomænet. Konsulent “N.” har stået som *OPTAGET* siden sidste fredag.<br>Der findes ingen mødeindkaldelse i historikken — kun en gentagelse.<br><br><i>Bemærkning:<br>Hvis du hører Outlook-påmindelsen uden at have en påmindelse: ignorer den, og mød op alligevel.</i>",
"uid": "b6fd22f2-0311-445e-bf42-8e31f9cad0a9"
}, },
{ {
"title": "Obligatorisk sjov", "title": "Obligatorisk sjov",
"hint": "BADGE", "hint": "BADGE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• “Teambuilding” uden slide deck<br>• Interessentpleje (humør)<br><br><b>FORTROLIGT UDDRAG:</b><br>Receptionen fandt et adgangskort med teksten: “KONSULENT N.” på gulvet ved køleskabet.<br>Kortet var lunt, som om det lige var blevet brugt — men ingen har registreret adgang.<br><br><i>Til stedeværende anbefales:<br>Smil normalt. Lad være med at sige ordet “gentagelse” højt før kl. 15:05.</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• “Teambuilding” uden slide deck<br>• Interessentpleje (humør)<br><br><b>FORTROLIGT UDDRAG:</b><br>Receptionen fandt et adgangskort med teksten: “KONSULENT N.” på gulvet ved køleskabet.<br>Kortet var lunt, som om det lige var blevet brugt — men ingen har registreret adgang.<br><br><i>Til stedeværende anbefales:<br>Smil normalt. Lad være med at sige ordet “gentagelse” højt før kl. 15:05.</i>",
"uid": "fe8114b0-cbed-4645-8599-cde5bdc9679c"
}, },
{ {
"title": "Afsluttende statusmøde (uformelt)", "title": "Afsluttende statusmøde (uformelt)",
"hint": "SIGNAL", "hint": "SIGNAL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Uformel status og weekendoverdragelse<br>• Opsamling på “wins”<br><br><b>FORTROLIGT UDDRAG:</b><br>Kl. 15:00 præcis kom der et systemevent:<br>“Your meeting has been updated.”<br>Ingen opdaterede noget.<br><br>Kort efter: en enkelt linje i beskrivelsesfeltet (nu slettet):<br><i>“…jeg kan høre isterningerne, men jeg kan ikke nå dem.”</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Uformel status og weekendoverdragelse<br>• Opsamling på “wins”<br><br><b>FORTROLIGT UDDRAG:</b><br>Kl. 15:00 præcis kom der et systemevent:<br>“Your meeting has been updated.”<br>Ingen opdaterede noget.<br><br>Kort efter: en enkelt linje i beskrivelsesfeltet (nu slettet):<br><i>“…jeg kan høre isterningerne, men jeg kan ikke nå dem.”</i>",
"uid": "bf2d0a93-fd77-42cb-b7d9-5a6d1728f134"
}, },
{ {
"title": "Kvalitativ erfaringsudveksling", "title": "Kvalitativ erfaringsudveksling",
"hint": "NOTE", "hint": "NOTE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Del en ting der virkede denne uge<br>• Del en ting der ikke gjorde<br><br><b>FORTROLIGT UDDRAG:</b><br>Der cirkulerer en printet mødeagenda med håndskrift nederst:<br><br>“Hvis I læser dette, så er jeg ikke forsvundet.<br>Jeg er bare blevet flyttet til et lokale, der kun findes om fredagen.”<br><br>Papiret lugter af citrus og… mødelokale-tæppe." "story": "<b>OFFICIELT FORMÅL:</b><br>• Del en ting der virkede denne uge<br>• Del en ting der ikke gjorde<br><br><b>FORTROLIGT UDDRAG:</b><br>Der cirkulerer en printet mødeagenda med håndskrift nederst:<br><br>“Hvis I læser dette, så er jeg ikke forsvundet.<br>Jeg er bare blevet flyttet til et lokale, der kun findes om fredagen.”<br><br>Papiret lugter af citrus og… mødelokale-tæppe.",
"uid": "e000454e-3359-4fe9-8c5a-89dd86194e29"
}, },
{ {
"title": "Strategisk afrunding af arbejdsugen", "title": "Strategisk afrunding af arbejdsugen",
"hint": "INDDÆMNING", "hint": "INDDÆMNING",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Strategisk afrunding (aka: “god weekend”)<br><br><b>FORTROLIGT UDDRAG:</b><br>IT har forsøgt at “aflyse serien”.<br>Kalenderen genopretter den inden for 9 sekunder.<br><br><b>Foreløbig inddæmningsprocedure:</b><br>• Indkaldelsen må ikke slettes<br>• Deltagere skal møde fysisk op<br>• Der må gerne medbringes snacks (observationsmæssigt stabiliserende)" "story": "<b>OFFICIELT FORMÅL:</b><br>• Strategisk afrunding (aka: “god weekend”)<br><br><b>FORTROLIGT UDDRAG:</b><br>IT har forsøgt at “aflyse serien”.<br>Kalenderen genopretter den inden for 9 sekunder.<br><br><b>Foreløbig inddæmningsprocedure:</b><br>• Indkaldelsen må ikke slettes<br>• Deltagere skal møde fysisk op<br>• Der må gerne medbringes snacks (observationsmæssigt stabiliserende)",
"uid": "867282a6-2da5-4773-84d2-35dac2ac2f01"
}, },
{ {
"title": "Tværfaglig alignment-session", "title": "Tværfaglig alignment-session",
"hint": "STØTTE", "hint": "STØTTE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Let alignment på tværs (ingen action items)<br><br><b>FORTROLIGT UDDRAG:</b><br>HR, IT og Facilities blev spurgt, om der findes et “Mødelokale 4B (uofficielt)”.<br>Facilities svarede: “Det gør der ikke.”<br>Så tilføjede de: “Men døren står nogle gange på klem om fredagen.”<br><br><i>Anbefaling:<br>Kom som du er. Tag evt. en ekstra ven med. (Flere vidner = færre glitch).</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Let alignment på tværs (ingen action items)<br><br><b>FORTROLIGT UDDRAG:</b><br>HR, IT og Facilities blev spurgt, om der findes et “Mødelokale 4B (uofficielt)”.<br>Facilities svarede: “Det gør der ikke.”<br>Så tilføjede de: “Men døren står nogle gange på klem om fredagen.”<br><br><i>Anbefaling:<br>Kom som du er. Tag evt. en ekstra ven med. (Flere vidner = færre glitch).</i>",
"uid": "dee99abf-127a-48c5-af54-e80071f5e120"
}, },
{ {
"title": "Ad hoc interessentdialog", "title": "Ad hoc interessentdialog",
"hint": "KØLESKAB", "hint": "KØLESKAB",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Løst og socialt touchpoint<br><br><b>FORTROLIGT UDDRAG:</b><br>Ny interessent identificeret: Køleskabet.<br>Det afgiver en svag brummen i en rytme, der minder om en mødepåmindelse.<br><br>Ved forsøg på åbning kl. 14:59:<br>• håndtag koldt<br>• håndtag “giver sig” først efter en skål<br><br>Hypotese:<br>Køleskabet reagerer på ritualiseret fredagsbar-adfærd." "story": "<b>OFFICIELT FORMÅL:</b><br>• Løst og socialt touchpoint<br><br><b>FORTROLIGT UDDRAG:</b><br>Ny interessent identificeret: Køleskabet.<br>Det afgiver en svag brummen i en rytme, der minder om en mødepåmindelse.<br><br>Ved forsøg på åbning kl. 14:59:<br>• håndtag koldt<br>• håndtag “giver sig” først efter en skål<br><br>Hypotese:<br>Køleskabet reagerer på ritualiseret fredagsbar-adfærd.",
"uid": "1e74e9e9-2ed5-42be-8489-b9a9e2d6d080"
}, },
{ {
"title": "Intern kapacitetsudligning", "title": "Intern kapacitetsudligning",
"hint": "KAPACITET", "hint": "KAPACITET",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Afbalancering af ugens energiniveau<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi har brug for kapacitetsplanlægning:<br>• 1 person: isterninger<br>• 1 person: musik<br>• 1 person: “spørg ikke hvorfor, bare gør det” (koordinator)<br><br>Konsulent N. er muligvis låst til en times varighed ad gangen.<br>Hvis nogen forlader før tid, bliver rummet… mindre." "story": "<b>OFFICIELT FORMÅL:</b><br>• Afbalancering af ugens energiniveau<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi har brug for kapacitetsplanlægning:<br>• 1 person: isterninger<br>• 1 person: musik<br>• 1 person: “spørg ikke hvorfor, bare gør det” (koordinator)<br><br>Konsulent N. er muligvis låst til en times varighed ad gangen.<br>Hvis nogen forlader før tid, bliver rummet… mindre.",
"uid": "7f04c11d-c39a-4869-bb7e-56d051210f6d"
}, },
{ {
"title": "Letvægts-retrospektiv", "title": "Letvægts-retrospektiv",
"hint": "RETRO", "hint": "RETRO",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Hvad skal vi gøre mere af? Mindre af?<br><br><b>FORTROLIGT UDDRAG:</b><br>Root cause er sandsynligvis denne handling:<br>Konsulent N. trykkede “Accepter alle forekomster” uden at læse beskrivelsen.<br><br>Der findes nu en gentagende mødeserie, som opfører sig som en beholder.<br>Og N. er “i mødet” — ikke i bygningen." "story": "<b>OFFICIELT FORMÅL:</b><br>• Hvad skal vi gøre mere af? Mindre af?<br><br><b>FORTROLIGT UDDRAG:</b><br>Root cause er sandsynligvis denne handling:<br>Konsulent N. trykkede “Accepter alle forekomster” uden at læse beskrivelsen.<br><br>Der findes nu en gentagende mødeserie, som opfører sig som en beholder.<br>Og N. er “i mødet” — ikke i bygningen.",
"uid": "70ad1e5f-093d-4e7c-b24c-990bcb240f14"
}, },
{ {
"title": "Uformel leverancegennemgang", "title": "Uformel leverancegennemgang",
"hint": "NØGLE", "hint": "NØGLE",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Gennemgang af ugens “leverancer” (uformelt)<br><br><b>FORTROLIGT UDDRAG:</b><br>Lost & Found indeholdt én genstand, der ikke burde være der:<br>En nøgle mærket “FB-LOKAL” i en plastikpose.<br>Posen var dateret næste fredag.<br><br><i>Bemærkning:<br>Tid opfører sig dårligt i nærheden af serien.<br>Vi tester nøglen ved første skål.</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Gennemgang af ugens “leverancer” (uformelt)<br><br><b>FORTROLIGT UDDRAG:</b><br>Lost & Found indeholdt én genstand, der ikke burde være der:<br>En nøgle mærket “FB-LOKAL” i en plastikpose.<br>Posen var dateret næste fredag.<br><br><i>Bemærkning:<br>Tid opfører sig dårligt i nærheden af serien.<br>Vi tester nøglen ved første skål.</i>",
"uid": "3e347783-cda3-4d7b-b8db-5250de8c3268"
}, },
{ {
"title": "Operationel efterbearbejdning", "title": "Operationel efterbearbejdning",
"hint": "EKKO", "hint": "EKKO",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Efterbearbejdning af ugen (lavt gear)<br><br><b>FORTROLIGT UDDRAG:</b><br>Når glas rammer glas, kommer der et ekko, der ikke passer til rummet.<br>Ekkoet lyder som nogen der prøver at sige:<br><i>“…er I her…?”</i><br><br>Hvis du hører ekkoet:<br>Svar ikke direkte. Skål bare igen. (Det virker mere stabilt)." "story": "<b>OFFICIELT FORMÅL:</b><br>• Efterbearbejdning af ugen (lavt gear)<br><br><b>FORTROLIGT UDDRAG:</b><br>Når glas rammer glas, kommer der et ekko, der ikke passer til rummet.<br>Ekkoet lyder som nogen der prøver at sige:<br><i>“…er I her…?”</i><br><br>Hvis du hører ekkoet:<br>Svar ikke direkte. Skål bare igen. (Det virker mere stabilt).",
"uid": "02e1fc1b-4ac0-4384-a6b0-90221605ebac"
}, },
{ {
"title": "Tværorganisatorisk vidensdeling", "title": "Tværorganisatorisk vidensdeling",
"hint": "SITES", "hint": "SITES",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Vidensdeling (uformel)<br><br><b>FORTROLIGT UDDRAG:</b><br>Andre kontorer rapporterer lignende:<br>“En fredagsbar-invite der bliver ved med at vende tilbage.”<br>De kalder det: “Den Tilbagevendende Invitation”.<br><br>Fælles observation:<br>Når folk møder op og hygger, falder antallet af fejl i kalenderen midlertidigt." "story": "<b>OFFICIELT FORMÅL:</b><br>• Vidensdeling (uformel)<br><br><b>FORTROLIGT UDDRAG:</b><br>Andre kontorer rapporterer lignende:<br>“En fredagsbar-invite der bliver ved med at vende tilbage.”<br>De kalder det: “Den Tilbagevendende Invitation”.<br><br>Fælles observation:<br>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)", "title": "Kalibreringsmøde (lav intensitet)",
"hint": "TÆRSKEL", "hint": "TÆRSKEL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Kalibrering uden intensitet<br><br><b>FORTROLIGT UDDRAG:</b><br>Kalibreringsparameter fundet:<br>Temperatur.<br>Hvis køleskabet når præcis “fredagskoldt”, bliver lyden i rummet klarere.<br><br><b>Strengt forbudt:</b><br>0,0% øl omtalt som “sikkert alternativ”.<br>Det gjorde rummet… meget stille sidst." "story": "<b>OFFICIELT FORMÅL:</b><br>• Kalibrering uden intensitet<br><br><b>FORTROLIGT UDDRAG:</b><br>Kalibreringsparameter fundet:<br>Temperatur.<br>Hvis køleskabet når præcis “fredagskoldt”, bliver lyden i rummet klarere.<br><br><b>Strengt forbudt:</b><br>0,0% øl omtalt som “sikkert alternativ”.<br>Det gjorde rummet… meget stille sidst.",
"uid": "b73190a0-0e24-4985-b8ab-1e9c5a5313e6"
}, },
{ {
"title": "Afrunding af ugens initiativer", "title": "Afrunding af ugens initiativer",
"hint": "ÅBNING", "hint": "ÅBNING",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Afrunding af igangværende initiativer<br><br><b>FORTROLIGT UDDRAG:</b><br>Nøglen passer i en dør, der normalt ikke har nøglehul.<br>Døren dukker op bag kaffemaskinen præcis 15:02.<br><br>På den anden side:<br>Et lokale med projektor.<br>Projektoren viser kun én slide:<br>“STATUS: OPTAGET”" "story": "<b>OFFICIELT FORMÅL:</b><br>• Afrunding af igangværende initiativer<br><br><b>FORTROLIGT UDDRAG:</b><br>Nøglen passer i en dør, der normalt ikke har nøglehul.<br>Døren dukker op bag kaffemaskinen præcis 15:02.<br><br>På den anden side:<br>Et lokale med projektor.<br>Projektoren viser kun ét slide:<br>“STATUS: OPTAGET”",
"uid": "f9a29201-fab2-4aab-91fc-e6417918c2a5"
}, },
{ {
"title": "Intern koordinering uden agenda", "title": "Intern koordinering uden agenda",
"hint": "STILHED", "hint": "STILHED",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Koordinering uden agenda<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi opdagede at en agenda gør det værre.<br>Når nogen skriver “Agenda:” i rummet, forsvinder døren igen.<br><br>Så:<br>• Ingen agenda<br>• Ingen referat<br>• Ingen action items<br>Kun fredagsbar.<br><br>(Det føles næsten for corporate til at virke. Men det virker.)" "story": "<b>OFFICIELT FORMÅL:</b><br>• Koordinering uden agenda<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi opdagede at en agenda gør det værre.<br>Når nogen skriver “Agenda:” i rummet, forsvinder døren igen.<br><br>Så:<br>• Ingen agenda<br>• Ingen referat<br>• Ingen action items<br>Kun fredagsbar.<br><br>(Det føles næsten for corporate til at virke. Men det virker.)",
"uid": "b2d86cb7-d5b9-4ddc-9cde-7b53355dfb3c"
}, },
{ {
"title": "Eksperimentel samarbejdsramme", "title": "Eksperimentel samarbejdsramme",
"hint": "RITUAL", "hint": "RITUAL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Eksperimentel ramme (aka: prøv noget)<br><br><b>FORTROLIGT UDDRAG:</b><br>Nyt retrieval-eksperiment:<br>Kl. 15:07 foretages en synkroniseret skål.<br>Efterfulgt af: “God weekend” sagt én gang — ikke to.<br><br>Da vi testede det, kom der en notifikation:<br><i>“Konsulent N. forsøger at deltage.”</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Eksperimentel ramme (aka: prøv noget)<br><br><b>FORTROLIGT UDDRAG:</b><br>Nyt retrieval-eksperiment:<br>Kl. 15:07 foretages en synkroniseret skål.<br>Efterfulgt af: “God weekend” sagt én gang — ikke to.<br><br>Da vi testede det, kom der en notifikation:<br><i>“Konsulent N. forsøger at deltage.”</i>",
"uid": "47462f0d-b093-477d-931e-479438f1b2cf"
}, },
{ {
"title": "Socialt orienteret statusafstemning", "title": "Socialt orienteret statusafstemning",
"hint": "PULS", "hint": "PULS",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Social statusafstemning (hvordan går det egentlig?)<br><br><b>FORTROLIGT UDDRAG:</b><br>Lyden fra “mødelokalet på den anden side” har nu en puls.<br>Den synker, når nogen griner.<br>Den stiger, når nogen siger “lige hurtigt”.<br><br>Vi kan muligvis trække N. tættere på ved at gøre rummet… menneskeligt." "story": "<b>OFFICIELT FORMÅL:</b><br>• Social statusafstemning (hvordan går det egentlig?)<br><br><b>FORTROLIGT UDDRAG:</b><br>Lyden fra “mødelokalet på den anden side” har nu en puls.<br>Den synker, når nogen griner.<br>Den stiger, når nogen siger “lige hurtigt”.<br><br>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", "title": "Procesmæssig nedlukning af ugen",
"hint": "PORTAL", "hint": "PORTAL",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Luk ugen ned på den rigtige måde<br><br><b>FORTROLIGT UDDRAG:</b><br>Portalen er stabil i cirka 58 minutter.<br>Ved 59:30 begynder projektoren at vise “MØDET ER FORLÆNGET”.<br><br>Hvis vi ikke lukker rigtigt:<br>Serien udvider sig med et ekstra “opfølgningsmøde”.<br>Ingen ønsker et follow-up til fredagsbar. Ingen." "story": "<b>OFFICIELT FORMÅL:</b><br>• Luk ugen ned på den rigtige måde<br><br><b>FORTROLIGT UDDRAG:</b><br>Portalen er stabil i cirka 58 minutter.<br>Ved 59:30 begynder projektoren at vise “MØDET ER FORLÆNGET”.<br><br>Hvis vi ikke lukker rigtigt:<br>Serien udvider sig med et ekstra “opfølgningsmøde”.<br>Ingen ønsker et follow-up til fredagsbar. Ingen.",
"uid": "9f0f35c4-7aa0-4aca-8406-9ab3dda97022"
}, },
{ {
"title": "Konsensusbaseret afrunding", "title": "Konsensusbaseret afrunding",
"hint": "BESLUTNING", "hint": "BESLUTNING",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Konsensusbaseret afrunding (enighed + hygge)<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi skal beslutte:<br>Åbner vi køleskabet helt?<br><br>Sidste gang blev døren på klem, og en hånd (med et tastatur-mærke) nåede ud.<br>Den slap en Post-it:<br>“Jeg er her. Jeg er stadig optaget.”<br><br>Konsensus kræver tilstedeværelse. (Og måske chips)." "story": "<b>OFFICIELT FORMÅL:</b><br>• Konsensusbaseret afrunding (enighed + hygge)<br><br><b>FORTROLIGT UDDRAG:</b><br>Vi skal beslutte:<br>Åbner vi køleskabet helt?<br><br>Sidste gang blev døren på klem, og en hånd (med et tastatur-mærke) nåede ud.<br>Den slap en Post-it:<br>“Jeg er her. Jeg er stadig optaget.”<br><br>Konsensus kræver tilstedeværelse. (Og måske chips).",
"uid": "7cf2d01a-94e6-480c-87c3-20def1316ad7"
}, },
{ {
"title": "Frivillig deltagelse i fælles kontekst", "title": "Frivillig deltagelse i fælles kontekst",
"hint": "HJEMKOMST", "hint": "HJEMKOMST",
"story": "<b>OFFICIELT FORMÅL:</b><br>• Frivillig deltagelse, fælles kontekst, afslappet<br><br><b>FORTROLIGT UDDRAG:</b><br>Hvis retrieval lykkes, vil N. fremstå normal.<br>Undtagen:<br>• Hans Outlook vil muligvis vise 1900 ulæste reminders<br>• Han vil reagere på ordet “gentagelse” som på en høj lyd<br><br><i>Afsluttende note (fra N., modtaget som kalenderopdatering):<br>“Tak. Bliv ved med at møde op. Det er sådan man holder virkeligheden på plads om fredagen.”</i>" "story": "<b>OFFICIELT FORMÅL:</b><br>• Frivillig deltagelse, fælles kontekst, afslappet<br><br><b>FORTROLIGT UDDRAG:</b><br>Hvis retrieval lykkes, vil N. fremstå normal.<br>Undtagen:<br>• Hans Outlook vil muligvis vise 1900 ulæste reminders<br>• Han vil reagere på ordet “gentagelse” som på en høj lyd<br><br><i>Afsluttende note (fra N., modtaget som kalenderopdatering):<br>“Tak. Bliv ved med at møde op. Det er sådan man holder virkeligheden på plads om fredagen.”</i>",
"uid": "fd74a09e-f082-4605-b00e-ddf73c172d5d"
} }
] ]
} }

23
stories/template.json Normal file
View File

@@ -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"
}
]
}

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test package."""

126
tests/test_cli_options.py Normal file
View File

@@ -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)

View File

@@ -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")

View File

@@ -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")

View File

@@ -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])

60
tests/test_no_color.py Normal file
View File

@@ -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)

132
tests/test_preview_html.py Normal file
View File

@@ -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": "<b>HELLO</b><br>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": "<b>HELLO</b><br>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)

27
tests/test_uid_mapping.py Normal file
View File

@@ -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)

212
validation.py Normal file
View File

@@ -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