diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..939fbd9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +.env +.env.* +!.env.example +.pytest_cache/ +.DS_Store +node_modules/ +frontend/node_modules/ +frontend/dist/ +logs/ +reports/*.docx +reports/*.md +*.egg-info/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c8c25f7 --- /dev/null +++ b/.env.example @@ -0,0 +1,34 @@ +# --- Anthropic --- +ANTHROPIC_API_KEY=sk-ant-... + +# --- Airtable: tasks (Master tracker) --- +PULSE_AIRTABLE_API_KEY=pat... +PULSE_AIRTABLE_BASE_ID=appXXXXXXXXXXXXXX +PULSE_AIRTABLE_TABLE_ID=tblXXXXXXXXXXXXXX + +# --- Airtable: resource bookings --- +# Falls back to PULSE_AIRTABLE_API_KEY if PULSE_RESOURCE_API_KEY is unset +PULSE_RESOURCE_API_KEY=pat... +PULSE_RESOURCE_BASE_ID=appXXXXXXXXXXXXXX +PULSE_RESOURCE_TABLE_ID=tblXXXXXXXXXXXXXX + +# --- Azure AD (Microsoft SSO) --- +# Single-page application registration in your Azure tenant. +# Add redirect URIs for both prod and local dev. +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +# Comma-separated list (e.g. oliver.agency). Empty = allow any tenant user. +AUTH_ALLOWED_DOMAINS=oliver.agency +# Local dev only — skips token validation, sets g.user_email = 'dev@oliver.agency' +DEV_AUTH_BYPASS=false + +# --- Postgres --- +# Inside the compose network the host is `db`; override for local-against-host runs. +DATABASE_URL=postgresql+psycopg://pulse:pulse@db:5432/pulse +POSTGRES_USER=pulse +POSTGRES_PASSWORD=pulse +POSTGRES_DB=pulse + +# --- Server --- +# Host port the deploy script picks; the container always listens on 5051. +PROGRAMME_PULSE_PORT=5051 diff --git a/.gitignore b/.gitignore index b24d71e..3f44d98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,30 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore - -# Node artifact files -node_modules/ -dist/ - -# Compiled Java class files -*.class - -# Compiled Python bytecode -*.py[cod] - -# Log files -*.log - -# Package files -*.jar - -# Maven -target/ -dist/ - -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS +.env +.env.* +!.env.example .DS_Store - -# Generated by Windows Thumbs.db +__pycache__/ +*.py[cod] +*.log +logs/ +reports/*.docx +reports/*.md +*.egg-info/ +.venv/ +venv/ -# Applications -*.app -*.exe -*.war +# deploy artefacts +deploy/apache-programme-pulse.conf +.deployed -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv +# frontend +frontend/node_modules/ +frontend/dist/ +frontend/.env +frontend/.env.local +frontend/tsconfig.tsbuildinfo +# editor / tooling +.claude/ +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c370a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . + +ENV PORT=5051 +EXPOSE 5051 + +# Run migrations on every start (idempotent), then gunicorn with gevent for SSE. +CMD ["sh", "-c", "alembic upgrade head && exec gunicorn web_app:app --bind 0.0.0.0:${PORT} --worker-class gevent --workers 1 --timeout 300 --access-logfile -"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..0eaf2f4 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn web_app:app --bind 0.0.0.0:$PORT --worker-class gevent --workers 1 --timeout 300 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ab7cfc6 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Programme Pulse + +A small Flask app for chatting with live Airtable programme data and generating two-tier status reports (Manager Summary + Full Report) as Word documents. + +Runs locally on port 5051 by default. + +--- + +## What it does + +- Loads tasks from one Airtable base (the Master tracker) and resource bookings from a second Airtable base. +- Lets you chat with the data through Claude. Streaming response, stop button, thumbs up/down feedback. +- Reads `.docx` meeting transcripts from `docs/Programme Pulse transcripts/` and uses them as supporting context. +- Generates two reports on demand: a Manager Summary and a Full Report. Both saved as `.docx` in `reports/`. +- Saves your stated preferences to `data/preferences.md` so future sessions remember them. + +--- + +## Requirements + +- Python 3.11 or newer +- An Anthropic API key +- Two Airtable bases with personal access tokens + +--- + +## Setup + +1. Create and activate a virtual environment. + + ```sh + python3 -m venv .venv + source .venv/bin/activate + ``` + +2. Install dependencies. + + ```sh + pip install -r requirements.txt + ``` + +3. Copy the example env file and fill in your own keys. + + ```sh + cp .env.example .env + ``` + + Open `.env` and add: + - `ANTHROPIC_API_KEY` + - `PULSE_AIRTABLE_API_KEY`, `PULSE_AIRTABLE_BASE_ID`, `PULSE_AIRTABLE_TABLE_ID` — the tasks base + - `PULSE_RESOURCE_API_KEY`, `PULSE_RESOURCE_BASE_ID`, `PULSE_RESOURCE_TABLE_ID` — the resource booking base + - `FLASK_SECRET_KEY` — any random string + +4. Run the app. + + ```sh + python web_app.py + ``` + + Or with gunicorn (recommended — streaming works better): + + ```sh + gunicorn web_app:app --bind 0.0.0.0:5051 --worker-class gevent --workers 1 --timeout 300 + ``` + +5. Open http://localhost:5051 in your browser. + +--- + +## Adding meeting transcripts + +Drop Teams `.docx` transcript exports into `docs/Programme Pulse transcripts/`. The app picks them up on restart and feeds them to the chat and the reports as supporting narrative. + +Each `.docx` is parsed paragraph by paragraph. One speaker turn per paragraph. + +--- + +## Folder layout + +``` +web_app.py Flask entry point +src/ + airtable_client.py Pulls tasks and bookings from Airtable + analyzer.py Health signal logic + claude_client.py Anthropic SDK wrapper, streaming + preferences.py Saves liked/disliked patterns + prompts.py System prompts and snapshot builders + reporter.py Word document generation + transcripts.py Parses .docx meeting transcripts +templates/index.html The single-page UI +design/ Fonts and design tokens +data/ preferences.md lives here +docs/Programme Pulse transcripts/ Drop .docx files here +reports/ Generated Word files land here +logs/ Runtime logs +tests/ Pytest suite +``` + +--- + +## Airtable schema notes + +The tasks base must include these fields (rename the constants in `src/airtable_client.py` if your column names differ): +- Title / task name +- Owner (collaborator) +- Status / Progress +- Priority +- Due date +- Notes +- Last modified + +The resource base needs: +- Resource name +- Project +- Division +- Start date, end date +- Hours +- Status +- Conflict flag + +Open `src/airtable_client.py` to map field names to your own base. + +--- + +## Running tests + +```sh +pytest +``` + +--- + +## Deploying elsewhere + +The app is stateless apart from `data/preferences.md`, `reports/`, and `logs/`. Mount those as volumes if you containerise it. The `Procfile` is set up for any platform that respects it (Heroku, Render, Fly, etc). + +Set `PORT` via env var. Everything else is read from `.env` or the platform's secret store. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a97945f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..43b6ca5 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.db import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# DATABASE_URL from env wins over alembic.ini +db_url = os.getenv("DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..b1f8b89 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..4168b38 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,70 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-05-07 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +revision: str = "0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "preferences", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_email", sa.String(320), nullable=False), + sa.Column("text", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_preferences_user_email", "preferences", ["user_email"]) + + op.create_table( + "chat_messages", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_email", sa.String(320), nullable=False), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("content", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_chat_user_created", "chat_messages", ["user_email", "created_at"]) + + op.create_table( + "reports", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_email", sa.String(320), nullable=False), + sa.Column("summary_md", sa.Text, nullable=False), + sa.Column("summary_doc_path", sa.String(512), nullable=False), + sa.Column("full_doc_path", sa.String(512), nullable=False), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_reports_user_email", "reports", ["user_email"]) + + op.create_table( + "feedback_events", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_email", sa.String(320), nullable=False), + sa.Column("rating", sa.String(8), nullable=False), + sa.Column("message_text", sa.Text, nullable=False), + sa.Column("extracted_insight", sa.Text, nullable=False), + sa.Column("preference_id", sa.Integer, sa.ForeignKey("preferences.id"), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_feedback_user_email", "feedback_events", ["user_email"]) + + +def downgrade() -> None: + op.drop_table("feedback_events") + op.drop_table("reports") + op.drop_table("chat_messages") + op.drop_table("preferences") diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/apache-programme-pulse.conf.tmpl b/deploy/apache-programme-pulse.conf.tmpl new file mode 100644 index 0000000..aa88f77 --- /dev/null +++ b/deploy/apache-programme-pulse.conf.tmpl @@ -0,0 +1,21 @@ +ProxyTimeout 300 +TimeOut 300 + +# Backend API — proxied to the Flask container on the chosen host port. +# flushpackets=on is essential for the SSE chat stream; without it Apache +# buffers and tokens stutter. +ProxyPass /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300 flushpackets=on +ProxyPassReverse /programme-pulse/api/ http://127.0.0.1:__APP_PORT__/api/ + +# SPA — static files served by Apache directly from the build output. +Alias /programme-pulse /var/www/html/programme-pulse + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + RewriteEngine On + RewriteBase /programme-pulse/ + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.html [L] + diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..ef8800f --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,278 @@ +#!/usr/bin/env bash +# Programme Pulse — deploy script. +# +# Idempotent. Safe to re-run on the dev server. +# Public URL: https://optical-dev.oliver.solutions/programme-pulse/ +# +# Server layout (mirrors /oliver-sales-ops-platform/, /gsb/, etc.): +# /opt/programme-pulse/ — repo + docker-compose +# /var/www/html/programme-pulse/ — built SPA, served by Apache +# +# What it does: +# 1. Sanity (.env, docker, git on PATH). +# 2. Auto-pick free host port (preferred: PROGRAMME_PULSE_PORT, default 5051). +# If taken, scans 5051..5099 and persists the chosen value to .env. +# 3. Render deploy/apache-programme-pulse.conf from .tmpl. +# 4. git pull (--no-pull to skip). +# 5. docker compose build && up -d (--no-build to skip). +# 6. Build frontend SPA in a one-shot node:20 container and rsync +# dist/ to /var/www/html/programme-pulse/ (--no-frontend to skip). +# 7. Poll /api/health until ready. +# 8. Print the apache Include line and reload reminder. +# +# Usage: +# ./deploy/deploy.sh # full deploy +# ./deploy/deploy.sh --no-pull # skip git pull +# ./deploy/deploy.sh --no-build # skip docker rebuild +# ./deploy/deploy.sh --no-frontend # skip SPA build/copy +# ./deploy/deploy.sh --logs # tail backend logs after deploy + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_PROJECT="programme-pulse" +URL_PATH="/programme-pulse" +WEB_ROOT="/var/www/html/programme-pulse" + +cd "$REPO_ROOT" + +log() { printf '\033[1;36m[deploy]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[deploy]\033[0m %s\n' "$*" >&2; } +ok() { printf '\033[1;32m[deploy]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[deploy]\033[0m %s\n' "$*"; } + +DO_PULL=1 +DO_BUILD=1 +DO_FRONTEND=1 +TAIL_LOGS=0 + +for arg in "$@"; do + case "$arg" in + --no-pull) DO_PULL=0 ;; + --no-build) DO_BUILD=0 ;; + --no-frontend) DO_FRONTEND=0 ;; + --logs) TAIL_LOGS=1 ;; + --help|-h) + sed -n '2,/^set/p' "$0" | grep -E '^# ' | sed 's/^# //' + exit 0 + ;; + *) + err "Unknown flag: $arg (try --help)" + exit 2 + ;; + esac +done + +# 1. Sanity +[[ -f docker-compose.yml ]] || { err "docker-compose.yml not found in $REPO_ROOT"; exit 1; } +if [[ ! -f .env ]]; then + err ".env not found. Copy .env.example and fill it in:" + err " cp .env.example .env && \$EDITOR .env" + exit 1 +fi +command -v docker >/dev/null 2>&1 || { err "docker not on PATH"; exit 1; } +command -v git >/dev/null 2>&1 || { err "git not on PATH"; exit 1; } + +# ---------- helpers ---------- + +port_in_use() { + local port=$1 + local pid="" + if command -v lsof >/dev/null 2>&1; then + pid=$( { lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null || true; } | awk 'NR>1 {print $2}' | head -1 ) + else + pid=$( { ss -ltnp "sport = :$port" 2>/dev/null || true; } | awk -F'pid=' 'NR>1 {print $2}' | cut -d, -f1 | head -1 ) + fi + [[ -n "$pid" ]] +} + +find_free_port() { + local preferred=$1 + local start=$2 + local end=$3 + if ! port_in_use "$preferred"; then + printf '%s' "$preferred" + return 0 + fi + local p + for ((p=start; p<=end; p++)); do + if ! port_in_use "$p"; then + printf '%s' "$p" + return 0 + fi + done + return 1 +} + +set_env_var() { + local key=$1 + local value=$2 + local file="${REPO_ROOT}/.env" + if grep -q "^${key}=" "$file" 2>/dev/null; then + sed -i.bak "s#^${key}=.*#${key}=${value}#" "$file" + rm -f "${file}.bak" + else + printf '%s=%s\n' "$key" "$value" >> "$file" + fi +} + +get_env_var() { + grep -E "^${1}=" "${REPO_ROOT}/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"' || true +} + +# ---------- 2. Pick port ---------- + +DEFAULT_PORT=5051 +APP_PORT=$(get_env_var PROGRAMME_PULSE_PORT); APP_PORT=${APP_PORT:-$DEFAULT_PORT} +PREV_APP_PORT="$APP_PORT" + +log "Resolving host port (preferred: $APP_PORT)…" + +RUNNING=$(docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | wc -l | tr -d ' ') +if [[ "$RUNNING" -gt 0 ]]; then + ok "Project '$COMPOSE_PROJECT' already has $RUNNING containers running — keeping current port assignment." +else + NEW_PORT=$(find_free_port "$APP_PORT" 5051 5099) || NEW_PORT="" + if [[ -z "$NEW_PORT" ]]; then + err "Could not find a free port in 5051..5099." + exit 1 + fi + [[ "$NEW_PORT" != "$APP_PORT" ]] && warn "port $APP_PORT busy → using $NEW_PORT" + APP_PORT=$NEW_PORT + set_env_var PROGRAMME_PULSE_PORT "$APP_PORT" + ok "Port: $APP_PORT (persisted to .env)" +fi + +# ---------- 3. Render apache conf ---------- + +APACHE_TMPL="$REPO_ROOT/deploy/apache-programme-pulse.conf.tmpl" +APACHE_CONF="$REPO_ROOT/deploy/apache-programme-pulse.conf" +if [[ -f "$APACHE_TMPL" ]]; then + sed "s#__APP_PORT__#${APP_PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF" + ok "Rendered apache-programme-pulse.conf with app port $APP_PORT" +else + warn "apache-programme-pulse.conf.tmpl missing — leaving deploy/apache-programme-pulse.conf untouched." +fi + +# ---------- 4. git pull ---------- + +if (( DO_PULL )); then + log "git pull origin main" + git pull --ff-only origin main +fi + +# ---------- 5. Backend build + up ---------- + +if (( DO_BUILD )); then + log "docker compose build" + docker compose -p "$COMPOSE_PROJECT" build +fi + +log "docker compose up -d (db + app)" +docker compose -p "$COMPOSE_PROJECT" up -d + +# ---------- 6. Frontend build + sync ---------- + +if (( DO_FRONTEND )); then + AZ_TENANT=$(get_env_var VITE_AZURE_TENANT_ID); AZ_TENANT=${AZ_TENANT:-$(get_env_var AZURE_TENANT_ID)} + AZ_CLIENT=$(get_env_var VITE_AZURE_CLIENT_ID); AZ_CLIENT=${AZ_CLIENT:-$(get_env_var AZURE_CLIENT_ID)} + if [[ -z "$AZ_TENANT" || -z "$AZ_CLIENT" ]]; then + err "AZURE_TENANT_ID and AZURE_CLIENT_ID must be set in .env to build the SPA." + exit 1 + fi + # Mirror DEV_AUTH_BYPASS into VITE_DEV_AUTH_BYPASS so a single env var + # decides both backend token validation and frontend MSAL gating. + VITE_BYPASS=$(get_env_var VITE_DEV_AUTH_BYPASS) + [[ -z "$VITE_BYPASS" ]] && VITE_BYPASS=$(get_env_var DEV_AUTH_BYPASS) + [[ -z "$VITE_BYPASS" ]] && VITE_BYPASS="false" + + log "Building Vite SPA (VITE_DEV_AUTH_BYPASS=${VITE_BYPASS}) in a one-shot node:20 container…" + docker run --rm \ + -v "$REPO_ROOT/frontend:/app" \ + -w /app \ + -e VITE_AZURE_TENANT_ID="$AZ_TENANT" \ + -e VITE_AZURE_CLIENT_ID="$AZ_CLIENT" \ + -e VITE_API_BASE="/programme-pulse/api" \ + -e VITE_DEV_AUTH_BYPASS="$VITE_BYPASS" \ + node:20-alpine \ + sh -c "npm install --silent && npm run build" + + if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then + err "Vite build did not produce frontend/dist — aborting frontend sync." + exit 1 + fi + + log "Syncing frontend/dist/ → $WEB_ROOT/" + if [[ ! -d "$WEB_ROOT" ]]; then + if command -v sudo >/dev/null 2>&1; then + sudo mkdir -p "$WEB_ROOT" + else + mkdir -p "$WEB_ROOT" + fi + fi + + if command -v rsync >/dev/null 2>&1; then + if [[ -w "$WEB_ROOT" ]]; then + rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/" + else + sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$WEB_ROOT/" + fi + else + if [[ -w "$WEB_ROOT" ]]; then + rm -rf "$WEB_ROOT"/* + cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/" + else + sudo rm -rf "$WEB_ROOT"/* + sudo cp -a "$REPO_ROOT/frontend/dist/." "$WEB_ROOT/" + fi + fi + ok "SPA synced to $WEB_ROOT" +fi + +# ---------- 7. Health poll ---------- + +log "Waiting for /api/health on :$APP_PORT (max 60s)…" +for i in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${APP_PORT}/api/health" >/dev/null 2>&1; then + ok "Backend healthy" + break + fi + sleep 2 + if (( i == 30 )); then + err "Backend did not become healthy within 60s. Recent logs:" + docker compose -p "$COMPOSE_PROJECT" logs app --tail 40 || true + exit 1 + fi +done + +# ---------- 8. Report ---------- + +ok "Deploy complete." +echo +echo " Backend (local): http://127.0.0.1:${APP_PORT}/api/health" +echo " Public URL: https://optical-dev.oliver.solutions${URL_PATH}/" +echo " SPA on disk: $WEB_ROOT" +echo " Port: $APP_PORT" +echo +echo " Apache include line for the merged vhost:" +echo " Include $REPO_ROOT/deploy/apache-programme-pulse.conf" +if [[ "$APP_PORT" != "$PREV_APP_PORT" ]] || ! grep -qF "$REPO_ROOT/deploy/apache-programme-pulse.conf" /etc/apache2/sites-enabled/*.conf 2>/dev/null; then + echo + warn "Backend port changed (or first deploy). Add the Include line above to:" + echo " /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf" + echo " inside , then reload Apache:" + echo " sudo apachectl configtest && sudo systemctl reload apache2" +fi +TRANSCRIPTS_DIR="$REPO_ROOT/docs/Programme Pulse transcripts" +if [[ ! -d "$TRANSCRIPTS_DIR" ]] || [[ -z "$(ls -A "$TRANSCRIPTS_DIR" 2>/dev/null)" ]]; then + echo + warn "Transcripts folder is empty: $TRANSCRIPTS_DIR" + echo " Drop Teams .docx exports there to give the chat narrative context." +fi +echo + +if (( TAIL_LOGS )); then + log "Tailing app logs (Ctrl-C to stop)…" + docker compose -p "$COMPOSE_PROJECT" logs -f app +fi diff --git a/design/fonts/typography.css b/design/fonts/typography.css new file mode 100644 index 0000000..e401b19 --- /dev/null +++ b/design/fonts/typography.css @@ -0,0 +1,13 @@ +@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@700;900&family=Inter:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@700;900&family=Commissioner:wght@400;500&display=swap'); + +/* Recommended pairing: Merriweather + Inter */ +:root { + --font-heading: 'Merriweather', serif; + --font-body: 'Inter', sans-serif; +} + +/* Alternatives */ +/* Option 2: DM Serif Display + DM Sans */ +/* Option 3: Fraunces + Commissioner */ diff --git a/design/tokens/colors.json b/design/tokens/colors.json new file mode 100644 index 0000000..d4ac55a --- /dev/null +++ b/design/tokens/colors.json @@ -0,0 +1,18 @@ +{ + "primary": "#0071e3", + "secondary": "#cc7217", + "accent": "#7d00fa", + "neutrals": [ + "#171f27", + "#51575d", + "#8b8f93", + "#c5c7c9", + "#ffffff" + ], + "semantic": { + "success": "#22c55e", + "warning": "#f59e0b", + "error": "#ef4444", + "info": "#3b82f6" + } +} \ No newline at end of file diff --git a/design/tokens/tailwind.config.ts b/design/tokens/tailwind.config.ts new file mode 100644 index 0000000..a7b3c97 --- /dev/null +++ b/design/tokens/tailwind.config.ts @@ -0,0 +1,26 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + theme: { + extend: { + colors: { + primary: '#0071e3', + secondary: '#cc7217', + accent: '#7d00fa', + neutral: { + 100: '#ffffff', + 200: '#c5c7c9', + 300: '#8b8f93', + 400: '#51575d', + 500: '#171f27', + }, + success: '#22c55e', + warning: '#f59e0b', + error: '#ef4444', + info: '#3b82f6', + }, + }, + }, +} + +export default config diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..98766c9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,43 @@ +# Top-level project name pinned per global Docker policy — without this, the +# compose file (when run from /opt/programme-pulse via deploy.sh) would default +# to project name "programme-pulse" anyway, but pinning it is explicit and +# survives if the file ever moves under deploy/. +name: programme-pulse + +services: + app: + build: . + restart: unless-stopped + ports: + # Bind only to loopback — Apache fronts the container. + - "127.0.0.1:${PROGRAMME_PULSE_PORT:-5051}:5051" + environment: + - PORT=5051 + - DATABASE_URL=postgresql+psycopg://${POSTGRES_USER:-pulse}:${POSTGRES_PASSWORD:-pulse}@db:5432/${POSTGRES_DB:-pulse} + env_file: + - .env + volumes: + - ./reports:/app/reports + - ./docs/Programme Pulse transcripts:/app/docs/Programme Pulse transcripts:ro + - ./logs:/app/logs + depends_on: + db: + condition: service_healthy + + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-pulse} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pulse} + POSTGRES_DB: ${POSTGRES_DB:-pulse} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-pulse} -d ${POSTGRES_DB:-pulse}"] + interval: 5s + timeout: 3s + retries: 12 + +volumes: + pgdata: diff --git a/docs/Programme Pulse transcripts/.gitkeep b/docs/Programme Pulse transcripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..60833c2 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,15 @@ +# Azure AD app registration (Single-page application platform). +# Add these as redirect URIs in the registration: +# https://optical-dev.oliver.solutions/programme-pulse/ +# http://localhost:5173/programme-pulse/ +VITE_AZURE_TENANT_ID= +VITE_AZURE_CLIENT_ID= + +# API base. Production: same-origin via Apache proxy → /programme-pulse/api. +# Dev: Vite proxy forwards /programme-pulse/api → http://localhost:5051/api. +VITE_API_BASE=/programme-pulse/api + +# Set to "true" to skip the MSAL sign-in gate locally. Must be paired with +# DEV_AUTH_BYPASS=true on the backend. The SPA will render straight to the +# signed-in UI and apiFetch will send requests without an Authorization header. +VITE_DEV_AUTH_BYPASS=false diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b563ef3 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Programme Pulse + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bc5d04a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3265 @@ +{ + "name": "programme-pulse-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "programme-pulse-frontend", + "version": "1.0.0", + "dependencies": { + "@azure/msal-browser": "^3.27.0", + "@azure/msal-react": "^2.2.0", + "chart.js": "^4.4.7", + "react": "^18.3.1", + "react-chartjs-2": "^5.3.0", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.30.0.tgz", + "integrity": "sha512-I0XlIGVdM4E9kYP5eTjgW8fgATdzwxJvQ6bm2PNiHaZhEuUz47NYw1xHthC9R+lXz4i9zbShS0VdLyxd7n0GGA==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-2.2.0.tgz", + "integrity": "sha512-2V+9JXeXyyjYNF92y5u0tU4el9px/V1+vkRuN+DtoxyiMHCtYQpJoaFdGWArh43zhz5aqQqiGW/iajPDSu3QsQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@azure/msal-browser": "^3.27.0", + "react": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.352", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.352.tgz", + "integrity": "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..56d612f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "programme-pulse-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@azure/msal-browser": "^3.27.0", + "@azure/msal-react": "^2.2.0", + "chart.js": "^4.4.7", + "react": "^18.3.1", + "react-chartjs-2": "^5.3.0", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..af0410b --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,49 @@ +import { AuthenticatedTemplate, UnauthenticatedTemplate, useMsal } from '@azure/msal-react'; +import { loginRequest } from './auth/authConfig'; +import { DEV_AUTH_BYPASS } from './auth/devBypass'; +import Header from './components/Header'; +import ChatPanel from './components/ChatPanel'; +import ReportsCard from './components/ReportsCard'; + +export default function App() { + if (DEV_AUTH_BYPASS) return ; + + return ( + <> + + + + + + + + ); +} + +function SignIn() { + const { instance } = useMsal(); + return ( +
+
+
+

Programme Pulse

+

Sign in with your OLIVER account to continue.

+ +
+
+ ); +} + +function SignedInApp() { + return ( + <> +
+
+ + +
+ + ); +} diff --git a/frontend/src/auth/apiFetch.ts b/frontend/src/auth/apiFetch.ts new file mode 100644 index 0000000..fd2dc79 --- /dev/null +++ b/frontend/src/auth/apiFetch.ts @@ -0,0 +1,118 @@ +import { InteractionRequiredAuthError } from '@azure/msal-browser'; +import { loginRequest, msalInstance } from './authConfig'; +import { DEV_AUTH_BYPASS } from './devBypass'; + +const API_BASE = (import.meta.env.VITE_API_BASE as string) || '/programme-pulse/api'; + +async function getIdToken(): Promise { + if (DEV_AUTH_BYPASS) return null; + + const account = msalInstance.getActiveAccount() ?? msalInstance.getAllAccounts()[0]; + if (!account) throw new Error('No active account — sign in required'); + + try { + const result = await msalInstance.acquireTokenSilent({ ...loginRequest, account }); + // Send the ID token, NOT the access token (per azure-ad-msal-auth skill gotcha). + return result.idToken; + } catch (err) { + if (err instanceof InteractionRequiredAuthError) { + await msalInstance.acquireTokenRedirect({ ...loginRequest, account }); + } + throw err; + } +} + +function buildUrl(path: string): string { + if (path.startsWith('http')) return path; + if (path.startsWith('/api/')) return API_BASE + path.slice(4); + if (path.startsWith('/')) return API_BASE + path; + return API_BASE + '/' + path; +} + +function on401() { + if (DEV_AUTH_BYPASS) return; + msalInstance.loginRedirect(loginRequest); +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + const idToken = await getIdToken(); + const headers = new Headers(init.headers); + if (idToken) headers.set('Authorization', `Bearer ${idToken}`); + if (init.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + const res = await fetch(buildUrl(path), { ...init, headers }); + if (res.status === 401) { + on401(); + throw new Error('Authentication required'); + } + return res; +} + +export async function apiJson(path: string, init: RequestInit = {}): Promise { + const res = await apiFetch(path, init); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + return res.json() as Promise; +} + +export function apiUrl(path: string): string { + return buildUrl(path); +} + +export async function streamSSE( + path: string, + body: unknown, + onChunk: (text: string) => void, + signal: AbortSignal, +): Promise<{ aborted: boolean; error?: string }> { + const idToken = await getIdToken(); + const headers: Record = { 'Content-Type': 'application/json' }; + if (idToken) headers.Authorization = `Bearer ${idToken}`; + + const res = await fetch(buildUrl(path), { + method: 'POST', + headers, + body: JSON.stringify(body), + signal, + }); + + if (res.status === 401) { + on401(); + return { aborted: false, error: 'Authentication required' }; + } + if (!res.ok || !res.body) { + return { aborted: false, error: `${res.status} ${res.statusText}` }; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const payload = line.slice(6); + if (payload === '[DONE]') return { aborted: false }; + try { + const data = JSON.parse(payload); + if (data.error) return { aborted: false, error: String(data.error) }; + if (typeof data.chunk === 'string') onChunk(data.chunk); + } catch { + // Partial JSON between chunks — ignore. + } + } + } + } catch (e) { + if ((e as Error).name === 'AbortError') return { aborted: true }; + throw e; + } + return { aborted: false }; +} diff --git a/frontend/src/auth/authConfig.ts b/frontend/src/auth/authConfig.ts new file mode 100644 index 0000000..8afa917 --- /dev/null +++ b/frontend/src/auth/authConfig.ts @@ -0,0 +1,34 @@ +import { Configuration, LogLevel, PublicClientApplication } from '@azure/msal-browser'; + +const tenantId = import.meta.env.VITE_AZURE_TENANT_ID as string; +const clientId = import.meta.env.VITE_AZURE_CLIENT_ID as string; + +export const loginRequest = { + scopes: ['User.Read'], +}; + +const msalConfig: Configuration = { + auth: { + clientId, + authority: `https://login.microsoftonline.com/${tenantId}`, + // Vite's `base` makes window.location.origin + import.meta.env.BASE_URL the SPA root. + redirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'), + postLogoutRedirectUri: window.location.origin + (import.meta.env.BASE_URL || '/'), + navigateToLoginRequestUrl: true, + }, + cache: { + cacheLocation: 'sessionStorage', + storeAuthStateInCookie: false, + }, + system: { + loggerOptions: { + logLevel: LogLevel.Warning, + piiLoggingEnabled: false, + loggerCallback: (level, message) => { + if (level === LogLevel.Error) console.error(message); + }, + }, + }, +}; + +export const msalInstance = new PublicClientApplication(msalConfig); diff --git a/frontend/src/auth/devBypass.ts b/frontend/src/auth/devBypass.ts new file mode 100644 index 0000000..58bf64e --- /dev/null +++ b/frontend/src/auth/devBypass.ts @@ -0,0 +1,2 @@ +export const DEV_AUTH_BYPASS = + (import.meta.env.VITE_DEV_AUTH_BYPASS ?? '').toLowerCase() === 'true'; diff --git a/frontend/src/components/ChartBlock.tsx b/frontend/src/components/ChartBlock.tsx new file mode 100644 index 0000000..5afebaa --- /dev/null +++ b/frontend/src/components/ChartBlock.tsx @@ -0,0 +1,113 @@ +import { useEffect, useRef } from 'react'; +import { + BarController, + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LineController, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from 'chart.js'; + +ChartJS.register( + BarController, + BarElement, + LineController, + LineElement, + PointElement, + CategoryScale, + LinearScale, + Legend, + Tooltip, +); + +interface ChartSpec { + type: 'bar' | 'hbar' | 'line'; + title?: string; + labels?: (string | number)[]; + series?: { label?: string; data: number[]; color?: string }[]; +} + +interface ChartBlockProps { + spec: ChartSpec; +} + +export default function ChartBlock({ spec }: ChartBlockProps) { + const canvasRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + const isHbar = spec.type === 'hbar'; + const chartType = spec.type === 'line' ? 'line' : 'bar'; + + chartRef.current?.destroy(); + chartRef.current = new ChartJS(canvasRef.current, { + type: chartType, + data: { + labels: spec.labels ?? [], + datasets: (spec.series ?? []).map((s) => ({ + label: s.label ?? '', + data: s.data ?? [], + backgroundColor: s.color ?? '#0071e3', + borderColor: s.color ?? '#0071e3', + borderWidth: chartType === 'line' ? 2 : 0, + borderRadius: chartType === 'bar' ? 3 : 0, + fill: false, + })), + }, + options: { + indexAxis: isHbar ? 'y' : 'x', + responsive: true, + plugins: { + legend: { position: 'top', labels: { font: { size: 11 }, padding: 10, boxWidth: 10 } }, + }, + scales: { + x: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } }, + y: { grid: { color: 'rgba(0,0,0,0.04)' }, ticks: { font: { size: 11 } } }, + }, + }, + }); + + return () => { + chartRef.current?.destroy(); + chartRef.current = null; + }; + }, [spec]); + + return ( +
+ {spec.title &&
{spec.title}
} + +
+ ); +} + +/** + * Pull `chart` fenced JSON blocks out of markdown text. Returns an array of + * { spec, raw } and the markdown with each chart block replaced by a + * placeholder of the form `\n\n[[CHART:N]]\n\n`. The renderer then splits on + * those placeholders and inlines real components. + */ +export function extractCharts(markdown: string): { cleaned: string; specs: ChartSpec[] } { + const fence = /```chart\s*\n([\s\S]*?)```/g; + const specs: ChartSpec[] = []; + let cleaned = markdown; + let m: RegExpExecArray | null; + let i = 0; + // Replace one at a time so indices stay valid. + while ((m = fence.exec(markdown)) !== null) { + try { + const spec = JSON.parse(m[1]) as ChartSpec; + specs.push(spec); + cleaned = cleaned.replace(m[0], `\n\n[[CHART:${i}]]\n\n`); + i += 1; + } catch { + // Streaming may produce partial JSON — leave as a code block. + } + } + return { cleaned, specs }; +} diff --git a/frontend/src/components/ChatPanel.tsx b/frontend/src/components/ChatPanel.tsx new file mode 100644 index 0000000..365993b --- /dev/null +++ b/frontend/src/components/ChatPanel.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from 'react'; +import MessageBubble from './MessageBubble'; +import { apiFetch, streamSSE } from '../auth/apiFetch'; + +interface Message { + id: number; + role: 'user' | 'assistant' | 'system-msg'; + content: string; + streaming?: boolean; + aborted?: boolean; +} + +let nextId = 1; + +export default function ChatPanel() { + const [messages, setMessages] = useState([ + { id: nextId++, role: 'system-msg', content: 'Ask me anything about the programme.' }, + ]); + const [input, setInput] = useState(''); + const [streaming, setStreaming] = useState(false); + const [genStatus, setGenStatus] = useState<{ text: string; ready?: boolean }>({ text: '' }); + const [generating, setGenerating] = useState(false); + const abortRef = useRef(null); + const textareaRef = useRef(null); + const endRef = useRef(null); + + useEffect(() => { + function onSystem(e: Event) { + const detail = (e as CustomEvent).detail; + setMessages((prev) => [...prev, { id: nextId++, role: 'system-msg', content: detail }]); + } + window.addEventListener('pp:system-message', onSystem); + return () => window.removeEventListener('pp:system-message', onSystem); + }, []); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); + }, [messages]); + + function autosize() { + const el = textareaRef.current; + if (!el) return; + el.style.height = '40px'; + el.style.height = Math.min(el.scrollHeight, 120) + 'px'; + } + + function stop() { + abortRef.current?.abort(); + } + + async function send() { + const text = input.trim(); + if (!text || streaming) return; + setInput(''); + requestAnimationFrame(autosize); + + const userMsg: Message = { id: nextId++, role: 'user', content: text }; + const assistantId = nextId++; + setMessages((prev) => [ + ...prev, + userMsg, + { id: assistantId, role: 'assistant', content: '', streaming: true }, + ]); + setStreaming(true); + + const ctrl = new AbortController(); + abortRef.current = ctrl; + + let acc = ''; + const result = await streamSSE( + '/chat/stream', + { message: text }, + (chunk) => { + acc += chunk; + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? { ...m, content: acc } : m)), + ); + }, + ctrl.signal, + ).catch((e) => ({ aborted: false, error: (e as Error).message })); + + abortRef.current = null; + setStreaming(false); + + setMessages((prev) => + prev.map((m) => + m.id === assistantId + ? { + ...m, + content: acc || (result.error ?? ''), + streaming: false, + aborted: result.aborted, + } + : m, + ), + ); + } + + async function generateReports() { + setGenerating(true); + setGenStatus({ text: 'Generating — this takes about 20–30 seconds…' }); + try { + const res = await apiFetch('/generate', { method: 'POST' }); + const data = await res.json(); + if (data.status === 'ok') { + setGenStatus({ text: '✓ Reports ready.', ready: true }); + window.dispatchEvent(new CustomEvent('pp:report-generated')); + } else { + setGenStatus({ text: 'Error: ' + (data.error || 'Unknown error') }); + } + } catch { + setGenStatus({ text: 'Error reaching the server.' }); + } finally { + setGenerating(false); + } + } + + return ( + <> +
+ {messages.map((m) => ( + + ))} +
+
+ +
+
+
+ +
+ + +
+
+
+
+
+ + + + + + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8b2cead --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,33 @@ +"""Shared pytest fixtures. + +Provides a SQLite-backed test database. We point DATABASE_URL at a temp file +before any `src.db` import so the engine binds to the right URL. +""" +from __future__ import annotations + +import os + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def _set_database_url(tmp_path_factory): + """Set DATABASE_URL → SQLite temp file for the whole test session.""" + db_path = tmp_path_factory.mktemp("db") / "test.sqlite" + os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_path}" + yield + + +@pytest.fixture(autouse=True) +def _reset_db(): + """Create a fresh schema for each test.""" + # Import here so DATABASE_URL has been set first. + from src import db as db_module + + # Force a fresh engine in case a previous test mutated module state. + db_module._engine = None + db_module._SessionLocal = None + engine = db_module.get_engine() + db_module.Base.metadata.drop_all(engine) + db_module.Base.metadata.create_all(engine) + yield diff --git a/tests/fixtures/sample_projects.json b/tests/fixtures/sample_projects.json new file mode 100644 index 0000000..1ab1baf --- /dev/null +++ b/tests/fixtures/sample_projects.json @@ -0,0 +1,166 @@ +[ + { + "Project Number": "LLD-001", + "Project Name": "Lancôme Visionnaire Campaign", + "Project Type": "Campaign", + "Brand": "LANCOME", + "Hub": "Global", + "Country": "FR", + "Division": "LLD", + "PM": "Sarah Jones", + "Current Stage": "Stage 3 - Production", + "Project Status": "On Track - OLIVER", + "Project Risk - NEW": "On Track - OLIVER", + "Current Stage Status": "In Progress", + "Project End Date": "2026-05-30", + "Current Stage Deadline": "2026-04-20", + "Action Required": "", + "Last Modified Time (on row)": "2026-04-10T09:00:00.000Z", + "Asset Count (Deliverable Roll Up)": 10, + "Completed Deliverable Count": 7, + "7. Opera Upload": "Not Started", + "7. Opera Upload DDL": "2026-05-25", + "7. Opera Upload Status": "Not Started", + "Has Deadline Slipped": false, + "Syndication Deadline Slip (Days)": 0, + "New Deadline Proposed by Oliver": "", + "Brief Submitted Date": "2026-02-01", + "Brief Acceptance Date (from Briefs Link) 2": "2026-02-03", + "Market's Action Needed": false, + "1. Missing DMI Asset Creation DDL": "2026-02-10", + "1. Missing DMI Asset Creation Status": "Complete", + "2. Mastering, Copy Creation / Extraction DDL": "2026-03-01", + "2. Mastering, Copy Creation / Extraction Status": "Complete", + "3. Global Rollout Invitation DDL": "2026-03-15", + "3. Global Rollout Invitation Status": "Complete", + "4. Translation (Salsify PDP) DDL": "2026-04-01", + "4. Translation (Salsify PDP) Status": "Complete", + "5. Translation (Asset) DDL": "2026-04-10", + "5. Translation (Asset) Status": "In Progress", + "6. Production DDL": "2026-04-20", + "6. Production Status": "In Progress" + }, + { + "Project Number": "LLD-002", + "Project Name": "YSL Beauty Hero Asset", + "Project Type": "Asset Production", + "Brand": "YSL", + "Hub": "EMEA", + "Country": "UK", + "Division": "LLD", + "PM": "", + "Current Stage": "Stage 2 - Development", + "Project Status": "Attention Required - ECF", + "Project Risk - NEW": "Attention Required - OLIVER", + "Current Stage Status": "Delayed", + "Project End Date": "2026-04-01", + "Current Stage Deadline": "2026-03-25", + "Action Required": "Awaiting client feedback", + "Last Modified Time (on row)": "2026-03-28T14:00:00.000Z", + "Asset Count (Deliverable Roll Up)": 5, + "Completed Deliverable Count": 1, + "7. Opera Upload": "Not Started", + "7. Opera Upload DDL": "2026-03-28", + "7. Opera Upload Status": "Not Started", + "Has Deadline Slipped": true, + "Syndication Deadline Slip (Days)": 18, + "New Deadline Proposed by Oliver": "2026-04-15", + "Brief Submitted Date": "2026-01-15", + "Brief Acceptance Date (from Briefs Link) 2": "2026-01-22", + "Market's Action Needed": false, + "1. Missing DMI Asset Creation DDL": "2026-02-01", + "1. Missing DMI Asset Creation Status": "Complete", + "2. Mastering, Copy Creation / Extraction DDL": "2026-03-01", + "2. Mastering, Copy Creation / Extraction Status": "Delayed", + "3. Global Rollout Invitation DDL": "2026-03-15", + "3. Global Rollout Invitation Status": "Not Started", + "4. Translation (Salsify PDP) DDL": "2026-03-20", + "4. Translation (Salsify PDP) Status": "Not Started", + "5. Translation (Asset) DDL": "2026-03-25", + "5. Translation (Asset) Status": "Not Started", + "6. Production DDL": "2026-03-28", + "6. Production Status": "Not Started" + }, + { + "Project Number": "LLD-003", + "Project Name": "Armani Beauty Refresh", + "Project Type": "Refresh", + "Brand": "ARMANI", + "Hub": "Global", + "Country": "IT", + "Division": "LLD", + "PM": "Tom Baker", + "Current Stage": "Stage 4 - Review", + "Project Status": "On Track - Market", + "Project Risk - NEW": "On Track - Market", + "Current Stage Status": "In Review", + "Project End Date": "2026-06-15", + "Current Stage Deadline": "2026-04-30", + "Action Required": "", + "Last Modified Time (on row)": "2026-04-11T11:00:00.000Z", + "Asset Count (Deliverable Roll Up)": 8, + "Completed Deliverable Count": 8, + "7. Opera Upload": "Not Started", + "7. Opera Upload DDL": "2026-06-10", + "7. Opera Upload Status": "Not Started", + "Has Deadline Slipped": false, + "Syndication Deadline Slip (Days)": 0, + "New Deadline Proposed by Oliver": "", + "Brief Submitted Date": "2026-01-20", + "Brief Acceptance Date (from Briefs Link) 2": "2026-01-21", + "Market's Action Needed": true, + "1. Missing DMI Asset Creation DDL": "2026-02-15", + "1. Missing DMI Asset Creation Status": "Complete", + "2. Mastering, Copy Creation / Extraction DDL": "2026-03-10", + "2. Mastering, Copy Creation / Extraction Status": "Complete", + "3. Global Rollout Invitation DDL": "2026-03-25", + "3. Global Rollout Invitation Status": "Complete", + "4. Translation (Salsify PDP) DDL": "2026-04-15", + "4. Translation (Salsify PDP) Status": "In Review", + "5. Translation (Asset) DDL": "2026-04-20", + "5. Translation (Asset) Status": "Not Started", + "6. Production DDL": "2026-04-30", + "6. Production Status": "Not Started" + }, + { + "Project Number": "LLD-004", + "Project Name": "Biotherm Blue Therapy", + "Project Type": "Campaign", + "Brand": "BIOTHERM", + "Hub": "APAC", + "Country": "CN", + "Division": "LLD", + "PM": "Emma Liu", + "Current Stage": "Stage 1 - Briefing", + "Project Status": "Attention Required - Market", + "Project Risk - NEW": "", + "Current Stage Status": "", + "Project End Date": "2026-04-05", + "Current Stage Deadline": "2026-03-30", + "Action Required": "", + "Last Modified Time (on row)": "2026-03-20T08:00:00.000Z", + "Asset Count (Deliverable Roll Up)": 12, + "Completed Deliverable Count": 0, + "7. Opera Upload": "Not Started", + "7. Opera Upload DDL": "2026-04-01", + "7. Opera Upload Status": "Not Started", + "Has Deadline Slipped": true, + "Syndication Deadline Slip (Days)": 7, + "New Deadline Proposed by Oliver": "", + "Brief Submitted Date": "2026-02-10", + "Brief Acceptance Date (from Briefs Link) 2": "", + "Market's Action Needed": true, + "1. Missing DMI Asset Creation DDL": "2026-03-01", + "1. Missing DMI Asset Creation Status": "Not Started", + "2. Mastering, Copy Creation / Extraction DDL": "2026-03-15", + "2. Mastering, Copy Creation / Extraction Status": "Not Started", + "3. Global Rollout Invitation DDL": "2026-03-25", + "3. Global Rollout Invitation Status": "Not Started", + "4. Translation (Salsify PDP) DDL": "2026-03-30", + "4. Translation (Salsify PDP) Status": "Not Started", + "5. Translation (Asset) DDL": "2026-04-01", + "5. Translation (Asset) Status": "Not Started", + "6. Production DDL": "2026-04-05", + "6. Production Status": "Not Started" + } +] diff --git a/tests/test_airtable_client.py b/tests/test_airtable_client.py new file mode 100644 index 0000000..0125280 --- /dev/null +++ b/tests/test_airtable_client.py @@ -0,0 +1,100 @@ +import pytest +from unittest.mock import MagicMock +from src.airtable_client import PulseAirtableClient, _clean_task_name, _clean_related_item + + +def test_clean_task_name_strips_bom(): + assert _clean_task_name("\ufeffMy Task") == "My Task" + + +def test_clean_task_name_strips_whitespace(): + assert _clean_task_name(" Task Name ") == "Task Name" + + +def test_clean_related_item_strips_notion_url(): + raw = "Automation (Enhancement) (https://www.notion.so/Automation-317003329b1e804c9115e1a92617fb01?pvs=21)" + assert _clean_related_item(raw) == "Automation (Enhancement)" + + +def test_clean_related_item_empty(): + assert _clean_related_item("") == "" + assert _clean_related_item(None) == "" + + +def test_clean_related_item_multiple_workstreams(): + raw = "Tools / Technology (https://www.notion.so/Tools?pvs=21), Syndication (Enhancement) (https://www.notion.so/Syndication?pvs=21)" + result = _clean_related_item(raw) + assert "https" not in result + assert "Tools / Technology" in result + assert "Syndication (Enhancement)" in result + assert not result.endswith(",") + + +@pytest.fixture +def mock_table(mocker): + mock_api = mocker.patch("src.airtable_client.Api") + mock_table = MagicMock() + mock_api.return_value.table.return_value = mock_table + return mock_table + + +def test_fetch_all_tasks_normalizes_fields(mock_table): + mock_table.all.return_value = [ + { + "id": "rec1", + "createdTime": "2026-04-08T04:32:44.000Z", + "fields": { + "\ufeffTask": "\ufeffBuild reporting tool", + "Progress": "In Progress", + "Priority": "P1", + "RAG": "Red", + "Owner": [{"id": "usr1", "name": "Tony Coppola", "email": "tony@oliver.agency"}], + "Notes": "In good shape", + }, + } + ] + client = PulseAirtableClient("fake_key", "base_id", "table_id") + tasks = client.fetch_all_tasks() + + assert len(tasks) == 1 + t = tasks[0] + assert t["task"] == "Build reporting tool" + assert t["progress"] == "In Progress" + assert t["priority"] == "P1" + assert t["rag"] == "Red" + assert t["owner"] == "Tony Coppola" + assert t["owners"] == ["Tony Coppola"] + assert t["notes"] == "In good shape" + + +def test_fetch_all_tasks_handles_missing_owner(mock_table): + mock_table.all.return_value = [ + {"id": "rec2", "createdTime": "2026-04-08T00:00:00.000Z", + "fields": {"\ufeffTask": "Some task", "Progress": "Not Started"}} + ] + client = PulseAirtableClient("fake_key", "base_id", "table_id") + tasks = client.fetch_all_tasks() + assert tasks[0]["owner"] == "Unassigned" + assert tasks[0]["owners"] == [] + + +def test_fetch_all_tasks_fallback_task_key(mock_table): + mock_table.all.return_value = [ + {"id": "rec3", "createdTime": "2026-04-08T00:00:00.000Z", + "fields": {"Task": "Plain task name"}} + ] + client = PulseAirtableClient("fake_key", "base_id", "table_id") + tasks = client.fetch_all_tasks() + assert tasks[0]["task"] == "Plain task name" + + +def test_fetch_all_tasks_filters_empty_owner_names(mock_table): + mock_table.all.return_value = [ + {"id": "rec4", "createdTime": "2026-04-08T00:00:00.000Z", + "fields": {"\ufeffTask": "Some task", + "Owner": [{"id": "usr1"}, {"id": "usr2", "name": "Alice"}]}} + ] + client = PulseAirtableClient("fake_key", "base_id", "table_id") + tasks = client.fetch_all_tasks() + assert tasks[0]["owner"] == "Alice" + assert tasks[0]["owners"] == ["Alice"] diff --git a/tests/test_analyzer.py b/tests/test_analyzer.py new file mode 100644 index 0000000..e3d34f1 --- /dev/null +++ b/tests/test_analyzer.py @@ -0,0 +1,111 @@ +import pytest +from datetime import date, timedelta +from src.analyzer import analyze + +TODAY = date.today().isoformat() +YESTERDAY = (date.today() - timedelta(days=1)).isoformat() +NEXT_WEEK = (date.today() + timedelta(days=7)).isoformat() + + +def _task(**kwargs): + defaults = { + "id": "rec1", "task": "Test task", "progress": "In Progress", + "priority": "P1", "rag": "Red", "owner": "Tony Coppola", + "owners": ["Tony Coppola"], "related_item": "", "category": [], + "start_date": "", "end_date": "", "deadline": "", + "doing": "", "blocked_by": "", "blocking": "", "notes": "", "hours": None, + } + defaults.update(kwargs) + return defaults + + +def test_analyze_counts_total(): + tasks = [_task(id="r1"), _task(id="r2"), _task(id="r3", progress="Complete")] + result = analyze(tasks) + assert result["total"] == 3 + assert result["active_total"] == 2 + + +def test_analyze_excludes_complete_and_cancelled_from_active(): + tasks = [ + _task(id="r1", progress="Complete"), + _task(id="r2", progress="Cancelled"), + _task(id="r3", progress="In Progress"), + ] + result = analyze(tasks) + assert result["active_total"] == 1 + + +def test_analyze_red_flags_blocked_and_pending(): + tasks = [ + _task(id="r1", progress="Blocked"), + _task(id="r2", progress="Pending Feedback"), + _task(id="r3", progress="In Progress"), + ] + result = analyze(tasks) + assert len(result["red_flags"]) == 2 + flags = {t["id"] for t in result["red_flags"]} + assert flags == {"r1", "r2"} + + +def test_analyze_p1_watchlist_excludes_complete(): + tasks = [ + _task(id="r1", priority="P1", progress="In Progress", rag="Red"), + _task(id="r2", priority="P1", progress="Complete", rag="Red"), + _task(id="r3", priority="P2", progress="In Progress", rag="Red"), + ] + result = analyze(tasks) + assert len(result["p1_watchlist"]) == 1 + assert result["p1_watchlist"][0]["id"] == "r1" + + +def test_analyze_p1_watchlist_ordered_by_rag(): + tasks = [ + _task(id="r1", priority="P1", progress="In Progress", rag="Green"), + _task(id="r2", priority="P1", progress="In Progress", rag="Red"), + _task(id="r3", priority="P1", progress="In Progress", rag="Amber"), + ] + result = analyze(tasks) + rags = [t["rag"] for t in result["p1_watchlist"]] + assert rags == ["Red", "Amber", "Green"] + + +def test_analyze_groups_by_owner(): + tasks = [ + _task(id="r1", owner="Alice", progress="In Progress"), + _task(id="r2", owner="Bob", progress="In Progress"), + _task(id="r3", owner="Alice", progress="Not Started"), + ] + result = analyze(tasks) + assert set(result["by_owner"].keys()) == {"Alice", "Bob"} + assert len(result["by_owner"]["Alice"]) == 2 + assert len(result["by_owner"]["Bob"]) == 1 + + +def test_analyze_detects_overdue_by_deadline(): + tasks = [ + _task(id="r1", progress="In Progress", deadline=YESTERDAY), + _task(id="r2", progress="In Progress", deadline=NEXT_WEEK), + _task(id="r3", progress="Complete", deadline=YESTERDAY), + ] + result = analyze(tasks) + assert len(result["overdue"]) == 1 + assert result["overdue"][0]["id"] == "r1" + assert result["overdue"][0]["_days_overdue"] >= 1 + + +def test_analyze_overdue_falls_back_to_end_date(): + tasks = [_task(id="r1", progress="In Progress", deadline="", end_date=YESTERDAY)] + result = analyze(tasks) + assert len(result["overdue"]) == 1 + + +def test_analyze_progress_counts(): + tasks = [ + _task(id="r1", progress="In Progress"), + _task(id="r2", progress="In Progress"), + _task(id="r3", progress="Complete"), + ] + result = analyze(tasks) + assert result["progress_counts"]["In Progress"] == 2 + assert result["progress_counts"]["Complete"] == 1 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..2234d14 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,48 @@ +import os + +import pytest + +from src import auth + + +def test_dev_bypass_disabled_by_default(monkeypatch): + monkeypatch.delenv("DEV_AUTH_BYPASS", raising=False) + assert auth.dev_bypass_enabled() is False + + +def test_dev_bypass_enabled(monkeypatch): + monkeypatch.setenv("DEV_AUTH_BYPASS", "true") + assert auth.dev_bypass_enabled() is True + monkeypatch.setenv("DEV_AUTH_BYPASS", "1") + assert auth.dev_bypass_enabled() is True + + +def test_dev_user_claims_shape(): + claims = auth.dev_user_claims() + assert claims["_email"] == "dev@oliver.agency" + assert claims["_name"] == "Dev User" + + +def test_check_domain_allows_when_no_allow_list(monkeypatch): + monkeypatch.delenv("AUTH_ALLOWED_DOMAINS", raising=False) + auth._check_domain("anyone@example.com") # no raise + + +def test_check_domain_allows_listed(monkeypatch): + monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency,oliver.com") + auth._check_domain("alice@oliver.agency") + auth._check_domain("bob@oliver.com") + + +def test_check_domain_rejects_unlisted(monkeypatch): + monkeypatch.setenv("AUTH_ALLOWED_DOMAINS", "oliver.agency") + with pytest.raises(auth.AuthError) as exc: + auth._check_domain("intruder@evil.com") + assert exc.value.status_code == 403 + + +def test_validate_bearer_token_rejects_empty(monkeypatch): + monkeypatch.setenv("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("AZURE_CLIENT_ID", "11111111-1111-1111-1111-111111111111") + with pytest.raises(auth.AuthError): + auth.validate_bearer_token("") diff --git a/tests/test_claude_client.py b/tests/test_claude_client.py new file mode 100644 index 0000000..58b0f9b --- /dev/null +++ b/tests/test_claude_client.py @@ -0,0 +1,130 @@ +import pytest +from unittest.mock import MagicMock +from src.claude_client import ClaudeClient, MODEL + + +@pytest.fixture +def mock_anthropic(mocker): + return mocker.patch("src.claude_client.Anthropic") + + +def _make_response(mock_anthropic, text: str): + text_block = MagicMock() + text_block.type = "text" + text_block.text = text + mock_response = MagicMock() + mock_response.content = [text_block] + mock_anthropic.return_value.messages.create.return_value = mock_response + return mock_response + + +def test_generate_report_returns_text_content(mock_anthropic): + _make_response(mock_anthropic, "## Executive Summary\n\nTest content.") + client = ClaudeClient("fake_key") + result = client.generate_report("system", "user prompt") + assert result == "## Executive Summary\n\nTest content." + + +def test_generate_report_skips_thinking_blocks(mock_anthropic): + thinking_block = MagicMock() + thinking_block.type = "thinking" + thinking_block.text = "internal reasoning not for output" + + text_block = MagicMock() + text_block.type = "text" + text_block.text = "Report output only" + + mock_response = MagicMock() + mock_response.content = [thinking_block, text_block] + mock_anthropic.return_value.messages.create.return_value = mock_response + + client = ClaudeClient("fake_key") + result = client.generate_report("system", "user prompt") + + assert "internal reasoning" not in result + assert result == "Report output only" + + +def test_generate_report_uses_correct_model(mock_anthropic): + _make_response(mock_anthropic, "report") + client = ClaudeClient("fake_key") + client.generate_report("sys", "user") + + call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs + assert call_kwargs["model"] == MODEL + + +def test_generate_report_uses_max_tokens(mock_anthropic): + _make_response(mock_anthropic, "report") + client = ClaudeClient("fake_key") + client.generate_report("sys", "user") + + call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs + assert call_kwargs["max_tokens"] == 4096 + + +def test_generate_report_passes_system_prompt(mock_anthropic): + _make_response(mock_anthropic, "report") + client = ClaudeClient("fake_key") + client.generate_report("my system prompt", "user message") + + call_kwargs = mock_anthropic.return_value.messages.create.call_args.kwargs + assert call_kwargs["system"] == "my system prompt" + + +def test_generate_report_concatenates_multiple_text_blocks(mock_anthropic): + block_a = MagicMock() + block_a.type = "text" + block_a.text = "Part one. " + + block_b = MagicMock() + block_b.type = "text" + block_b.text = "Part two." + + mock_response = MagicMock() + mock_response.content = [block_a, block_b] + mock_anthropic.return_value.messages.create.return_value = mock_response + + client = ClaudeClient("fake_key") + result = client.generate_report("sys", "user") + assert result == "Part one. Part two." + + +def test_chat_returns_text(mocker): + mock_anthropic = mocker.patch("src.claude_client.Anthropic") + mock_client = MagicMock() + mock_anthropic.return_value = mock_client + + mock_block = MagicMock() + mock_block.type = "text" + mock_block.text = "Here is the answer." + mock_client.messages.create.return_value.content = [mock_block] + + from src.claude_client import ClaudeClient + client = ClaudeClient("fake_key") + result = client.chat( + messages=[{"role": "user", "content": "What is blocked?"}], + system_prompt="You are an assistant." + ) + assert result == "Here is the answer." + + +def test_chat_passes_messages_and_system(mocker): + mock_anthropic = mocker.patch("src.claude_client.Anthropic") + mock_client = MagicMock() + mock_anthropic.return_value = mock_client + + mock_block = MagicMock() + mock_block.type = "text" + mock_block.text = "response" + mock_client.messages.create.return_value.content = [mock_block] + + from src.claude_client import ClaudeClient + client = ClaudeClient("fake_key") + messages = [{"role": "user", "content": "hello"}] + client.chat(messages=messages, system_prompt="sys") + + call_kwargs = mock_client.messages.create.call_args.kwargs + assert call_kwargs["system"] == "sys" + assert call_kwargs["messages"] == messages + assert call_kwargs["max_tokens"] == 8192 diff --git a/tests/test_preferences.py b/tests/test_preferences.py new file mode 100644 index 0000000..55f9848 --- /dev/null +++ b/tests/test_preferences.py @@ -0,0 +1,53 @@ +from src.preferences import ( + append_preference, + delete_preference, + list_preferences, + load_preferences, +) + + +def test_load_preferences_empty(): + assert load_preferences("user@oliver.agency") == "" + + +def test_append_and_list(): + pid1 = append_preference("user@oliver.agency", "Prefer concise answers") + pid2 = append_preference("user@oliver.agency", "Always cite the data") + rows = list_preferences("user@oliver.agency") + assert [r["text"] for r in rows] == ["Prefer concise answers", "Always cite the data"] + assert {r["id"] for r in rows} == {pid1, pid2} + + +def test_load_preferences_formats_as_bullets(): + append_preference("user@oliver.agency", "First") + append_preference("user@oliver.agency", "Second") + text = load_preferences("user@oliver.agency") + assert "- First" in text + assert "- Second" in text + + +def test_per_user_isolation(): + append_preference("alice@oliver.agency", "Alice's preference") + append_preference("bob@oliver.agency", "Bob's preference") + alice = list_preferences("alice@oliver.agency") + bob = list_preferences("bob@oliver.agency") + assert [r["text"] for r in alice] == ["Alice's preference"] + assert [r["text"] for r in bob] == ["Bob's preference"] + + +def test_delete_preference(): + pid = append_preference("user@oliver.agency", "To be deleted") + assert delete_preference("user@oliver.agency", pid) is True + assert list_preferences("user@oliver.agency") == [] + + +def test_delete_other_users_preference_fails(): + pid = append_preference("alice@oliver.agency", "Alice's secret") + assert delete_preference("bob@oliver.agency", pid) is False + # Still there + assert len(list_preferences("alice@oliver.agency")) == 1 + + +def test_load_preferences_no_user_returns_empty(): + assert load_preferences(None) == "" + assert load_preferences("") == "" diff --git a/tests/test_prompts.py b/tests/test_prompts.py new file mode 100644 index 0000000..87df66a --- /dev/null +++ b/tests/test_prompts.py @@ -0,0 +1,52 @@ +import json +from src.prompts import build_chat_system_prompt, build_manager_summary_prompt, build_full_report_prompt + +SAMPLE_TASKS = [ + { + "id": "rec1", "task": "Build reporting tool", "progress": "In Progress", + "priority": "P1", "rag": "Red", "owner": "Tony Coppola", "owners": ["Tony Coppola"], + "related_item": "Reporting", "category": ["Airtable"], "start_date": "", + "end_date": "2026-04-30", "deadline": "2026-04-25", "doing": "", + "blocked_by": "", "blocking": "", "notes": "On track", "hours": None, + } +] + +SAMPLE_ANALYSIS = { + "total": 1, "active_total": 1, + "progress_counts": {"In Progress": 1}, + "priority_counts": {"P1": 1}, + "red_flags": [], + "p1_watchlist": [SAMPLE_TASKS[0]], + "by_owner": {"Tony Coppola": [SAMPLE_TASKS[0]]}, + "overdue": [], +} + + +def test_build_chat_system_prompt_includes_task_data(): + prompt = build_chat_system_prompt(SAMPLE_TASKS) + assert "Build reporting tool" in prompt + assert "Tony Coppola" in prompt + + +def test_build_chat_system_prompt_includes_instructions(): + prompt = build_chat_system_prompt(SAMPLE_TASKS) + assert "shorthand" in prompt.lower() or "plain language" in prompt.lower() + + +def test_build_manager_summary_prompt_includes_p1_watchlist(): + prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS) + assert "P1" in prompt or "Watch List" in prompt + + +def test_build_manager_summary_prompt_has_required_sections(): + prompt = build_manager_summary_prompt(SAMPLE_ANALYSIS) + assert "Programme Overview" in prompt + assert "Blockers" in prompt + + +def test_build_full_report_prompt_has_required_sections(): + prompt = build_full_report_prompt(SAMPLE_ANALYSIS) + assert "Executive Summary" in prompt + assert "Team Breakdown" in prompt + assert "Red Flags" in prompt + assert "Overdue" in prompt diff --git a/tests/test_reporter.py b/tests/test_reporter.py new file mode 100644 index 0000000..12ef813 --- /dev/null +++ b/tests/test_reporter.py @@ -0,0 +1,70 @@ +import pytest +from pathlib import Path +from src.reporter import manager_summary_to_markdown, build_manager_summary_docx, build_full_report_docx + +SAMPLE_ANALYSIS = { + "total": 5, "active_total": 3, + "progress_counts": {"In Progress": 2, "Not Started": 1, "Complete": 2}, + "priority_counts": {"P1": 3, "P2": 2}, + "red_flags": [], + "p1_watchlist": [], + "by_owner": {}, + "overdue": [], +} + +SAMPLE_CLAUDE_TEXT = """## Programme Overview + +Things are moving well this week. + +## P1 Watch List + +- Build reporting tool: In Progress — on track + +## Blockers + +No blockers this week. +""" + +SAMPLE_FULL_CLAUDE_TEXT = """## Executive Summary + +5 total tasks, 3 active. + +## Red Flags + +No red flags. + +## Team Breakdown + +Tony Coppola is working on the reporting tool. + +## P1 Watch List + +- Tony — Build reporting tool Red: In Progress — on track + +## Overdue Tasks + +No overdue tasks. +""" + + +def test_manager_summary_to_markdown_contains_header(tmp_path): + md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT) + assert "Programme Pulse" in md + assert "Programme Overview" in md + + +def test_manager_summary_to_markdown_includes_stats(tmp_path): + md = manager_summary_to_markdown(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT) + assert "5" in md # total tasks + + +def test_build_manager_summary_docx_creates_file(tmp_path): + path = build_manager_summary_docx(SAMPLE_ANALYSIS, SAMPLE_CLAUDE_TEXT, tmp_path) + assert path.exists() + assert path.suffix == ".docx" + + +def test_build_full_report_docx_creates_file(tmp_path): + path = build_full_report_docx(SAMPLE_ANALYSIS, SAMPLE_FULL_CLAUDE_TEXT, tmp_path) + assert path.exists() + assert path.suffix == ".docx" diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..b1cff2e --- /dev/null +++ b/web_app.py @@ -0,0 +1,450 @@ +from __future__ import annotations + +import json +import os +import threading +from concurrent.futures import ThreadPoolExecutor +from datetime import date, datetime +from pathlib import Path + +from dotenv import load_dotenv +from flask import Flask, Response, g, jsonify, request, send_file, stream_with_context +from flask_cors import CORS +from sqlalchemy import select + +from src.auth import AuthError, dev_bypass_enabled, dev_user_claims, validate_bearer_token +from src.db import ChatMessage, FeedbackEvent, Preference, Report, db_available, session_scope + +load_dotenv() + +app = Flask(__name__) + +# CORS — frontend lives at /programme-pulse/ on optical-dev (same origin in prod via Apache), +# and at http://localhost:5173 in dev. Authorization header must be allowed. +CORS( + app, + origins=[ + "https://optical-dev.oliver.solutions", + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_headers=["Authorization", "Content-Type"], + expose_headers=["Content-Disposition"], + supports_credentials=False, +) + +PULSE_API_KEY = os.getenv("PULSE_AIRTABLE_API_KEY", "") +PULSE_BASE_ID = os.getenv("PULSE_AIRTABLE_BASE_ID", "") +PULSE_TABLE_ID = os.getenv("PULSE_AIRTABLE_TABLE_ID", "") +RESOURCE_API_KEY = os.getenv("PULSE_RESOURCE_API_KEY", "") or PULSE_API_KEY +RESOURCE_BASE_ID = os.getenv("PULSE_RESOURCE_BASE_ID", "") +RESOURCE_TABLE_ID = os.getenv("PULSE_RESOURCE_TABLE_ID", "") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + +OUTPUT_DIR = Path("reports") +OUTPUT_DIR.mkdir(exist_ok=True) + +# Airtable caches — not user data, fine to keep in-process. /api/refresh rebuilds them. +_snapshot: list[dict] = [] +_snapshot_lock = threading.Lock() +_resource_snapshot: list[dict] = [] +_resource_lock = threading.Lock() + + +def _load_snapshot(): + global _snapshot + if not PULSE_API_KEY: + return + from src.airtable_client import PulseAirtableClient + try: + client = PulseAirtableClient(PULSE_API_KEY, PULSE_BASE_ID, PULSE_TABLE_ID) + data = client.fetch_all_tasks() + except Exception as e: + app.logger.warning("Airtable tasks load failed: %s", e) + return + with _snapshot_lock: + _snapshot = data + + +def _get_snapshot() -> list[dict]: + with _snapshot_lock: + if _snapshot: + return _snapshot + _load_snapshot() + with _snapshot_lock: + return _snapshot + + +def _load_resource_snapshot(): + global _resource_snapshot + if not RESOURCE_API_KEY or not RESOURCE_BASE_ID: + return + from src.airtable_client import ResourceAirtableClient + try: + client = ResourceAirtableClient(RESOURCE_API_KEY, RESOURCE_BASE_ID, RESOURCE_TABLE_ID) + data = client.fetch_all_bookings() + except Exception as e: + app.logger.warning("Airtable bookings load failed: %s", e) + return + with _resource_lock: + _resource_snapshot = data + + +def _get_resource_snapshot() -> list[dict]: + with _resource_lock: + if _resource_snapshot: + return _resource_snapshot + _load_resource_snapshot() + with _resource_lock: + return _resource_snapshot + + +# Eagerly load both data sources in background on startup +threading.Thread(target=_load_snapshot, daemon=True).start() +threading.Thread(target=_load_resource_snapshot, daemon=True).start() + + +# --------------------------------------------------------------------------- +# Auth gate +# --------------------------------------------------------------------------- + +PUBLIC_PATHS = {"/api/health"} + + +@app.before_request +def _auth_gate(): + if request.method == "OPTIONS": + return None + if request.path in PUBLIC_PATHS: + return None + if not request.path.startswith("/api/"): + # We only serve API routes; the SPA is served by Apache from /var/www/html. + return jsonify({"error": "Not found"}), 404 + + if dev_bypass_enabled(): + claims = dev_user_claims() + else: + header = request.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return jsonify({"error": "Missing Authorization: Bearer header"}), 401 + token = header[7:].strip() + try: + claims = validate_bearer_token(token) + except AuthError as e: + return jsonify({"error": str(e)}), e.status_code + + g.user_email = claims["_email"] + g.user_name = claims.get("_name") or claims["_email"] + return None + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.get("/api/health") +def health(): + return jsonify({ + "status": "ok", + "db": db_available(), + "tasks_loaded": len(_snapshot), + "bookings_loaded": len(_resource_snapshot), + }) + + +@app.get("/api/me") +def me(): + return jsonify({"email": g.user_email, "name": g.user_name}) + + +@app.post("/api/chat") +def chat(): + message = (request.json or {}).get("message", "").strip() + if not message: + return jsonify({"error": "Empty message"}), 400 + + tasks = _get_snapshot() + if not tasks: + return jsonify({"response": "No data loaded. Check Airtable credentials."}) + + from src.claude_client import ClaudeClient + from src.prompts import build_chat_system_prompt + + history = _load_history(g.user_email) + history.append({"role": "user", "content": message}) + _save_message(g.user_email, "user", message) + + system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=g.user_email) + claude = ClaudeClient(ANTHROPIC_API_KEY) + response_text = claude.chat(messages=history[-20:], system_prompt=system_prompt) + _save_message(g.user_email, "assistant", response_text) + return jsonify({"response": response_text}) + + +@app.post("/api/chat/stream") +def chat_stream(): + user_email = g.user_email + message = (request.json or {}).get("message", "").strip() + if not message: + return jsonify({"error": "Empty message"}), 400 + + tasks = _get_snapshot() + if not tasks: + def _no_data(): + yield "data: " + json.dumps({"chunk": "No data loaded. Check Airtable credentials."}) + "\n\n" + yield "data: [DONE]\n\n" + return Response(stream_with_context(_no_data()), mimetype="text/event-stream") + + from src.claude_client import ClaudeClient + from src.prompts import build_chat_system_prompt + + history = _load_history(user_email) + history.append({"role": "user", "content": message}) + _save_message(user_email, "user", message) + + system_prompt = build_chat_system_prompt(tasks, _get_resource_snapshot(), user_email=user_email) + claude = ClaudeClient(ANTHROPIC_API_KEY) + + def generate(): + full_response: list[str] = [] + try: + with claude.chat_stream(messages=history[-20:], system_prompt=system_prompt) as stream: + for text in stream.text_stream: + full_response.append(text) + yield "data: " + json.dumps({"chunk": text}) + "\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + yield "data: " + json.dumps({"error": str(e)}) + "\n\n" + yield "data: [DONE]\n\n" + finally: + response_text = "".join(full_response) + if response_text: + _save_message(user_email, "assistant", response_text) + + return Response( + stream_with_context(generate()), + mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.post("/api/generate") +def generate(): + tasks = _get_snapshot() + if not tasks: + return jsonify({"error": "No data loaded"}), 500 + + from src.analyzer import analyze + from src.claude_client import ClaudeClient + from src.prompts import PULSE_SYSTEM_PROMPT, build_full_report_prompt, build_manager_summary_prompt + from src.reporter import build_full_report_docx, build_manager_summary_docx, manager_summary_to_markdown + + analysis = analyze(tasks) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + + def _gen_summary(): + return ClaudeClient(ANTHROPIC_API_KEY).generate_report( + PULSE_SYSTEM_PROMPT, build_manager_summary_prompt(analysis) + ) + + def _gen_full(): + return ClaudeClient(ANTHROPIC_API_KEY).generate_report( + PULSE_SYSTEM_PROMPT, build_full_report_prompt(analysis) + ) + + with ThreadPoolExecutor(max_workers=2) as pool: + f_summary = pool.submit(_gen_summary) + f_full = pool.submit(_gen_full) + summary_text = f_summary.result() + full_text = f_full.result() + + summary_md = manager_summary_to_markdown(analysis, summary_text) + summary_doc = build_manager_summary_docx(analysis, summary_text, OUTPUT_DIR, f"summary-{ts}.docx") + full_doc = build_full_report_docx(analysis, full_text, OUTPUT_DIR, f"full-{ts}.docx") + + with session_scope() as s: + report = Report( + user_email=g.user_email, + summary_md=summary_md, + summary_doc_path=str(summary_doc), + full_doc_path=str(full_doc), + ) + s.add(report) + s.flush() + report_id = report.id + + return jsonify({"status": "ok", "report_id": report_id}) + + +@app.get("/api/copy/summary") +def copy_summary(): + report = _latest_report(g.user_email) + if not report: + return jsonify({"error": "No report generated yet"}), 404 + return jsonify({"markdown": report.summary_md}) + + +@app.get("/api/download/summary") +def download_summary(): + report = _latest_report(g.user_email) + if not report: + return jsonify({"error": "No report generated yet"}), 404 + return _send_report_file(report.summary_doc_path, f"programme-pulse-summary-{date.today()}.docx") + + +@app.get("/api/download/full") +def download_full(): + report = _latest_report(g.user_email) + if not report: + return jsonify({"error": "No report generated yet"}), 404 + return _send_report_file(report.full_doc_path, f"programme-pulse-full-{date.today()}.docx") + + +@app.get("/api/download/history//") +def download_history(report_id: int, which: str): + if which not in ("summary", "full"): + return jsonify({"error": "Invalid type"}), 400 + with session_scope() as s: + report = s.get(Report, report_id) + if report is None or report.user_email != g.user_email: + return jsonify({"error": "Not found"}), 404 + path = report.summary_doc_path if which == "summary" else report.full_doc_path + download_name = f"programme-pulse-{which}-{report.generated_at.strftime('%Y%m%d-%H%M%S')}.docx" + return _send_report_file(path, download_name) + + +@app.get("/api/history") +def history(): + with session_scope() as s: + rows = s.scalars( + select(Report) + .where(Report.user_email == g.user_email) + .order_by(Report.generated_at.desc()) + .limit(10) + ).all() + runs = [ + { + "id": r.id, + "label": r.generated_at.strftime("%-d %b %Y, %H:%M"), + "ts": r.generated_at.strftime("%Y%m%d-%H%M%S"), + } + for r in rows + ] + return jsonify({"runs": runs}) + + +@app.get("/api/preferences") +def get_preferences(): + from src.preferences import list_preferences + return jsonify({"preferences": list_preferences(g.user_email)}) + + +@app.delete("/api/preferences/") +def remove_preference(pref_id: int): + from src.preferences import delete_preference + ok = delete_preference(g.user_email, pref_id) + if not ok: + return jsonify({"error": "Not found"}), 404 + return jsonify({"status": "ok"}) + + +@app.post("/api/feedback") +def feedback(): + body = request.json or {} + rating = body.get("rating") + message_text = (body.get("message") or "").strip() + if not message_text or rating not in ("up", "down"): + return jsonify({"error": "Invalid feedback"}), 400 + + from src.claude_client import ClaudeClient + from src.preferences import append_preference + + direction = "liked" if rating == "up" else "disliked" + extraction_prompt = f"""The user {direction} this assistant response. Extract one concise preference insight that should guide future responses. + +Response the user {direction}: +\"\"\"{message_text}\"\"\" + +Write a single plain sentence starting with an action word, e.g. "Prefer...", "Avoid...", "Always...", "Lead with...". No preamble. Max 20 words.""" + + claude = ClaudeClient(ANTHROPIC_API_KEY) + insight = claude.chat( + messages=[{"role": "user", "content": extraction_prompt}], + system_prompt="You extract concise preference insights from user feedback. One sentence only.", + ) + insight = insight.strip() + pref_id = append_preference(g.user_email, insight) + + with session_scope() as s: + s.add(FeedbackEvent( + user_email=g.user_email, + rating=rating, + message_text=message_text, + extracted_insight=insight, + preference_id=pref_id, + )) + + return jsonify({"status": "ok", "insight": insight}) + + +@app.post("/api/refresh") +def refresh(): + global _snapshot, _resource_snapshot + with _snapshot_lock: + _snapshot = [] + with _resource_lock: + _resource_snapshot = [] + _load_snapshot() + _load_resource_snapshot() + with _snapshot_lock: + count = len(_snapshot) + return jsonify({"status": "ok", "tasks": count}) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _load_history(user_email: str) -> list[dict]: + """Last 20 chat turns for this user, oldest-first, in Anthropic message format.""" + if not db_available(): + return [] + with session_scope() as s: + rows = s.scalars( + select(ChatMessage) + .where(ChatMessage.user_email == user_email) + .order_by(ChatMessage.created_at.desc()) + .limit(20) + ).all() + return [{"role": r.role, "content": r.content} for r in reversed(rows)] + + +def _save_message(user_email: str, role: str, content: str) -> None: + if not db_available(): + return + with session_scope() as s: + s.add(ChatMessage(user_email=user_email, role=role, content=content)) + + +def _latest_report(user_email: str) -> Report | None: + if not db_available(): + return None + with session_scope() as s: + return s.scalars( + select(Report) + .where(Report.user_email == user_email) + .order_by(Report.generated_at.desc()) + .limit(1) + ).first() + + +def _send_report_file(path: str, download_name: str): + p = Path(path) + if not p.exists(): + return jsonify({"error": "File missing on disk"}), 410 + return send_file(str(p), as_attachment=True, download_name=download_name) + + +if __name__ == "__main__": + port = int(os.getenv("PORT", "5051")) + app.run(host="0.0.0.0", port=port, debug=False)