several updates to functionality
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
fredagsbar_output/*
|
||||
**/*.pyc
|
||||
|
||||
45
AGENTS.md
Normal file
45
AGENTS.md
Normal 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.
|
||||
50
README.md
50
README.md
@@ -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
9
config.toml
Normal 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
|
||||
874
generator.py
874
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:<!DOCTYPE html><html><body>{html_oneline}</body></html>\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"<div style='font-family: {font}; color: {text_color};'>"
|
||||
f"<b style='color:{theme_color}'>{header_text}</b><br><br>"
|
||||
f"<div style='background-color: {bg_color}; padding: 15px; border-left: 5px solid {theme_color};'>"
|
||||
f"{story_text}" # Her indsætter vi story direkte, da den nu indeholder HTML tags fra JSON
|
||||
f"{story_text}"
|
||||
f"</div><br>"
|
||||
"<hr>"
|
||||
"<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>"
|
||||
"<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>"
|
||||
"</div>"
|
||||
)
|
||||
return html.replace("\n", "")
|
||||
|
||||
# --- Plain Text Version ---
|
||||
# Vi erstatter <br> med \n for læsbarhed i plain text
|
||||
plain_story = story_text.replace("<br>", "\n").replace("<b>", "").replace("</b>", "").replace("<i>", "").replace("</i>", "")
|
||||
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(
|
||||
"<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>"
|
||||
)
|
||||
|
||||
event.description = plain_desc
|
||||
cal.events.add(event)
|
||||
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)
|
||||
|
||||
serialized = cal.serialize()
|
||||
processed = inject_outlook_lines(serialized, html_desc)
|
||||
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)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8", newline="\r\n") as f:
|
||||
f.write(processed)
|
||||
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)
|
||||
|
||||
print(f"[{log_index}/{total_logs}] '{title}' -> {out_path}")
|
||||
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",
|
||||
)
|
||||
|
||||
def generate_series(json_path):
|
||||
data = load_story(json_path)
|
||||
meta = data["meta"]
|
||||
events = data["events"]
|
||||
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
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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'])}"
|
||||
|
||||
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)
|
||||
|
||||
create_event_ics_file(event_entry, meta, log_num, total_logs, start_dt, end_dt, out_path)
|
||||
|
||||
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)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.story_file):
|
||||
print(f"Fejl: Filen '{args.story_file}' findes ikke.")
|
||||
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)
|
||||
|
||||
generate_series(args.story_file)
|
||||
print("\nFærdig!")
|
||||
if clear_uids:
|
||||
removed = clear_story_uids(story_data)
|
||||
if removed:
|
||||
if dry_run:
|
||||
print(f"{Colors.OKBLUE}[DRY RUN] Fjernede {removed} UID'er fra story-data (ingen filskrivning).{Colors.ENDC}")
|
||||
else:
|
||||
try:
|
||||
with open(story_path, "w", encoding="utf-8") as f:
|
||||
json.dump(story_data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
except Exception as e:
|
||||
print(f"{Colors.FAIL}Kunne ikke rydde UID'er i historie-fil: {e}{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
msg = "Ingen UID'er at fjerne."
|
||||
if dry_run:
|
||||
print(f"{Colors.OKBLUE}[DRY RUN] {msg}{Colors.ENDC}")
|
||||
else:
|
||||
print(f"{Colors.OKBLUE}{msg}{Colors.ENDC}")
|
||||
return
|
||||
|
||||
if not isinstance(duration_minutes, int) or duration_minutes <= 0:
|
||||
print(f"{Colors.FAIL}duration_minutes skal være et positivt heltal.{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
timezone_info = TIMEZONE if timezone_name == DEFAULT_TIMEZONE else load_timezone(timezone_name)
|
||||
|
||||
meta = story_data["meta"]
|
||||
events_data = story_data["events"]
|
||||
repo_url = config.get("repo_url", "")
|
||||
|
||||
story_slug = sanitize_filename(meta.get("name", "story"))
|
||||
if not dry_run:
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
filename = f"FULL_SERIES_{story_slug}.ics"
|
||||
out_path = os.path.join(output_dir, filename)
|
||||
existing_title_uids, existing_index_uids, existing_totals, duplicate_titles = load_existing_uids(out_path)
|
||||
try:
|
||||
allow_uid_reuse = validate_uid_mapping(events_data, duplicate_titles, existing_totals)
|
||||
except ValueError as e:
|
||||
print(f"{Colors.FAIL}{e}{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
if not allow_uid_reuse:
|
||||
existing_title_uids = {}
|
||||
existing_index_uids = {}
|
||||
existing_totals = set()
|
||||
|
||||
existing_total = next(iter(existing_totals)) if len(existing_totals) == 1 else None
|
||||
use_index_map = existing_total == len(events_data) if existing_total is not None else False
|
||||
|
||||
# Setup Helligdage
|
||||
dk_holidays = holidays.DK()
|
||||
|
||||
print(f"{Colors.HEADER}--- Genererer kalender: {meta.get('name')} ---{Colors.ENDC}")
|
||||
print(f"Startdato: {config['start_date']}")
|
||||
print(f"Blokerede uger: {config['blocked_weeks']}")
|
||||
if dry_run:
|
||||
print(f"{Colors.OKBLUE}[DRY RUN] Ingen filer skrives, og JSON opdateres ikke.{Colors.ENDC}")
|
||||
|
||||
cal = Calendar()
|
||||
uid_html_map: dict[str, str] = {}
|
||||
seen_uids: set[str] = set()
|
||||
preview_items: list[dict[str, Any]] = []
|
||||
skipped_dates: list[dict[str, str]] = []
|
||||
story_updated = False
|
||||
|
||||
# Start Logic
|
||||
current_date = next_or_same_friday(config['start_date_obj'])
|
||||
last_event_date = None
|
||||
|
||||
total_logs = len(events_data)
|
||||
|
||||
# Loop over hver HISTORIE del (vi skipper ikke historien, kun datoen)
|
||||
for idx, event_entry in enumerate(events_data):
|
||||
log_num = idx + 1
|
||||
|
||||
# Find næste ledige dato
|
||||
while True:
|
||||
is_blocked, reason = is_blocked_date(current_date, dk_holidays, config)
|
||||
if not is_blocked:
|
||||
break # Datoen er god!
|
||||
|
||||
# Hvis blokeret, print info og hop en uge frem
|
||||
print(f"{Colors.FAIL} >> SKIPPER {current_date}: {reason}{Colors.ENDC}")
|
||||
skipped_dates.append(
|
||||
{
|
||||
"date": current_date.strftime("%Y-%m-%d"),
|
||||
"reason": reason or "Blokeret dato",
|
||||
}
|
||||
)
|
||||
current_date += timedelta(weeks=1)
|
||||
|
||||
# Gap Check (Advarsel hvis der er lang tid siden sidst)
|
||||
if last_event_date:
|
||||
days_since_last = (current_date - last_event_date).days
|
||||
if days_since_last > 21:
|
||||
print(f"{Colors.WARNING} [!] ADVARSEL: Stort hul i kalenderen ({days_since_last} dage) mellem sidste event og nu.{Colors.ENDC}")
|
||||
|
||||
# UID validering og autofyld til story JSON
|
||||
title = event_entry.get("title", "")
|
||||
uid_from_ics = None
|
||||
if title and title in existing_title_uids:
|
||||
uid_from_ics = existing_title_uids[title]
|
||||
elif use_index_map and log_num in existing_index_uids:
|
||||
uid_from_ics = existing_index_uids[log_num]
|
||||
|
||||
existing_uid_raw = event_entry.get("uid")
|
||||
existing_uid = normalize_uid(existing_uid_raw)
|
||||
if existing_uid_raw is not None and not existing_uid:
|
||||
print(f"{Colors.FAIL}Ugyldigt UID (tom værdi) for '{event_entry.get('title')}'.{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
if existing_uid and uid_from_ics and existing_uid != uid_from_ics:
|
||||
print(f"{Colors.FAIL}UID mismatch for '{event_entry.get('title')}': {existing_uid} != {uid_from_ics}{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
if existing_uid:
|
||||
my_uid = existing_uid
|
||||
elif uid_from_ics:
|
||||
my_uid = uid_from_ics
|
||||
else:
|
||||
my_uid = deterministic_event_uid(story_path, meta, event_entry, log_num, config)
|
||||
|
||||
if my_uid in seen_uids:
|
||||
print(f"{Colors.FAIL}Duplikeret UID '{my_uid}' for '{event_entry.get('title')}'.{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
seen_uids.add(my_uid)
|
||||
if not dry_run and existing_uid != my_uid:
|
||||
event_entry["uid"] = my_uid
|
||||
story_updated = True
|
||||
|
||||
# Opret Event
|
||||
start_dt = datetime.combine(current_date, config['start_time_obj']).replace(tzinfo=timezone_info)
|
||||
end_dt = start_dt + timedelta(minutes=duration_minutes)
|
||||
|
||||
event = Event()
|
||||
event.uid = my_uid
|
||||
event.name = event_entry["title"]
|
||||
event.begin = start_dt
|
||||
event.end = end_dt
|
||||
event.status = "CONFIRMED"
|
||||
event.classification = "PUBLIC"
|
||||
if config.get("organizer_email"):
|
||||
event.organizer = config.get("organizer_email")
|
||||
|
||||
html_content = generate_html_content(event_entry, meta, log_num, total_logs, repo_url)
|
||||
preview_items.append(
|
||||
{
|
||||
"start": start_dt,
|
||||
"end": end_dt,
|
||||
"title": event_entry["title"],
|
||||
"html": html_content,
|
||||
}
|
||||
)
|
||||
|
||||
clean_story = html_to_text(event_entry["story"])
|
||||
hint_text = f" // {event_entry.get('hint', '')}" if event_entry.get('hint') else ""
|
||||
log_prefix = meta.get("log_prefix", "LOG")
|
||||
|
||||
plain_desc = (
|
||||
f"// {log_prefix} {log_num}/{total_logs} // {event_entry['title']}{hint_text}\n\n"
|
||||
f"{clean_story}\n\n"
|
||||
"-------------------\n"
|
||||
f"{repo_url}"
|
||||
)
|
||||
event.description = plain_desc
|
||||
|
||||
cal.events.add(event)
|
||||
uid_html_map[my_uid] = html_content
|
||||
|
||||
print(f"{Colors.OKGREEN}[{log_num}/{total_logs}] {current_date}: {event_entry['title']}{Colors.ENDC}")
|
||||
|
||||
last_event_date = current_date
|
||||
# Gør klar til næste uge
|
||||
current_date += timedelta(weeks=1)
|
||||
|
||||
if dry_run:
|
||||
print(f"\n{Colors.OKBLUE}Dry-run færdig. Ingen filer skrevet.{Colors.ENDC}")
|
||||
return
|
||||
|
||||
# Gem fil
|
||||
full_ical_text = cal.serialize()
|
||||
last_modified = datetime.now(tz=timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
final_text = inject_outlook_hacks(full_ical_text, uid_html_map, last_modified=last_modified)
|
||||
|
||||
with open(out_path, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(final_text)
|
||||
|
||||
if preview_html:
|
||||
preview_filename = f"PREVIEW_{story_slug}.html"
|
||||
preview_path = os.path.join(output_dir, preview_filename)
|
||||
preview_html_text = build_preview_html(
|
||||
meta,
|
||||
config,
|
||||
preview_items,
|
||||
skipped_dates,
|
||||
timezone_name,
|
||||
)
|
||||
with open(preview_path, "w", encoding="utf-8") as f:
|
||||
f.write(preview_html_text)
|
||||
print(f"{Colors.OKBLUE}HTML preview gemt: {preview_path}{Colors.ENDC}")
|
||||
|
||||
if story_updated:
|
||||
try:
|
||||
with open(story_path, "w", encoding="utf-8") as f:
|
||||
json.dump(story_data, f, ensure_ascii=False, indent=2)
|
||||
f.write("\n")
|
||||
except Exception as e:
|
||||
print(f"{Colors.FAIL}Kunne ikke opdatere historie-fil med UID'er: {e}{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n{Colors.OKBLUE}Færdig! Fil gemt: {out_path}{Colors.ENDC}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Generer ICS fil med feriehåndtering.")
|
||||
parser.add_argument("story_file", help="Sti til JSON filen")
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Vis planlagte datoer og skippede uger uden at skrive filer eller opdatere JSON.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clear-uids",
|
||||
action="store_true",
|
||||
help="Fjern events[].uid fra story JSON før generering (ingen filskrivning ved --dry-run).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--preview-html",
|
||||
action="store_true",
|
||||
help="Gem en HTML preview med planlagte datoer og HTML-indhold.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
help="Sti til config.toml (eller config.json).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=OUTPUT_DIR,
|
||||
help="Output mappe til .ics og preview.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timezone",
|
||||
default=DEFAULT_TIMEZONE,
|
||||
help="IANA timezone navn (fx Europe/Copenhagen).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--duration-minutes",
|
||||
type=int,
|
||||
default=60,
|
||||
help="Event varighed i minutter.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color",
|
||||
action="store_true",
|
||||
help="Slå farver fra i terminal output.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.no_color:
|
||||
disable_colors()
|
||||
|
||||
if not os.path.exists(args.story_file):
|
||||
print(f"{Colors.FAIL}Historie-fil ikke fundet.{Colors.ENDC}")
|
||||
sys.exit(1)
|
||||
|
||||
generate_single_file(
|
||||
args.story_file,
|
||||
dry_run=args.dry_run,
|
||||
clear_uids=args.clear_uids,
|
||||
preview_html=args.preview_html,
|
||||
config_path=args.config,
|
||||
output_dir=args.output_dir,
|
||||
timezone_name=args.timezone,
|
||||
duration_minutes=args.duration_minutes,
|
||||
)
|
||||
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal 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
|
||||
73
stories/mysteriet_i_java_hulen.json
Normal file
73
stories/mysteriet_i_java_hulen.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
133
stories/sagen_om_koglepen.json
Normal file
133
stories/sagen_om_koglepen.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,102 +12,122 @@
|
||||
{
|
||||
"title": "Ugentlig tværgående synkronisering",
|
||||
"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",
|
||||
"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)",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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)",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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
23
stories/template.json
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test package."""
|
||||
126
tests/test_cli_options.py
Normal file
126
tests/test_cli_options.py
Normal 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)
|
||||
38
tests/test_config_validation.py
Normal file
38
tests/test_config_validation.py
Normal 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")
|
||||
56
tests/test_date_skipping.py
Normal file
56
tests/test_date_skipping.py
Normal 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")
|
||||
25
tests/test_html_injection.py
Normal file
25
tests/test_html_injection.py
Normal 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
60
tests/test_no_color.py
Normal 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
132
tests/test_preview_html.py
Normal 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
27
tests/test_uid_mapping.py
Normal 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
212
validation.py
Normal 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
|
||||
Reference in New Issue
Block a user